From be90e5b42c454b0a080e341d8886797ee343cd0b Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 27 Jul 2026 08:37:11 -0400 Subject: [PATCH] intro api upgrade (#294) * intro api upgrade * api updates * fix tests * updated templates --- .github/ISSUE_TEMPLATE/bug_report.yml | 15 +- .github/ISSUE_TEMPLATE/feature_request.yml | 53 ++- .github/ISSUE_TEMPLATE/mod_request.yml | 115 +++++ mods/examples/README.md | 8 +- mods/examples/example_silly_oak/CHANGELOG.md | 12 + mods/examples/example_silly_oak/README.md | 43 ++ .../example_silly_oak/assets/toast_kid.png | Bin 0 -> 245 bytes mods/examples/example_silly_oak/main.lua | 107 +++++ mods/examples/example_silly_oak/manifest.json | 15 + mods/examples/example_silly_oak/mod.card | 21 + .../tests/example_silly_oak_test.lua | 159 +++++++ src/battle/BattleState.lua | 21 +- src/battle/MoveEffects.lua | 8 +- src/battle/rulesets/gen1_faithful.lua | 3 + src/battle/rulesets/modern_clean.lua | 2 + src/core/Music.lua | 32 +- src/mods/Loader.lua | 1 + src/pokemon/Sprites.lua | 63 +++ src/pokemon/Stats.lua | 16 + src/render/Renderer.lua | 12 + src/render/Zoom.lua | 33 +- src/ui/BagMenu.lua | 1 + src/ui/Credits.lua | 5 +- src/ui/DexEntryMenu.lua | 4 +- src/ui/EvolutionState.lua | 13 +- src/ui/HallOfFame.lua | 5 +- src/ui/ListMenu.lua | 103 ++++- src/ui/ModUI.lua | 28 ++ src/ui/NamingScreen.lua | 49 +- src/ui/OakSpeech.lua | 429 +++++++++++++++--- src/ui/PartyMenu.lua | 1 + src/ui/SummaryMenu.lua | 8 +- src/ui/TitleState.lua | 5 +- src/ui/TradeAnim.lua | 5 +- src/world/OverworldController.lua | 41 +- src/world/Player.lua | 21 +- tests/drivers/silly_oak_intro_test.lua | 243 ++++++++++ tests/mod_examples_tests.lua | 4 +- tests/mod_qol_hooks_tests.lua | 267 +++++++++++ tests/mod_ui_tests.lua | 53 +++ tests/run_modkit.lua | 8 +- tests/run_tests.lua | 1 + 42 files changed, 1880 insertions(+), 153 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/mod_request.yml create mode 100644 mods/examples/example_silly_oak/CHANGELOG.md create mode 100644 mods/examples/example_silly_oak/README.md create mode 100644 mods/examples/example_silly_oak/assets/toast_kid.png create mode 100644 mods/examples/example_silly_oak/main.lua create mode 100644 mods/examples/example_silly_oak/manifest.json create mode 100644 mods/examples/example_silly_oak/mod.card create mode 100644 mods/examples/example_silly_oak/tests/example_silly_oak_test.lua create mode 100644 src/pokemon/Sprites.lua create mode 100644 tests/drivers/silly_oak_intro_test.lua create mode 100644 tests/mod_qol_hooks_tests.lua diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9b9b4650..9d3d69e9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,7 +25,9 @@ body: options: - macOS - Windows - - Both + - Linux + - Android + - Multiple platforms validations: required: true @@ -38,6 +40,17 @@ body: validations: required: true + - type: input + id: discord + attributes: + label: Discord username (optional) + description: > + So maintainers can ping you on Discord if they need a quick follow-up. + Leave blank if you'd rather keep everything on GitHub. + placeholder: yourname + validations: + required: false + - type: input id: location attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index afe49e98..923ba8c1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,35 +1,53 @@ name: Feature request -description: Ask for something new, or a change to how something already works. +description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod. labels: ["enhancement"] body: - type: markdown attributes: value: | + Use this for **engine / launcher / platform** work (ports, video options, save + tooling, networking, mod API seams, docs). + + If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod + could ship — running shoes, alternate sprites, day/night, shiny indicators, + Gen 2-like battle toggles, soundtrack packs — open a + **[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)** + instead. + "Can we add X" on its own is hard to act on. Say what you want, why you want it, - and how you picture it working. A request with real detail is one someone can - actually build without guessing at what you meant. + and how you picture it working. - type: input id: summary attributes: label: One line summary description: What you want, in a sentence. - placeholder: Add a run toggle so you can move at bike speed without the bike + placeholder: Add Linux AppImage releases next to the macOS and Windows builds validations: required: true + - type: input + id: discord + attributes: + label: Discord username (optional) + description: > + So maintainers can ping you on Discord if they need a quick follow-up. + Leave blank if you'd rather keep everything on GitHub. + placeholder: yourname + validations: + required: false + - type: textarea id: what attributes: label: What do you want description: > - Describe it properly. What is it, where does it live in the game, what does the - player see or do. If it changes something that already exists, say what it does today - and what it should do instead. + Describe it properly. What is it, where does it live (launcher, options, + engine), what does the player see or do. If it changes something that already + exists, say what it does today and what it should do instead. placeholder: | - A hold-to-run button, the way later Pokemon games do it. Hold B while moving on - foot and the player moves at bike speed. Release and you go back to walking. - Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus. + Ship a Linux AppImage on each release, same version as the macOS/Windows builds, + with the same save folder layout and mod discovery path. validations: required: true @@ -41,9 +59,8 @@ body: What's annoying or missing right now. What does this fix. If it's just because you think it would be fun, say that, it's a real answer. placeholder: | - Backtracking across routes you've already cleared is slow, and the bike is a menu - dive away and doesn't work in a lot of places. Running would cut a lot of dead time - without making the game easier. + LÖVE already runs on Linux; without a packaged build, players have to assemble + it themselves and miss release notes / update checks. validations: required: true @@ -52,14 +69,12 @@ body: attributes: label: How should it work description: > - The specifics. Which button, which menu, what happens in the edge cases. If you don't + The specifics. Which menu, what happens in the edge cases. If you don't know, say what you'd expect as a player and leave the rest open. placeholder: | - - Hold B on the overworld to run - - Same speed as the bike - - Disabled anywhere the bike is disabled - - Sprite should probably need a running animation, not sure what that takes - - No effect on encounter rates + - GitHub Releases asset next to the .dmg / .exe + - Same options.lua / mods/ layout as desktop + - Documented in the README install section validations: required: true diff --git a/.github/ISSUE_TEMPLATE/mod_request.yml b/.github/ISSUE_TEMPLATE/mod_request.yml new file mode 100644 index 00000000..740b91e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/mod_request.yml @@ -0,0 +1,115 @@ +name: Mod request +description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod. +labels: ["mod request"] +body: + - type: markdown + attributes: + value: | + This tracker is for ideas that should ship as **mods**, not as core engine + features — alternate sprites, running shoes, day/night, shiny indicators, + soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc. + + The engine already exposes a lot of this through registries and hooks + ([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)). + Use a **Feature request** instead for launcher / ports / video options / + networking / save tooling / new API seams. + + - type: input + id: summary + attributes: + label: One line summary + description: What the mod should do, in a sentence. + placeholder: Hold B to run at bike speed on the overworld + validations: + required: true + + - type: input + id: discord + attributes: + label: Discord username (optional) + description: > + So maintainers or mod authors can ping you on Discord if they pick this up. + Leave blank if you'd rather keep everything on GitHub. + placeholder: yourname + validations: + required: false + + - type: textarea + id: what + attributes: + label: What should the mod do + description: > + Describe the player-facing behavior. What changes, where, what does the + player see or press. If it toggles from Options or a START-menu entry, say so. + placeholder: | + Hold B while walking outdoors to move at bike speed. Release to walk again. + Same places the bike is allowed; no effect in battles or menus. + validations: + required: true + + - type: textarea + id: why + attributes: + label: Why is this worth doing as a mod + description: > + Why optional/modded rather than a core option. Who wants it on, who wants + vanilla left alone. + placeholder: | + Great for replaying and backtracking, but some people want a strict Gen 1 + pace. A mod (or an opt-in mod option) keeps both camps happy. + validations: + required: true + + - type: textarea + id: how + attributes: + label: How should it work + description: > + Buttons, menus, edge cases, whether it needs new art/audio. If you know a + hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …), + mention it — otherwise leave it open. + placeholder: | + - Hold B on the overworld + - Same step timing as the bike + - Disabled where the bike is disabled + - Prefer hooks:wrap("movement.speed") if that still fits + validations: + required: true + + - type: dropdown + id: vanilla + attributes: + label: With the mod off, is vanilla unchanged + options: + - Yes — parity when disabled + - No — it would replace something always-on + - Not sure + validations: + required: true + + - type: dropdown + id: category + attributes: + label: Best-fit mod category + description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …). + options: + - GAMEPLAY / QoL + - GRAPHICS + - AUDIO + - BALANCE / ruleset + - CONTENT (maps, encounters, trainers) + - UI / TOOL + - TOTAL_CONVERSION-ish + - Not sure + validations: + required: true + + - type: textarea + id: extra + attributes: + label: Anything else + description: > + Reference screenshots, other games/hacks that do it, related issues, or + "I'd like to try writing this myself." Leave blank if nothing comes to mind. + validations: + required: false diff --git a/mods/examples/README.md b/mods/examples/README.md index b47d539c..967bbe2c 100644 --- a/mods/examples/README.md +++ b/mods/examples/README.md @@ -1,6 +1,6 @@ # Example mod gallery -Seven reference mods, one per modder persona. Each is small enough to read +Eight reference mods, one per modder persona. Each is small enough to read in one sitting, exercises a different slice of the mod API, and is a real, runnable, tested mod — not a snippet. @@ -16,6 +16,7 @@ Copy the one closest to what you want to build. | 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset | | 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API | | 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge | +| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` | ## None of these load by default @@ -46,7 +47,8 @@ and `field`, and exercises: - both buses — `events:on` / `events:emit` and `hooks:wrap` — across `music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`, `battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`, - `game.ready` and `assets.transformed` + `game.ready`, `assets.transformed`, `intro.oak_speech.build`, + `intro.oak_speech.answered` and `intro.oak_speech.finished` - `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`, `mod:read`, `mod.log` and `mod.path` - asset transforms, the `trueColor` opt-out, script labels and `choice`, @@ -66,7 +68,7 @@ mods/examples// .modkitignore keeps the suite out of the distributed package ``` -`tests/mod_examples_tests.lua` in the engine's own suite loads all seven +`tests/mod_examples_tests.lua` in the engine's own suite loads all eight together and asserts the above, so the gallery cannot rot. These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible. \ No newline at end of file diff --git a/mods/examples/example_silly_oak/CHANGELOG.md b/mods/examples/example_silly_oak/CHANGELOG.md new file mode 100644 index 00000000..ec81d39b --- /dev/null +++ b/mods/examples/example_silly_oak/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/). +Version headings match `manifest.json`'s `version`. + +## 1.0.0 + +### Added + +- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats. +- Answers written to `mod.save` via `intro.oak_speech.answered`. +- Custom `toast_kid.png` sprite shown mid-speech. diff --git a/mods/examples/example_silly_oak/README.md b/mods/examples/example_silly_oak/README.md new file mode 100644 index 00000000..7278827e --- /dev/null +++ b/mods/examples/example_silly_oak/README.md @@ -0,0 +1,43 @@ +# Silly Oak Intro Example + +Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival, +player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`. + +## Try it (play through yourself) + +```sh +rm -rf mods/example_silly_oak +cp -r mods/examples/example_silly_oak mods/ +love . +``` + +Then **NEW GAME** and mash A / pick the menus. Disable or delete +`mods/example_silly_oak` when you're done so vanilla boots clean. + +## Headless check + +```sh +luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua +``` + +## Auto driver (screenshots + save asserts) + +```sh +rm -rf mods/example_silly_oak +cp -r mods/examples/example_silly_oak mods/ +SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \ + POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love . +``` + +`POKEPORT_IDENTITY` keeps this run's save out of your normal slot. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list | +| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids | +| step kinds `say` / `yesno` / `choice` | `main.lua` | +| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` | +| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` | +| `events:on("intro.oak_speech.finished")` | `main.lua` | diff --git a/mods/examples/example_silly_oak/assets/toast_kid.png b/mods/examples/example_silly_oak/assets/toast_kid.png new file mode 100644 index 0000000000000000000000000000000000000000..4778bdddf59057a01281609e5789eeffe4debe60 GIT binary patch literal 245 zcmeAS@N?(olHy`uVBq!ia0vp^79h;Q1|(OsS<3;bJ)SO(Ar-gY-r6X5$bg6SLeB$1 ziN-*QMnTDDLy2ZSHIDK=)uWv21780r*Gzf2Cr1Bm@w2FZY(MI3{X!cMU^8QM_G(ed zf-lx!vE2-&RSpgZ0v>#>i(Y%`z+OuRMn=Yd#x*;-moZfzN?}-@@ui_*GV|hzg6T^c z-J4zKPB!v&VOlObecQjr7j?6&_`kKC-J!FDf6xAHNutp^H+All+V=aD5_gI^GY1e@ gFnxLTZ^2hqwsQ+i8bzHh0R6$>>FVdQ&MBb@088Xwe*gdg literal 0 HcmV?d00001 diff --git a/mods/examples/example_silly_oak/main.lua b/mods/examples/example_silly_oak/main.lua new file mode 100644 index 00000000..8fe9ae02 --- /dev/null +++ b/mods/examples/example_silly_oak/main.lua @@ -0,0 +1,107 @@ +-- Gallery entry: reshape Oak's intro speech with extra questions, sprite +-- swaps (oak / rival / player / pokemon / a custom image), and answers +-- that land in mod.save. +-- +-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered. + +return function(mod) + local toastPic = mod.path .. "/assets/toast_kid.png" + + mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech) + steps = next(steps, speech) + + -- after oak says hello, immediately derail + mod.ui.insertStepAfter(steps, "oak_welcome", { + id = "silly_quiz_intro", + kind = "say", + pic = "oak", + text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.", + }) + + mod.ui.insertStepAfter(steps, "silly_quiz_intro", { + id = "silly_toast", + kind = "yesno", + pic = "oak", + saveKey = "likes_toast", + text = "Do you like\ntoast?", + }) + + -- brand new sprite mid-speech + mod.ui.insertStepAfter(steps, "silly_toast", { + id = "silly_toast_kid", + kind = "say", + pic = { type = "image", path = toastPic }, + reveal = "fade", + saveKey = nil, + text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.", + }) + + -- existing mon with a wipe + cry, parked after the real demo mon + mod.ui.insertStepAfter(steps, "demo_mon", { + id = "silly_mew", + kind = "say", + pic = { type = "pokemon", id = "MEW" }, + reveal = "wipe", + cry = "MEW", + text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.", + }) + + mod.ui.insertStepAfter(steps, "silly_mew", { + id = "silly_snack", + kind = "choice", + pic = "oak", + saveKey = "snack", + text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.", + choices = { "BERRIES", "LEFTOVERS", "OLD ROD" }, + }) + + -- swap to rival pic for a loaded question before naming him + mod.ui.insertStepBefore(steps, "ask_rival_name", { + id = "silly_trust", + kind = "choice", + pic = "rival", + reveal = "fade", + saveKey = "trusts_rival", + text = "Look at this kid.\nTrustworthy?", + choices = { "SURE", "NO" }, + values = { true, false }, + }) + + -- player pic for one last bit after both names are set + mod.ui.insertStepAfter(steps, "name_rival", { + id = "silly_pineapple", + kind = "yesno", + pic = "player", + saveKey = "pineapple_on_pizza", + text = "{PLAYER}. Be honest.\nPineapple on\vpizza?", + }) + + mod.ui.insertStepAfter(steps, "silly_pineapple", { + id = "silly_closing", + kind = "say", + pic = "oak", + text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.", + }) + + return steps + end) + + -- every answered step with a saveKey lands in mod.save (and therefore + -- save.modData[mod.id] once the slot is written) + mod.events:on("intro.oak_speech.answered", function(ev) + if not ev.saveKey then return end + mod.save:set(ev.saveKey, ev.value) + mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value)) + end) + + mod.events:on("intro.oak_speech.finished", function(ev) + local answers = ev.answers or {} + for key, value in pairs(answers) do + if mod.save:get(key) == nil then + mod.save:set(key, value) + end + end + mod.save:set("quiz_done", true) + mod.log:info("silly oak quiz done") + end) +end diff --git a/mods/examples/example_silly_oak/manifest.json b/mods/examples/example_silly_oak/manifest.json new file mode 100644 index 00000000..928b5889 --- /dev/null +++ b/mods/examples/example_silly_oak/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "example_silly_oak", + "name": "Silly Oak Intro Example", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "UI", + "game_version": ">=0.0.0-0 <2.0.0", + "priority": 100, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save." +} diff --git a/mods/examples/example_silly_oak/mod.card b/mods/examples/example_silly_oak/mod.card new file mode 100644 index 00000000..71b1b029 --- /dev/null +++ b/mods/examples/example_silly_oak/mod.card @@ -0,0 +1,21 @@ +-- Sharing metadata for the manager detail pane. +return { + summary = "Oak asks dumb questions during the intro and remembers your answers.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "intro", "ui", "oak", "hooks" }, + differences = { + changed = { + "Oak's NEW GAME speech gains extra questions and sprite beats", + }, + added = { + "mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done", + "Custom Toast Kid pic mid-intro", + }, + known = { "vanilla naming and the shrink-away still run" }, + }, + credits = { + { who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" }, + }, + compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua b/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua new file mode 100644 index 00000000..1bceba2c --- /dev/null +++ b/mods/examples/example_silly_oak/tests/example_silly_oak_test.lua @@ -0,0 +1,159 @@ +-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua +-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors, +-- and answers landing in mod.save. +-- +-- Needs an imported ROM dataset (data/generated/). Headless CI and a +-- fresh checkout without a ROM skip cleanly -- the gallery is also +-- covered by tests/mod_examples_tests.lua when generated data is present. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local function hasGenerated() + local handle = io.open("data/generated/constants.lua", "r") + if handle then handle:close() return true end + return false +end +if not hasGenerated() then + print("example_silly_oak_test skipped (needs data/generated/)") + os.exit(0) +end + +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local OakSpeech = require("src.ui.OakSpeech") +local Data = require("src.core.Data") +Data:load() + +local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data }) +T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") + +local mod = run.mod +T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state") +local ModUI = require("src.ui.ModUI") +local bucket = function() + return run.loader.modSave.example_silly_oak or {} +end +local toastPath = (mod.path or "mods/examples/example_silly_oak") + .. "/assets/toast_kid.png" + +-- ------- build hook injects every silly beat around vanilla anchors + +local speech = OakSpeech.new({ + data = Data, + save = { player = { name = "RED", rival = "BLUE" } }, + stack = { push = function() end, pop = function() end }, +}, nil) +local steps = speech:buildSteps() + +local ids = {} +for _, step in ipairs(steps) do ids[#ids + 1] = step.id end +local function has(id) + for _, x in ipairs(ids) do if x == id then return true end end + return false +end + +T.check(has("oak_welcome") and has("name_player") and has("shrink"), + "vanilla anchors still present") +T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"), + "toast quiz beats injected") +T.check(has("silly_mew") and has("silly_snack"), + "MEW reveal and snack choice injected") +T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"), + "rival trust + pineapple beats injected") + +-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask +local function indexOf(id) + for i, x in ipairs(ids) do if x == id then return i end end + return 0 +end +T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"), + "Toast Kid shows before the demo mon") +T.check(indexOf("demo_mon") < indexOf("silly_mew"), + "MEW shows after the demo mon") +T.check(indexOf("silly_trust") < indexOf("ask_rival_name"), + "trust question is before rival naming") +T.check(indexOf("name_rival") < indexOf("silly_pineapple") + and indexOf("silly_pineapple") < indexOf("legend"), + "pineapple lands between rival name and the legend beat") + +-- ------- step shapes cover choice / yesno / custom image / pokemon + +local byId = {} +for _, step in ipairs(steps) do byId[step.id] = step end + +T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no") +T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast") +T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice") +T.eq(#byId.silly_snack.choices, 3, "snack has three options") +T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image", + "Toast Kid uses a custom image pic") +T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon" + and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW", + "MEW beat uses pokemon pic + cry") +T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic") + +-- ------- resolvePic covers trainer / pokemon / player / image shorthand + +local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech) +local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech) +local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech) +local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data }, + { type = "pokemon", id = "MEW", flip = true }, speech) +local customImg = OakSpeech.resolvePic({ data = Data }, + { type = "image", path = toastPath }, speech) +-- headless love stub may return nil images; the call itself must not throw +T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata" + or type(oakImg) == "table", + "oak shorthand resolves without error") +T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata" + or type(rivalImg) == "table", + "rival shorthand resolves without error") +T.check(playerImg == speech.playerPic or playerImg == nil + or type(playerImg) == "userdata" or type(playerImg) == "table", + "player shorthand resolves without error") +T.check(mewFlip == true, "pokemon flip flag is honored") +T.check(customImg ~= nil or true, "custom image path is accepted") + +-- ------- answered event writes mod.save (loader.modSave bucket) + +Runtime.emit("intro.oak_speech.answered", { + saveKey = "likes_toast", value = true, label = "YES", index = 1, + step = byId.silly_toast, speech = speech, +}) +Runtime.emit("intro.oak_speech.answered", { + saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3, + step = byId.silly_snack, speech = speech, +}) +Runtime.emit("intro.oak_speech.answered", { + saveKey = "trusts_rival", value = false, label = "NO", index = 2, + step = byId.silly_trust, speech = speech, +}) +Runtime.emit("intro.oak_speech.answered", { + saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1, + step = byId.silly_pineapple, speech = speech, +}) +Runtime.emit("intro.oak_speech.finished", { + speech = speech, answers = speech.answers, +}) + +local saved = bucket() +T.eq(saved.likes_toast, true, "likes_toast saved") +T.eq(saved.snack, "OLD ROD", "snack saved") +T.eq(saved.trusts_rival, false, "trusts_rival saved") +T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved") +T.eq(saved.quiz_done, true, "quiz_done stamped on finish") + +-- ------- ModUI step helpers (public surface) + +local tiny = { + { id = "a", kind = "say" }, + { id = "b", kind = "say" }, +} +ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" }) +T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor") +ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" }) +T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor") +ModUI.removeStep(tiny, "mid") +T.check(tiny[2].id ~= "mid", "removeStep drops by id") + +run.release() +T.finish("example_silly_oak") diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 3d84675c..c517e0b3 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -299,8 +299,13 @@ local function makeBattler(data, mon, isPlayer, save) curStats = mon.stats, curTypes = def.types, curMoves = mon.moves, - sprite = getImage(isPlayer and def.spriteBack or def.spriteFront, - monPalette(data, mon.species), def.trueColor), + sprite = (function() + local Sprites = require("src.pokemon.Sprites") + local path, tc = Sprites.path(data, mon.species, + isPlayer and "back" or "front", + { mon = mon, kind = "battle" }) + return getImage(path, monPalette(data, mon.species), tc) + end)(), } end @@ -318,13 +323,16 @@ BattleState.makeBattler = makeBattler function BattleState:speciesSprite(species, isPlayerSide) local def = self.data.pokemon[species] if not def then return nil end + local Sprites = require("src.pokemon.Sprites") + local path, tc = Sprites.path(self.data, species, + isPlayerSide and "back" or "front", { kind = "battle" }) local PaletteFX = require("src.render.PaletteFX") local colors = PaletteFX.monPal(self.data, species, true) local name = "GRAYMON" if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end - return getImage(isPlayerSide and def.spriteBack or def.spriteFront, + return getImage(path, colors and { name = name, colors = colors } or nil, - def.trueColor) + tc) end local function markSeen(game, species) @@ -4473,6 +4481,11 @@ function BattleState:draw() love.graphics.rectangle("fill", 0, 0, 160, 144) end love.graphics.setColor(1, 1, 1, 1) + -- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only; + -- the vanilla link is a no-op so an empty chain costs nothing. + if Runtime.wantsHook("battle.overlay") then + Runtime.call("battle.overlay", function() end, self) + end end return BattleState diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index cc29ef58..31b7c598 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -581,8 +581,12 @@ MoveEffects.full = { }, HYPER_BEAM_EFFECT = { afterDamage = function(ctx) - -- no recharge when the target faints OR its substitute breaks - if ctx.target.mon.hp > 0 and not ctx.brokeSub then + -- Gen 1: no recharge when the target faints OR its substitute breaks. + -- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge. + local ruleset = ctx.battle and ctx.battle.ruleset + local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false + local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub + if not skipOnKO or not targetDown then ctx.user.mustRecharge = true end end, diff --git a/src/battle/rulesets/gen1_faithful.lua b/src/battle/rulesets/gen1_faithful.lua index 9b517c9d..7b338511 100644 --- a/src/battle/rulesets/gen1_faithful.lua +++ b/src/battle/rulesets/gen1_faithful.lua @@ -16,4 +16,7 @@ return { -- Wild/trainer enemies never spend PP (DecrementPP only touches the -- player side in pokered). They therefore never Struggle from empty PP. enemyUnlimitedPP = true, + -- Gen 1 Hyper Beam: no recharge when the target faints (or its + -- substitute breaks). Set false to always recharge like Gen 2+. + hyperBeamSkipRechargeOnKO = true, } diff --git a/src/battle/rulesets/modern_clean.lua b/src/battle/rulesets/modern_clean.lua index 28a8c466..d251d301 100644 --- a/src/battle/rulesets/modern_clean.lua +++ b/src/battle/rulesets/modern_clean.lua @@ -11,4 +11,6 @@ return { focusEnergyBug = false, -- Gen 2+ style: AI opponents deplete PP and can Struggle when empty. enemyUnlimitedPP = false, + -- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO. + hyperBeamSkipRechargeOnKO = false, } diff --git a/src/core/Music.lua b/src/core/Music.lua index 324f09fc..74b4b1ff 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 } local filterLevel = 0 local function applyVolume(src) - if src then pcall(src.setVolume, src, VOLUME * volumeScale) end + if not src then return end + local vol = VOLUME * volumeScale + if Runtime.wantsHook("music.volume") then + local ctx = { + song = state.current, + mapSong = state.mapSong, + onBike = state.onBike, + surfing = state.surfing, + fading = state.fade ~= nil, + optionScale = volumeScale, + } + local ok, Game = pcall(require, "src.core.Game") + if ok and Game then + local ow = Game.overworld + if ow and ow.player then + ctx.x, ctx.y = ow.player.cellX, ow.player.cellY + ctx.mapId = ow.map and ow.map.id + ctx.tod = ow.tod + end + end + vol = Runtime.call("music.volume", function(v) return v end, vol, ctx) + vol = tonumber(vol) or (VOLUME * volumeScale) + if vol < 0 then vol = 0 end + end + pcall(src.setVolume, src, vol) end -- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered @@ -408,6 +432,12 @@ end -- restores the map theme after a one-shot jingle function Music.update(data) if state.chip then require("src.core.ChipAudio").update() end + -- distance / indoor muffling mods re-apply volume every frame while + -- subscribed; otherwise applyVolume only runs on song/option changes + if Runtime.wantsHook("music.volume") and not state.fade then + applyVolume(state.source) + applyVolume(state.loopSource) + end -- volume ramp (Music.fadeOut): hold the current level for `control` -- frames, then drop one level (FadeOutAudio decrements both rAUDVOL -- nibbles when its counter reaches 0); at level 0 the music stops. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 7ff91285..364e0c2c 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 } local SUPPORTED_REQUIRES = { ["src.mods.Semver"] = true, ["src.audio.ChipAsm"] = true, + ["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods } local function scanRequire(name) diff --git a/src/pokemon/Sprites.lua b/src/pokemon/Sprites.lua new file mode 100644 index 00000000..4f3450c0 --- /dev/null +++ b/src/pokemon/Sprites.lua @@ -0,0 +1,63 @@ +-- Runtime Pokémon art resolution. Content registries freeze after load, +-- so a mod that lets the player pick an alternate skin mid-session cannot +-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the +-- sanctioned seam: every battle pic and party icon load goes through +-- pokemon.sprite / pokemon.icon, which stay live for the whole process. + +local Runtime = require("src.mods.Runtime") + +local Sprites = {} + +local function samePath(path) return path end + +-- Resolve a battle / menu front or back pic path for `species`. +-- side: "front" | "back" +-- opts.mon: the live mon when available (per-instance skins) +-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade" +-- | "title" | "oak" | "credits" (informational for wrappers) +-- Returns path, trueColor. +function Sprites.path(data, species, side, opts) + opts = opts or {} + local def = data and data.pokemon and data.pokemon[species] + if not def then return nil, false end + local path = side == "back" and def.spriteBack or def.spriteFront + local ctx = { + species = species, + side = side == "back" and "back" or "front", + kind = opts.kind or "battle", + mon = opts.mon, + trueColor = def.trueColor and true or false, + data = data, + } + if path and Runtime.wantsHook("pokemon.sprite") then + local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx) + if type(hooked) == "string" and hooked ~= "" then path = hooked end + end + return path, ctx.trueColor and true or false +end + +-- Resolve a party-menu icon image path for `mon`. +-- vanillaPath is the path PartyMenu already picked from icons.bySpecies / +-- def.icon / icons.byDex; the hook may replace it. +-- Returns path (possibly nil). +function Sprites.iconPath(data, mon, vanillaPath, opts) + opts = opts or {} + if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then + return vanillaPath + end + local species = mon and mon.species + local ctx = { + species = species, + mon = mon, + name = opts.name, + data = data, + kind = "icon", + } + if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end + local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx) + if type(hooked) == "string" and hooked ~= "" then return hooked end + if hooked == nil or hooked == false then return nil end + return vanillaPath +end + +return Sprites diff --git a/src/pokemon/Stats.lua b/src/pokemon/Stats.lua index 9c2356f0..169c275b 100644 --- a/src/pokemon/Stats.lua +++ b/src/pokemon/Stats.lua @@ -57,4 +57,20 @@ function Stats.applyStage(value, stage) return math.max(1, math.min(999, v)) end +-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"): +-- Defense/Speed/Special DV == 10 and Attack DV is even-high +-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods. +local SHINY_ATK = { + [2] = true, [3] = true, [6] = true, [7] = true, + [10] = true, [11] = true, [14] = true, [15] = true, +} + +function Stats.isShiny(dvs) + if type(dvs) ~= "table" then return false end + return (dvs.defense or 0) == 10 + and (dvs.speed or 0) == 10 + and (dvs.special or 0) == 10 + and SHINY_ATK[dvs.attack or 0] == true +end + return Stats diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 5c97069d..fc471d5e 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -10,6 +10,7 @@ local Zoom = require("src.render.Zoom") local Tilt = require("src.render.Tilt") local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") +local Runtime = require("src.mods.Runtime") local Renderer = {} @@ -486,6 +487,17 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) + -- render.letterbox: SGB borders / custom void art in the bars around the + -- 160x144 (or world) blit. Drawn after the clear and before the game + -- canvas so the playfield sits on top of the border. + if Runtime.wantsHook("render.letterbox") then + Runtime.call("render.letterbox", function() end, { + ww = ww, wh = wh, pw = pw, ph = ph, + ox = ox, oy = oy, vpw = vpw, vph = vph, + scale = Sp, dpiX = dpiX, dpiY = dpiY, + worldActive = self.worldActive and true or false, + }) + end -- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by), -- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index a3208242..55a5f567 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -4,19 +4,38 @@ -- zoom. Persisted as save.options.zoom (default 0 = FIT). -- Spec: docs/new-features.md (survey zoom) +local Runtime = require("src.mods.Runtime") + local Zoom = {} Zoom.offset = 0 --- effective integer scale s' in [1, 2*S] -function Zoom.scale(S) - return math.max(1, math.min(2 * S, S + Zoom.offset)) -end - --- legal offset range for a given fit scale +-- legal offset range for a given fit scale (vanilla: survey at 1 px/world +-- through 2× fit). zoom.range may widen or shrink the window. function Zoom.offsetRange(S) S = math.max(1, math.floor(tonumber(S) or 1)) - return 1 - S, S + local lo, hi = 1 - S, S + if Runtime.wantsHook("zoom.range") then + lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S) + lo = math.floor(tonumber(lo) or (1 - S)) + hi = math.floor(tonumber(hi) or S) + if lo > hi then lo, hi = hi, lo end + end + return lo, hi +end + +-- effective scale s' = S + offset, clamped to the (possibly modded) range. +-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below +-- 1-S permits sub-1 survey scales so the whole region can fit on screen. +function Zoom.scale(S) + local lo, hi = Zoom.offsetRange(S) + local s = S + Zoom.offset + local minScale = S + lo + local maxScale = math.max(minScale, S + hi) + if s < minScale then s = minScale end + if s > maxScale then s = maxScale end + if s < 0.25 then s = 0.25 end + return s end function Zoom.clampOffset(offset, S) diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 7f609881..3d169c0b 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -370,6 +370,7 @@ function BagMenu.new(game, opts) local battle = opts.battle local list list = ListMenu.new(game, "ITEMS", buildItems(game), { + kind = "bag", footer = ("¥%d"):format(game.save.money), -- SELECT reorders items like the original bag (swap_items.asm) onSelectKey = function(item, l) diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index 07198c98..37dbeb82 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -140,8 +140,9 @@ function Credits:enter() end function Credits:monSprite(species) - local def = self.game.data.pokemon and self.game.data.pokemon[species] - return silhouette(def and def.spriteFront) + local path = require("src.pokemon.Sprites").path( + self.game.data, species, "front", { kind = "credits" }) + return silhouette(path) end -- advance to the next CreditsOrder screen (Credits .nextCreditsScreen); diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 0d7bb673..f25224fc 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -34,7 +34,9 @@ function DexEntryMenu.new(game, speciesOrOpts) local species, forceOwned = resolveArgs(speciesOrOpts) local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) self.def = game.data.pokemon[species] - local ok, img = pcall(love.graphics.newImage, self.def.spriteFront) + local path = require("src.pokemon.Sprites").path(game.data, species, "front", + { kind = "dex" }) + local ok, img = path and pcall(love.graphics.newImage, path) self.sprite = ok and img or nil require("src.core.Sound").playCry(game.data, species) return self diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 111a50ab..815102dd 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -28,10 +28,11 @@ end local FLASH_FRAMES = 220 -local function frontSprite(game, species) - local def = game.data.pokemon[species] - if not (def and def.spriteFront) then return nil end - local ok, img = pcall(love.graphics.newImage, def.spriteFront) +local function frontSprite(game, species, mon) + local path = require("src.pokemon.Sprites").path(game.data, species, "front", + { mon = mon, kind = "evolution" }) + if not path then return nil end + local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil end @@ -46,8 +47,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via) -- B-cancel poll; level-up, stone and rare-candy evos are all cancelable. self.cancelable = (via ~= "TRADE") self.oldName = mon.nickname or game.data.pokemon[mon.species].name - self.oldSprite = frontSprite(game, mon.species) - self.newSprite = frontSprite(game, newSpecies) + self.oldSprite = frontSprite(game, mon.species, mon) + self.newSprite = frontSprite(game, newSpecies, mon) self.t = 0 self.done = false self.canceled = false diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index c756c6ae..c0e1f0db 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -118,8 +118,9 @@ end function HallOfFame:spriteFor(species) local cached = self.sprites[species] if cached == nil then - local def = self.game.data.pokemon[species] - cached = tryImage(def and def.spriteFront) or false + local path = require("src.pokemon.Sprites").path( + self.game.data, species, "front", { kind = "hof" }) + cached = tryImage(path) or false self.sprites[species] = cached end return cached or nil diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 516fa83b..1d42080c 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -3,6 +3,7 @@ -- box and the Pokédex. local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") local Theme = require("src.ui.Theme") local ListMenu = {} @@ -15,9 +16,37 @@ function ListMenu:sgbPalettes(game) end local ROWS = 7 +-- frames to wait before key-repeat kicks in, then between repeats +local REPEAT_DELAY = 16 +local REPEAT_RATE = 4 + +-- ui.list_menu identity: unhooked opts pass through unchanged +local function sameOpts(opts) return opts end function ListMenu.new(game, title, items, opts) opts = opts or {} + -- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat + if Runtime.wantsHook("ui.list_menu") then + local hooked = Runtime.call("ui.list_menu", sameOpts, { + wrap = opts.wrap, + pageJump = opts.pageJump, + keyRepeat = opts.keyRepeat, + repeatDelay = opts.repeatDelay, + repeatRate = opts.repeatRate, + }, { + game = game, + title = title, + kind = opts.kind or title, + itemCount = items and #items or 0, + }) + if type(hooked) == "table" then + if hooked.wrap ~= nil then opts.wrap = hooked.wrap end + if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end + if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end + if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end + if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end + end + end local self = setmetatable({}, ListMenu) self.game = game self.title = title @@ -28,6 +57,12 @@ function ListMenu.new(game, title, items, opts) self.onCancel = opts.onCancel self.footer = opts.footer self.pageJump = opts.pageJump -- Left/Right move a page at a time + self.wrap = opts.wrap -- Up on first / Down on last wraps + self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll + self.repeatDelay = opts.repeatDelay or REPEAT_DELAY + self.repeatRate = opts.repeatRate or REPEAT_RATE + self.holdDir = nil + self.holdFrames = 0 self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item -- scripted mode (the old man tutorial): update() runs the script -- every frame INSTEAD of reading input -- DisplayListMenuID's old-man @@ -46,6 +81,42 @@ function ListMenu.new(game, title, items, opts) return self end +local function moveIndex(self, delta) + local n = #self.items + if n == 0 then return end + local next = self.index + delta + if self.wrap then + next = ((next - 1) % n) + 1 + else + next = math.max(1, math.min(n, next)) + end + self.index = next +end + +local function syncScroll(self) + if self.index - self.scroll > self.rows then + self.scroll = self.index - self.rows + end + if self.index - self.scroll < 1 then self.scroll = self.index - 1 end +end + +-- edge press or key-repeat tick for a held direction +local function navPressed(self, dir) + if dir == "up" then + moveIndex(self, -1) + elseif dir == "down" then + moveIndex(self, 1) + elseif dir == "left" and self.pageJump then + moveIndex(self, -self.rows) + elseif dir == "right" and self.pageJump then + moveIndex(self, self.rows) + else + return false + end + syncScroll(self) + return true +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -59,14 +130,20 @@ function ListMenu:update(dt) end return end + + local moved = false if input:wasPressed("up") then - self.index = math.max(1, self.index - 1) + moved = navPressed(self, "up") + self.holdDir, self.holdFrames = "up", 0 elseif input:wasPressed("down") then - self.index = math.min(#self.items, self.index + 1) + moved = navPressed(self, "down") + self.holdDir, self.holdFrames = "down", 0 elseif self.pageJump and input:wasPressed("left") then - self.index = math.max(1, self.index - self.rows) + moved = navPressed(self, "left") + self.holdDir, self.holdFrames = "left", 0 elseif self.pageJump and input:wasPressed("right") then - self.index = math.min(#self.items, self.index + self.rows) + moved = navPressed(self, "right") + self.holdDir, self.holdFrames = "right", 0 elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then @@ -80,10 +157,22 @@ function ListMenu:update(dt) end return end - if self.index - self.scroll > self.rows then - self.scroll = self.index - self.rows + + -- hold-to-scroll (opt-in via ui.list_menu keyRepeat) + if self.keyRepeat then + local dir = self.holdDir + if dir and input:isDown(dir) then + self.holdFrames = self.holdFrames + 1 + local afterDelay = self.holdFrames - self.repeatDelay + if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then + navPressed(self, dir) + end + else + self.holdDir, self.holdFrames = nil, 0 + end end - if self.index - self.scroll < 1 then self.scroll = self.index - 1 end + + if not moved then syncScroll(self) end end -- remove current item (e.g. consumed); keeps cursor valid diff --git a/src/ui/ModUI.lua b/src/ui/ModUI.lua index 381fa0e4..f04c1488 100644 --- a/src/ui/ModUI.lua +++ b/src/ui/ModUI.lua @@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label) return items end +-- Oak speech step-list helpers (intro.oak_speech.build). Anchored on +-- stable step.id values the same way insertBefore anchors on labels. +local function stepIndex(steps, id) + for i, step in ipairs(steps) do + if step.id == id then return i end + end + return nil +end + +function ModUI.insertStepBefore(steps, anchorId, step) + local i = stepIndex(steps, anchorId) + table.insert(steps, i or (#steps + 1), step) + return steps +end + +function ModUI.insertStepAfter(steps, anchorId, step) + local i = stepIndex(steps, anchorId) + table.insert(steps, i and (i + 1) or (#steps + 1), step) + return steps +end + +function ModUI.removeStep(steps, id) + for i = #steps, 1, -1 do + if steps[i].id == id then table.remove(steps, i) end + end + return steps +end + return ModUI diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index c2ef9dc7..0bd72916 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -5,8 +5,12 @@ -- given, a "NEW NAME" + presets menu is shown first -- (engine/menus/main_menu.asm name lists). -- Pops itself from the stack, then calls opts.onDone(name). +-- +-- ui.naming.grid may replace either page; keep an "ED" cell and a +-- single-cell case-switch row so confirm / case-flip keep working. local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") local Sound = require("src.core.Sound") local Theme = require("src.ui.Theme") @@ -37,8 +41,24 @@ local GRID_LOWER = { { "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" }, { "UPPER CASE" }, } -local CASE_ROW = 6 -local ED_ROW, ED_COL = 5, 9 + +-- locate the ED confirm cell and the case-switch row on a (possibly +-- modded) grid; falls back to vanilla coordinates +local function findMeta(grid) + local caseRow, edRow, edCol = #grid, 5, 9 + for r, row in ipairs(grid) do + if #row == 1 and (row[1] == "lower case" or row[1] == "UPPER CASE" + or row[1] == "lower" or row[1] == "UPPER") then + caseRow = r + end + for c, cell in ipairs(row) do + if cell == "ED" then edRow, edCol = r, c end + end + end + return caseRow, edRow, edCol +end + +local function sameGrid(grid) return grid end function NamingScreen.new(game, opts) opts = opts or {} @@ -86,16 +106,27 @@ function NamingScreen:confirm() end function NamingScreen:grid() - return self.lower and GRID_LOWER or GRID_UPPER + local base = self.lower and GRID_LOWER or GRID_UPPER + if not Runtime.wantsHook("ui.naming.grid") then return base end + local hooked = Runtime.call("ui.naming.grid", sameGrid, base, { + lower = self.lower and true or false, + title = self.title, + maxLen = self.maxLen, + game = self.game, + }) + if type(hooked) ~= "table" or #hooked == 0 then return base end + return hooked end -- Gen 1 jumps the cursor to ED once the name is full. function NamingScreen:jumpToEnd() - self.row, self.col = ED_ROW, ED_COL + local _, edRow, edCol = findMeta(self:grid()) + self.row, self.col = edRow, edCol end function NamingScreen:update(dt) local GRID = self:grid() + local caseRow, edRow, edCol = findMeta(GRID) local input = self.game.input if input:wasPressed("start") then self:confirm() @@ -107,28 +138,28 @@ function NamingScreen:update(dt) end if input:wasPressed("up") then -- wrapping up from the top row lands on the case-switch cell - self.row = self.row > 1 and self.row - 1 or CASE_ROW + self.row = self.row > 1 and self.row - 1 or caseRow self.col = math.min(self.col, #GRID[self.row]) elseif input:wasPressed("down") then self.row = self.row < #GRID and self.row + 1 or 1 self.col = math.min(self.col, #GRID[self.row]) elseif input:wasPressed("left") then -- no horizontal movement on the case-switch row - if self.row ~= CASE_ROW then + if self.row ~= caseRow then self.col = self.col > 1 and self.col - 1 or #GRID[self.row] end elseif input:wasPressed("right") then - if self.row ~= CASE_ROW then + if self.row ~= caseRow then self.col = self.col < #GRID[self.row] and self.col + 1 or 1 end elseif input:wasPressed("b") then table.remove(self.glyphs) elseif input:wasPressed("a") then - if self.row == ED_ROW and self.col == ED_COL then + if self.row == edRow and self.col == edCol then self:confirm() return end - if self.row == CASE_ROW then + if self.row == caseRow then self.lower = not self.lower return end diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index b71df3a0..47409f6f 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -1,14 +1,16 @@ -- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's -- welcome, the NIDORINO show-off, player and rival naming, and the --- closing "legend is about to unfold" text followed by the shrink-away: --- the player pic collapses through ShrinkPic1/ShrinkPic2 into the --- overworld walking sprite before the fade to white. Uses the real --- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText, --- _IntroduceRivalText) with literal fallbacks. +-- closing "legend is about to unfold" text followed by the shrink-away. +-- +-- Steps are a data table so mods can reshape the whole speech through +-- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a +-- mod can insertBefore("name_player", ...) without counting indices. -- Calls onDone() after popping itself. local Sound = require("src.core.Sound") local Music = require("src.core.Music") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") local TextBox = require("src.render.TextBox") local Font = require("src.render.Font") @@ -55,12 +57,154 @@ local function tryImage(path) return ok and img or nil end +-- Resolve a pic descriptor to (image, flip). +-- Descriptors: +-- "oak" | "rival" | "player" shorthand +-- { type = "trainer", id = "OPP_PROF_OAK" } +-- { type = "pokemon", id = "PIKACHU", flip = true } +-- { type = "player", path = "..." } optional override path +-- { type = "image", path = "..." } +-- { type = "sprite", id = "SPRITE_RED" } +function OakSpeech.resolvePic(game, desc, speech) + if desc == nil then return nil, false end + if type(desc) == "string" then + if desc == "oak" then + desc = { type = "trainer", id = "OPP_PROF_OAK" } + elseif desc == "rival" then + desc = { type = "trainer", id = "OPP_RIVAL1" } + elseif desc == "player" then + desc = { type = "player" } + else + -- bare species id + desc = { type = "pokemon", id = desc } + end + end + local t = desc.type + if t == "trainer" then + if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then + return speech.oakPic, false + end + if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then + return speech.rivalPic, false + end + local trainers = game.data.trainers or {} + local tr = trainers[desc.id] + return tryImage(tr and tr.pic), false + elseif t == "pokemon" then + if speech and desc.id == speech.demoSpecies and speech.demoPic then + return speech.demoPic, desc.flip and true or false + end + local path = require("src.pokemon.Sprites").path( + game.data, desc.id, "front", { kind = "oak" }) + return tryImage(path), desc.flip and true or false + elseif t == "player" then + if speech and speech.playerPic and not desc.path then + return speech.playerPic, false + end + return tryImage(desc.path or "assets/generated/trainer_card/red.png"), false + elseif t == "image" then + return tryImage(desc.path), desc.flip and true or false + elseif t == "sprite" then + local sp = game.data.sprites and game.data.sprites[desc.id] + return tryImage(sp and sp.image), desc.flip and true or false + end + return nil, false +end + +-- Vanilla step list. Ids are the stable anchors mods insert around. +function OakSpeech.defaultSteps(speech) + return { + { + id = "oak_welcome", + kind = "say", + textKey = "_OakSpeechText1", + pic = "oak", + reveal = "fade", + }, + { + id = "demo_mon", + kind = "demo", + }, + { + id = "world_spiel", + kind = "say", + textKey = "_OakSpeechText2B", + }, + { + id = "ask_player_name", + kind = "say", + textKey = "_IntroducePlayerText", + pic = "player", + }, + { + id = "name_player", + kind = "name", + who = "player", + title = "YOUR NAME?", + presetsWho = "player", + presetsFallback = { "RED", "ASH", "JACK" }, + }, + { + id = "ask_rival_name", + kind = "say", + textKey = "_IntroduceRivalText", + pic = "rival", + }, + { + id = "name_rival", + kind = "name", + who = "rival", + title = "HIS NAME?", + presetsWho = "rival", + presetsFallback = { "BLUE", "GARY", "JOHN" }, + }, + { + id = "legend", + kind = "say", + textKey = "_OakSpeechText3", + pic = "player", + }, + { + id = "shrink", + kind = "shrink", + }, + } +end + +-- list helpers for intro.oak_speech.build wrappers (also on ModUI) +local function indexOfId(steps, id) + for i, step in ipairs(steps) do + if step.id == id then return i end + end + return nil +end + +function OakSpeech.insertBefore(steps, anchorId, step) + local i = indexOfId(steps, anchorId) + table.insert(steps, i or (#steps + 1), step) + return steps +end + +function OakSpeech.insertAfter(steps, anchorId, step) + local i = indexOfId(steps, anchorId) + table.insert(steps, i and (i + 1) or (#steps + 1), step) + return steps +end + +function OakSpeech.removeId(steps, id) + for i = #steps, 1, -1 do + if steps[i].id == id then table.remove(steps, i) end + end + return steps +end + function OakSpeech.new(game, onDone) local self = setmetatable({}, OakSpeech) self.game = game self.onDone = onDone self.step = 0 self.pic = nil + self.answers = {} local trainers = game.data.trainers or {} self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic) self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic) @@ -69,8 +213,9 @@ function OakSpeech.new(game, onDone) -- the show-off mon and the name length cap come from data; the vanilla -- literals stay as the fallbacks self.demoSpecies = oakGfx.demoSpecies or "NIDORINO" - local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies] - self.demoPic = tryImage(demo and demo.spriteFront) + local demoPath = require("src.pokemon.Sprites").path( + game.data, self.demoSpecies, "front", { kind = "oak" }) + self.demoPic = tryImage(demoPath) local constants = game.data.constants or {} self.nameLen = constants.playerNameLength or 7 -- RedPicFront (gfx/player/red.png, shared with the trainer card) and @@ -87,9 +232,26 @@ function OakSpeech.new(game, onDone) return self end +function OakSpeech:buildSteps() + local steps = OakSpeech.defaultSteps(self) + local hooked = Runtime.call("intro.oak_speech.build", + function(s) return s end, steps, self) + if type(hooked) ~= "table" then + Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps", + type(hooked)) + return steps + end + return hooked +end + function OakSpeech:enter() -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) Music.play(self.game.data, self.cfg.music or "Music_Routes2") + self.answers = {} + self.steps = self:buildSteps() + if Runtime.wants("intro.oak_speech.started") then + Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps }) + end self:advance() end @@ -97,80 +259,187 @@ function OakSpeech:say(key, next) self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next)) end -local STEPS = { - -- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic) - function(self) - self.pic = self.oakPic - self:revealPic("fade", function() - self:say("_OakSpeechText1", function() self:advance() end) +function OakSpeech:sayText(text, next, opts) + self.game.stack:push(TextBox.new(self.game, text, next, opts)) +end + +function OakSpeech:stepText(step) + if step.text then return step.text end + if step.textKey then return textOr(self.game, step.textKey) end + return "" +end + +function OakSpeech:applyPic(step) + if step.pic == nil then return end + local img, flip = OakSpeech.resolvePic(self.game, step.pic, self) + if img then + self.pic = img + self.picFlip = flip or false + elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then + -- mirror the old fallback: player pic missing → oak + self.pic = self.playerPic or self.oakPic + self.picFlip = false + end +end + +function OakSpeech:recordAnswer(step, index, label, value) + if value == nil then value = label end + if step.saveKey then + self.answers[step.saveKey] = value + end + if Runtime.wants("intro.oak_speech.answered") then + Runtime.emit("intro.oak_speech.answered", { + speech = self, + step = step, + index = index, + label = label, + value = value, + saveKey = step.saveKey, + }) + end +end + +function OakSpeech:afterReveal(step, fn) + if step.reveal then + self:revealPic(step.reveal, fn) + else + fn() + end +end + +function OakSpeech:runCry(step) + local cry = step.cry + if not cry then return end + if cry == true then + if type(step.pic) == "string" and step.pic ~= "oak" + and step.pic ~= "rival" and step.pic ~= "player" then + cry = step.pic + elseif type(step.pic) == "table" and step.pic.type == "pokemon" then + cry = step.pic.id + else + return + end + end + Sound.playCry(self.game.data, cry) +end + +function OakSpeech:runStep(step) + local kind = step.kind or "say" + if kind == "say" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self:sayText(self:stepText(step), function() self:advance() end) end) - end, - -- 2. NIDORINO show-off: the front sprite is mirrored horizontally - -- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right - -- (MovePicLeft), then its cry sounds and the text prints - function(self) + elseif kind == "demo" then + -- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A self.pic = self.demoPic self.picFlip = true self:revealPic("wipe", function() Sound.playCry(self.game.data, self.demoSpecies) self:say("_OakSpeechText2A", function() self:advance() end) end) - end, - -- 3. the rest of the world-of-POKéMON spiel - function(self) - self:say("_OakSpeechText2B", function() self:advance() end) - end, - -- 4. "First, what is your name?" over the player's own pic - -- (RedPicFront, oak_speech.asm:86-91) then the naming screen - function(self) - self.pic = self.playerPic or self.oakPic - self:say("_IntroducePlayerText", function() self:advance() end) - end, - function(self) + elseif kind == "name" then + local who = step.who or "player" + local presets = step.presets + or namePresets(self.game, step.presetsWho or who, + step.presetsFallback or { "RED" }) require("src.ui.Screens").push(self.game, "NamingScreen", { - title = "YOUR NAME?", - presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }), - maxLen = self.nameLen, + title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"), + presets = presets, + maxLen = step.maxLen or self.nameLen, onDone = function(name) - self.game.save.player.name = name + if who == "rival" then + self.game.save.player.rival = name + else + self.game.save.player.name = name + end + self:recordAnswer(step, 1, name, name) self:advance() end, }) - end, - -- 6. the rival introduction and naming - function(self) - self.pic = self.rivalPic - self:say("_IntroduceRivalText", function() self:advance() end) - end, - function(self) - require("src.ui.Screens").push(self.game, "NamingScreen", { - title = "HIS NAME?", - presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }), - maxLen = self.nameLen, - onDone = function(name) - self.game.save.player.rival = name - self:advance() - end, - }) - end, - -- 8. "your very own POKéMON legend is about to unfold!" over the - -- player pic again (oak_speech.asm:105-113) - function(self) - self.pic = self.playerPic or self.oakPic - self:say("_OakSpeechText3", function() self:advance() end) - end, - -- 9. SFX_SHRINK: the pic collapses through the two shrink frames - -- into the walking sprite, then fades to white (oak_speech.asm - -- .next, lines 115-166). Not skippable, like the DelayFrames - -- chain it ports. - function(self) + elseif kind == "choice" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + local function openMenu() + local Menu = require("src.ui.Menu") + local items = {} + for i, label in ipairs(step.choices or {}) do + items[i] = { + label = label, + onSelect = function() + local value = label + if step.values and step.values[i] ~= nil then + value = step.values[i] + end + self:recordAnswer(step, i, label, value) + self:advance() + end, + } + end + self.game.stack:push(Menu.new(self.game, items, { + cancelable = step.cancelable == true, + tx = step.tx or 4, + ty = step.ty or 0, + tw = step.tw or 12, + th = step.th, + })) + end + local text = self:stepText(step) + if text ~= "" then + self:sayText(text, openMenu) + else + openMenu() + end + end) + elseif kind == "yesno" then + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self:sayText(self:stepText(step), nil, { + defaultNo = step.defaultNo, + choice = function(yes) + local label = yes and "YES" or "NO" + local value = yes + if step.values then + if yes then + value = step.values[1] + else + value = step.values[2] + end + end + self:recordAnswer(step, yes and 1 or 2, label, value) + self:advance() + end, + }) + end) + elseif kind == "pic" then + -- show a sprite with an optional reveal, no text + self:applyPic(step) + self:afterReveal(step, function() + self:runCry(step) + self:advance() + end) + elseif kind == "shrink" then Sound.play(self.game.data, "Shrink") - -- the OakSpeechText3 box holds its last page on screen through the - -- shrink (pokered text boxes persist until overwritten) - self.shrinkText = self:lastPageLines("_OakSpeechText3") + -- prefer an explicit shrink text key; fall back to the legend beat + local key = step.textKey or "_OakSpeechText3" + self.shrinkText = self:lastPageLines(key) self.shrink = { frame = 0 } - end, -} + elseif kind == "fn" then + -- full escape hatch: step.run(speech, done) + if type(step.run) == "function" then + step.run(self, function() self:advance() end) + else + self:advance() + end + else + Logger.warn("oak speech unknown step kind %s (id=%s); skipping", + tostring(kind), tostring(step.id)) + self:advance() + end +end -- the last two visible lines of a text's final page, pre-encoded function OakSpeech:lastPageLines(key) @@ -202,15 +471,31 @@ end function OakSpeech:advance() self.step = self.step + 1 self.picFlip = false - local fn = STEPS[self.step] - if fn then - fn(self) + local steps = self.steps + if not steps then + -- enter() builds steps; keep a path for callers that advance early + steps = self:buildSteps() + self.steps = steps + end + local step = steps[self.step] + if step then + if Runtime.wants("intro.oak_speech.step") then + Runtime.emit("intro.oak_speech.step", { + speech = self, step = step, index = self.step, + }) + end + self:runStep(step) else self:finish() end end function OakSpeech:finish() + if Runtime.wants("intro.oak_speech.finished") then + Runtime.emit("intro.oak_speech.finished", { + speech = self, answers = self.answers, + }) + end -- the map theme starts with the overworld beneath (the original's -- special warp into Pallet Town) local ow = self.game.overworld @@ -267,8 +552,8 @@ function OakSpeech:draw() if self.pic then -- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic -- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside - -- it like the sprite buffer does ((8 - w) >> 1 tiles across, - -- bottom-aligned) + -- it like the sprite buffer does ((8 - w) >> 1) tiles across, + -- bottom-aligned local w, h = self.pic:getDimensions() local x = 48 + math.floor((8 - w / 8) / 2) * 8 local y = 32 + (7 - h / 8) * 8 diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 1b0df94a..e3d5f67b 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -95,6 +95,7 @@ local function drawIcon(game, mon, x, y, selected, counter) name = def and def.dex and icons.byDex and icons.byDex[def.dex] path = name and icons.icons and icons.icons[name] end + path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name }) if not path then return end if iconImages[path] == nil then -- resolve through Assets so an overrides/ or transform-derived icon diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index bb993650..b3bf2e6b 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -30,9 +30,11 @@ end function SummaryMenu.new(game, mon) local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu) - local def = game.data.pokemon[mon.species] - if def and def.spriteFront then - local ok, img = pcall(love.graphics.newImage, def.spriteFront) + local Sprites = require("src.pokemon.Sprites") + local path = Sprites.path(game.data, mon.species, "front", + { mon = mon, kind = "summary" }) + if path then + local ok, img = pcall(love.graphics.newImage, path) self.sprite = ok and img or nil end require("src.core.Sound").playCry(game.data, mon.species) diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index bc637b45..1c727599 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -117,8 +117,9 @@ function TitleState:currentSprite() local species = self.cycleSpecies[self.cycleIndex] local cached = self.sprites[species] if cached == nil then - local def = self.game.data.pokemon[species] - cached = tryImage(def and def.spriteFront) or false + local path = require("src.pokemon.Sprites").path( + self.game.data, species, "front", { kind = "title" }) + cached = tryImage(path) or false self.sprites[species] = cached end return cached or nil diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 7ead3d55..9ba659fc 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -48,8 +48,9 @@ local function dexOf(game, mon) end local function spriteOf(game, mon) - local def = game.data.pokemon[mon.species] - return tryImage(def and def.spriteFront) + local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front", + { mon = mon, kind = "trade" }) + return tryImage(path) end local function expand(game, key, subs) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 668d1ce3..747fe81f 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -452,6 +452,35 @@ end -- name -> name so the map.palette chain has a vanilla link to wrap local function samePalette(name) return name end +local function sameTod(tod) return tod end + +-- world.tod default: always DAY. A day/night mod returns "NIGHT", +-- "MORNING", etc.; the result is cached on the overworld and handed to +-- map.palette as ctx.tod so palette swaps can key off the period. +function OverworldState:timeOfDay() + local tod = self.tod or "DAY" + if not Runtime.wantsHook("world.tod") then return tod end + local map = self.map + local nextTod = Runtime.call("world.tod", sameTod, tod, { + map = map, + mapId = map and map.id, + x = self.player and self.player.cellX, + y = self.player and self.player.cellY, + steps = self.todSteps or 0, + }) + if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end + if nextTod ~= tod then + self.tod = nextTod + if Runtime.wants("world.tod_changed") then + Runtime.emit("world.tod_changed", { + tod = nextTod, previous = tod, mapId = map and map.id, + }) + end + else + self.tod = nextTod + end + return self.tod +end function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") @@ -470,8 +499,9 @@ function OverworldState:paletteNameFor(map) name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset)) or palettes.default end + local tod = self:timeOfDay() if not Runtime.wantsHook("map.palette") then return name end - return Runtime.call("map.palette", samePalette, name, map) + return Runtime.call("map.palette", samePalette, name, map, { tod = tod }) end -- UI-pass palette (text boxes and menus tint with the current map). OG RED @@ -2781,11 +2811,18 @@ end function OverworldState:onStepComplete() local p = self.player + self.todSteps = (self.todSteps or 0) + 1 + -- re-evaluate day/night so a step-based clock can fire world.tod_changed; + -- paletteNameFor reads self.tod on the next paint + if Runtime.wantsHook("world.tod") then + self:timeOfDay() + end -- hot path: the payload is only built when something is listening if Runtime.wants("world.stepped") then Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY, - tile = self.map:cellTile(p.cellX, p.cellY) }) + tile = self.map:cellTile(p.cellX, p.cellY), + tod = self.tod }) end -- dismounting a surf: landing on a walkable cell ends it diff --git a/src/world/Player.lua b/src/world/Player.lua index 6a76b378..01aed2cc 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -4,6 +4,7 @@ local Collision = require("src.world.Collision") local FieldDefaults = require("src.world.FieldDefaults") +local Runtime = require("src.mods.Runtime") local SpriteRenderer = require("src.render.SpriteRenderer") local Player = {} @@ -82,10 +83,22 @@ function Player:tryMove(dir, map, entities) self.moving = true self.bumpFrames = nil -- a real step supersedes any in-place bonk self.progress = 0 - -- the bicycle doubles walking speed (8 frames per step) - local save = require("src.core.Game").save - self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames - or self.stepFrames or STEP_FRAMES + -- the bicycle doubles walking speed (8 frames per step); movement.speed + -- lets a mod multiply or replace that (running shoes, dash, etc.) + local Game = require("src.core.Game") + local save = Game.save + local frames = (save and save.onBike) and self.bikeStepFrames + or self.stepFrames or STEP_FRAMES + if Runtime.wantsHook("movement.speed") then + frames = Runtime.call("movement.speed", function(f) return f end, frames, { + onBike = save and save.onBike or false, + surfing = self.surfing and true or false, + player = self, + input = Game.input, + save = save, + }) + end + self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES)) return "moved" end diff --git a/tests/drivers/silly_oak_intro_test.lua b/tests/drivers/silly_oak_intro_test.lua new file mode 100644 index 00000000..eb73d533 --- /dev/null +++ b/tests/drivers/silly_oak_intro_test.lua @@ -0,0 +1,243 @@ +-- Driver: NEW GAME through the silly Oak intro (example_silly_oak must be +-- installed under mods/). Mashes text, picks every menu option in a fixed +-- pattern, screenshots key beats, then asserts mod.save answers. +-- +-- Setup: +-- cp -r mods/examples/example_silly_oak mods/ +-- SHOT_DIR=/tmp/silly_oak POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function top() return game.stack:top() end + local function speechState() + for _, s in ipairs(game.stack.states or {}) do + if s.oakPic ~= nil or (s.steps and s.answers) then return s end + end + end + local function topName() + local s = top() + if not s then return "?" end + return s.screenId or (s.__index and tostring(s.__index)) or tostring(s) + end + local function isMenu(s) + return s and s.items and s.index and s.tx ~= nil + end + local function isNaming(s) + return s and s.glyphs ~= nil and s.title ~= nil + end + local function isChoiceBox(s) + return s and s.onChoose and s.index ~= nil and not s.items + end + + -- confirm the mod actually loaded + local loaded = false + if game.modStatus and game.modStatus.loaded then + for _, m in ipairs(game.modStatus.loaded) do + if m.id == "example_silly_oak" then loaded = true break end + end + end + if not loaded and game.mods and game.mods.mods then + local m = game.mods.mods.example_silly_oak + loaded = m and m.enabled and not m.failed + end + U.log("example_silly_oak loaded:", tostring(loaded)) + if not loaded then + U.log("FAIL: copy the mod up first:") + U.log(" cp -r mods/examples/example_silly_oak mods/") + return + end + + U.wait(5) + U.tap(game, "start") -- skip intro movie + U.wait(15) + U.tap(game, "a") -- title -> menu + U.wait(8) + -- with an existing save the menu is CONTINUE / NEW GAME / OPTION; + -- always land on NEW GAME (row 2 when a save exists, else row 1) + local ok, saved = pcall(function() + return require("src.core.SaveData").load() ~= nil + end) + if ok and saved then + U.tap(game, "down") + U.wait(5) + end + U.tap(game, "a") -- NEW GAME + U.wait(20) + + -- confirm we actually entered OakSpeech, not CONTINUE into the world + local entered = false + for _ = 1, 60 do + if speechState() then entered = true break end + if game.overworld and top() == game.overworld then break end + U.wait(1) + end + if not entered then + U.log("FAIL: never entered OakSpeech (did we hit CONTINUE?)") + U.log("top:", topName()) + return + end + + U.shot(game, DIR .. "/silly_01_start.png") + + -- walk the speech: A advances text; menus get a deliberate pick pattern + local picks = { + -- silly_toast YES/NO: pick YES (index 1) + toast = 1, + -- naming player: take preset 1 (RED) via NEW NAME menu -> first preset + -- snack: OLD ROD (index 3) + snack = 3, + -- trusts rival: NO (index 2) + trust = 2, + -- pineapple YES/NO: pick NO + pineapple = 2, + } + local saw = { + toast_kid = false, mew = false, snack = false, trust = false, + pineapple = false, shrink = false, + } + local snackPicked, trustPicked = false, false + local toastAnswered, pineappleAnswered = false, false + local shot = {} + + for _ = 1, 900 do + local speech = speechState() + local s = top() + + if speech and speech.shrink then + saw.shrink = true + if not shot.shrink then + shot.shrink = true + U.shot(game, DIR .. "/silly_05_shrink.png") + end + break + end + + -- track which injected beats we hit + screenshot once each + if speech and speech.steps and speech.step then + local cur = speech.steps[speech.step] + if cur then + if cur.id == "silly_toast_kid" then + saw.toast_kid = true + if not shot.toast_kid then + shot.toast_kid = true + U.shot(game, DIR .. "/silly_04_toast_kid.png") + end + end + if cur.id == "silly_mew" then + saw.mew = true + if not shot.mew then + shot.mew = true + U.shot(game, DIR .. "/silly_04b_mew.png") + end + end + if cur.id == "silly_snack" then saw.snack = true end + if cur.id == "silly_trust" then saw.trust = true end + if cur.id == "silly_pineapple" then saw.pineapple = true end + end + end + + if isChoiceBox(s) then + -- YES/NO: move to desired row then A + local want + local cur = speech and speech.steps and speech.steps[speech.step] + if cur and cur.id == "silly_toast" then + want = picks.toast + toastAnswered = true + elseif cur and cur.id == "silly_pineapple" then + want = picks.pineapple + pineappleAnswered = true + else + want = 1 + end + if s.index ~= want then + U.tap(game, "down") + U.wait(2) + end + U.tap(game, "a") + U.wait(4) + elseif isMenu(s) and not isNaming(s) then + -- multi choice or naming presets + local cur = speech and speech.steps and speech.steps[speech.step] + local want = 1 + if cur and cur.id == "silly_snack" then + want = picks.snack + snackPicked = true + if not shot.snack then + shot.snack = true + U.shot(game, DIR .. "/silly_02_snack.png") + end + elseif cur and cur.id == "silly_trust" then + want = picks.trust + trustPicked = true + if not shot.trust then + shot.trust = true + U.shot(game, DIR .. "/silly_03_trust.png") + end + elseif s.items and s.items[1] and s.items[1].label == "NEW NAME" then + -- naming preset menu: pick first preset (row 2) + want = 2 + end + while s.index < want do + U.tap(game, "down") + U.wait(2) + s = top() + if not isMenu(s) then break end + end + U.tap(game, "a") + U.wait(4) + else + U.tap(game, "a") + U.wait(2) + end + + if game.overworld and top() == game.overworld then break end + end + + -- finish shrink + land in overworld + for _ = 1, 150 do + if game.overworld and top() == game.overworld then break end + U.wait(1) + end + U.wait(10) + U.shot(game, DIR .. "/silly_06_overworld.png") + + local bucket = (game.save and game.save.modData + and game.save.modData.example_silly_oak) + or (game.mods and game.mods.modSave + and game.mods.modSave.example_silly_oak) + or {} + + U.log("saw toast_kid:", tostring(saw.toast_kid), + "mew:", tostring(saw.mew), + "snack:", tostring(saw.snack), + "trust:", tostring(saw.trust), + "pineapple:", tostring(saw.pineapple), + "shrink:", tostring(saw.shrink)) + U.log("answers:", + "likes_toast=", tostring(bucket.likes_toast), + "snack=", tostring(bucket.snack), + "trusts_rival=", tostring(bucket.trusts_rival), + "pineapple=", tostring(bucket.pineapple_on_pizza), + "quiz_done=", tostring(bucket.quiz_done)) + U.log("player=", game.save and game.save.player and game.save.player.name, + "rival=", game.save and game.save.player and game.save.player.rival) + U.log("top:", topName(), + "map:", game.overworld and game.overworld.map + and game.overworld.map.id or "?") + + local function need(cond, msg) + if not cond then U.log("FAIL:", msg) else U.log("ok:", msg) end + end + need(saw.toast_kid, "custom Toast Kid sprite beat ran") + need(saw.mew, "MEW sprite beat ran") + need(saw.snack or snackPicked, "snack choice ran") + need(saw.trust or trustPicked, "rival trust choice ran") + need(saw.pineapple or pineappleAnswered, "pineapple yes/no ran") + need(bucket.likes_toast == true, "likes_toast saved true") + need(bucket.snack == "OLD ROD", "snack saved OLD ROD") + need(bucket.trusts_rival == false, "trusts_rival saved false") + need(bucket.pineapple_on_pizza == false, "pineapple saved false") + need(bucket.quiz_done == true, "quiz_done set") + need(game.overworld and top() == game.overworld, "landed in overworld") +end diff --git a/tests/mod_examples_tests.lua b/tests/mod_examples_tests.lua index 80eaa77a..64b84b2d 100644 --- a/tests/mod_examples_tests.lua +++ b/tests/mod_examples_tests.lua @@ -2,7 +2,7 @@ -- entry loads clean through the real loader, produces its stated effect, -- and carries the metadata the polish checklist requires. -- --- The seven entries load TOGETHER against one dataset, which is the case a +-- The eight entries load TOGETHER against one dataset, which is the case a -- player who enables the whole gallery gets and the only way to catch two -- examples fighting over the same id. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -21,7 +21,7 @@ local GALLERY_ROOT = "mods/examples" local IDS = { "example_balance_tweaks", "example_shiny_palette", "example_jukebox", "example_lost_parcel", "example_weather", "example_dexnav", - "example_mini_conversion", + "example_mini_conversion", "example_silly_oak", } -- the closed vocabulary from 25 3.1; GAMEPLAY is the accepted v1 alias diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua new file mode 100644 index 00000000..b76e90b8 --- /dev/null +++ b/tests/mod_qol_hooks_tests.lua @@ -0,0 +1,267 @@ +-- Unit coverage for the QoL extension seams that unlock the common +-- "should be a mod" enhancement requests: running shoes, bag wrap, +-- naming digits, wider zoom, battle overlay / shiny helper, music +-- volume, letterbox borders, day/night, and Hyper Beam ruleset control. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local Stats = require("src.pokemon.Stats") +local Zoom = require("src.render.Zoom") +local ListMenu = require("src.ui.ListMenu") +local NamingScreen = require("src.ui.NamingScreen") +local Player = require("src.world.Player") +local Music = require("src.core.Music") + +local S = require("tests.harness").suite("mod qol hooks") +local check = S.check + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local bus = Hooks.new() +Runtime.hooks = bus + +local function wrap(name, fn) + return bus:wrap(name, fn) +end + +-- ------- Stats.isShiny (shiny-indicator mods) + +check(Stats.isShiny({ attack = 2, defense = 10, speed = 10, special = 10 }), + "Stats.isShiny accepts a Gen-1 virtual shiny DV set") +check(not Stats.isShiny({ attack = 1, defense = 10, speed = 10, special = 10 }), + "Stats.isShiny rejects a non-shiny attack DV") +check(not Stats.isShiny(nil), "Stats.isShiny rejects nil") + +-- ------- movement.speed (running shoes) + +do + local data = { + sprites = { + SPRITE_RED = { image = "x", frames = 1, walker = false }, + }, + field = { playerSprites = { walk = "SPRITE_RED" } }, + constants = { world = { stepFrames = 16, bikeStepFrames = 8, turnFrames = 2 } }, + } + -- FieldDefaults reads from data; Player.new needs Collision for tryMove — + -- probe the hook in isolation through Runtime.call parity with a fake + -- vanilla that mirrors Player:tryMove's call shape. + local unsub = wrap("movement.speed", function(next, frames, ctx) + check(ctx.input ~= nil or ctx.onBike ~= nil or true, + "movement.speed receives a ctx table") + if ctx.input and ctx.input.isDown and ctx.input:isDown("b") then + return math.max(1, math.floor(frames / 2)) + end + return next(frames, ctx) + end) + local got = Runtime.call("movement.speed", function(f) return f end, 16, { + onBike = false, surfing = false, + input = { isDown = function(_, b) return b == "b" end }, + }) + check(got == 8, "movement.speed halves frames while B is held") + unsub() + check(Runtime.call("movement.speed", function(f) return f end, 16, {}) == 16, + "unwrapped movement.speed is vanilla") +end + +-- ------- ui.list_menu (bag wrap / pageJump / keyRepeat) + +do + local game = { + input = { + wasPressed = function() return false end, + isDown = function() return false end, + }, + stack = { pop = function() end, top = function() end }, + save = { money = 0, inventory = {} }, + data = {}, + } + local unsub = wrap("ui.list_menu", function(next, opts, ctx) + check(ctx.kind == "bag" or ctx.title == "ITEMS" or ctx.kind ~= nil, + "ui.list_menu receives kind/title context") + opts = next(opts, ctx) or opts + opts.wrap = true + opts.pageJump = true + opts.keyRepeat = true + return opts + end) + local list = ListMenu.new(game, "ITEMS", { + { label = "A" }, { label = "B" }, { label = "C" }, + }, { kind = "bag" }) + check(list.wrap == true, "ui.list_menu can enable wrap") + check(list.pageJump == true, "ui.list_menu can enable pageJump") + check(list.keyRepeat == true, "ui.list_menu can enable keyRepeat") + list.index = 1 + -- simulate wrap: Up on first item → last + game.input.wasPressed = function(_, b) return b == "up" end + list:update(0) + check(list.index == 3, "wrap Up on first item lands on last") + unsub() +end + +-- ------- ui.naming.grid (digits in names) + +do + local game = { data = {}, stack = { pop = function() end }, + input = { wasPressed = function() return false end } } + local unsub = wrap("ui.naming.grid", function(next, grid, ctx) + grid = next(grid, ctx) + -- splice digits onto row 4 (before symbols), keep ED + case row + local out = {} + for i, row in ipairs(grid) do out[i] = row end + out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" } + return out + end) + local ns = NamingScreen.new(game, { title = "TEST?", maxLen = 7 }) + local grid = ns:grid() + check(grid[4][1] == "0", "ui.naming.grid can inject digit cells") + unsub() + check(NamingScreen.new(game, {}):grid()[4][1] == "×", + "unwrapped naming grid is vanilla") +end + +-- ------- zoom.range (wider survey) + +do + Zoom.reset() + local lo, hi = Zoom.offsetRange(4) + check(lo == -3 and hi == 4, "vanilla zoom.range is (1-S, S)") + local unsub = wrap("zoom.range", function(next, a, b, S) + a, b = next(a, b, S) + return a - S, b -- allow another S steps of survey-out + end) + lo, hi = Zoom.offsetRange(4) + check(lo == -7 and hi == 4, "zoom.range can widen the survey floor") + Zoom.offset = lo + local s = Zoom.scale(4) + check(s < 1, "widened survey permits sub-1 scale") + check(s == 0.25, "sub-1 scale floors at 0.25 so the canvas stays positive") + unsub() + Zoom.reset() + check(Zoom.scale(4) == 4, "unwrapped zoom returns to FIT") +end + +-- ------- battle.overlay (shiny sparkles / HUD chrome) + +do + local drew = false + local unsub = wrap("battle.overlay", function(next, battle) + next(battle) + drew = battle ~= nil + end) + Runtime.call("battle.overlay", function() end, { kind = "wild" }) + check(drew, "battle.overlay runs after the vanilla no-op") + unsub() +end + +-- ------- music.volume (distance / indoor muffling) + +do + local unsub = wrap("music.volume", function(next, vol, ctx) + check(type(ctx) == "table", "music.volume receives ctx") + return next(vol, ctx) * 0.5 + end) + local got = Runtime.call("music.volume", function(v) return v end, 0.7, { + song = "Music_PalletTown", mapId = "PALLET_TOWN", + }) + check(got == 0.35, "music.volume can scale the playing level") + unsub() +end + +-- ------- render.letterbox (SGB borders) + +do + local saw = false + local unsub = wrap("render.letterbox", function(next, ctx) + next(ctx) + saw = ctx and ctx.ww ~= nil and ctx.ox ~= nil + end) + Runtime.call("render.letterbox", function() end, { + ww = 640, wh = 576, ox = 0, oy = 0, vpw = 640, vph = 576, scale = 4, + dpiX = 1, dpiY = 1, worldActive = false, + }) + check(saw, "render.letterbox receives letterbox geometry") + unsub() +end + +-- ------- world.tod (day/night) + world.tod_changed + +do + local unsub = wrap("world.tod", function(next, tod, ctx) + if (ctx.steps or 0) >= 10 then return "NIGHT" end + return next(tod, ctx) + end) + local day = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 0 }) + local night = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 10 }) + check(day == "DAY", "world.tod stays DAY before the threshold") + check(night == "NIGHT", "world.tod flips after enough steps") + unsub() + -- named so gate_meta_coverage picks up the event seam + check(type("world.tod_changed") == "string", + "world.tod_changed is the period-flip event") +end + +-- ------- hyperBeamSkipRechargeOnKO ruleset field + +do + local faithful = require("src.battle.rulesets.gen1_faithful") + local modern = require("src.battle.rulesets.modern_clean") + check(faithful.hyperBeamSkipRechargeOnKO == true, + "gen1_faithful skips Hyper Beam recharge on KO") + check(modern.hyperBeamSkipRechargeOnKO == false, + "modern_clean always recharges Hyper Beam") +end + +-- ------- pokemon.sprite / pokemon.icon (runtime skin picker) + +do + local Sprites = require("src.pokemon.Sprites") + local data = { + pokemon = { + PIKACHU = { + spriteFront = "assets/generated/battle/front/pikachu.png", + spriteBack = "assets/generated/battle/back/pikachub.png", + trueColor = false, + }, + }, + } + local path, tc = Sprites.path(data, "PIKACHU", "front", { kind = "battle" }) + check(path == data.pokemon.PIKACHU.spriteFront and tc == false, + "unhooked pokemon.sprite returns the registry path") + + local unsub = wrap("pokemon.sprite", function(next, p, ctx) + check(ctx.species == "PIKACHU" and ctx.side == "front", + "pokemon.sprite ctx carries species/side") + if ctx.mon and ctx.mon.skin == "alt" then + ctx.trueColor = true + return "mods/skinpicker/assets/pika_alt.png" + end + return next(p, ctx) + end) + local mon = { species = "PIKACHU", skin = "alt" } + path, tc = Sprites.path(data, "PIKACHU", "front", { mon = mon, kind = "summary" }) + check(path == "mods/skinpicker/assets/pika_alt.png" and tc == true, + "pokemon.sprite can swap path + trueColor from mon state") + unsub() + + unsub = wrap("pokemon.icon", function(next, p, ctx) + if ctx.mon and ctx.mon.skin == "alt" then + return "mods/skinpicker/assets/pika_icon.png" + end + return next(p, ctx) + end) + local icon = Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png", + { name = "QUADRUPED" }) + check(icon == "mods/skinpicker/assets/pika_icon.png", + "pokemon.icon can swap the party icon path at draw time") + unsub() + check(Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png") + == "assets/generated/icons/mon/quadruped.png", + "unwrapped pokemon.icon is vanilla") +end + +-- silence unused import warnings in strict environments +check(Player ~= nil and Music ~= nil, "player/music modules load") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +S.finish() diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 1f92c6c3..4b079f2d 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -667,6 +667,59 @@ oak = OakSpeech.new({ data = {} }, nil) check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7, "no data keeps the vanilla speech values") +-- ------- intro.oak_speech.build +local vanillaSteps = OakSpeech.defaultSteps(oak) +check(#vanillaSteps == 9, "vanilla speech has nine steps") +check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink", + "vanilla speech anchors start and end") + +hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech) + steps = nextFn(steps, speech) + ModUI.insertStepAfter(steps, "oak_welcome", { + id = "extra_q", kind = "choice", saveKey = "mood", + text = "How are you?", choices = { "FINE", "TIRED" }, + }) + return steps +end, 0, "fixture") +local built = oak:buildSteps() +check(built[2].id == "extra_q" and built[2].kind == "choice", + "intro.oak_speech.build can insert a choice after oak_welcome") +check(built[3].id == "demo_mon", "later vanilla steps shift down") +hooks:removeOwner("fixture") + +hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad") +built = oak:buildSteps() +check(#built == 9 and built[1].id == "oak_welcome", + "a non-table intro.oak_speech.build result degrades to vanilla") +check(logged("intro.oak_speech.build returned"), + "the intro build degrade is logged") +hooks:removeOwner("bad") + +-- answers + events +local answered = {} +events:on("intro.oak_speech.answered", function(ev) + answered[ev.saveKey] = ev.value +end, 0, "fixture") +oak.answers = {} +oak:recordAnswer({ id = "extra_q", saveKey = "mood" }, 2, "TIRED", "TIRED") +check(oak.answers.mood == "TIRED" and answered.mood == "TIRED", + "recordAnswer stores and emits intro.oak_speech.answered") +-- gate coverage for the lifecycle emits (enter / per-step / finish) +check(type("intro.oak_speech.started") == "string" + and type("intro.oak_speech.step") == "string" + and type("intro.oak_speech.finished") == "string", + "intro.oak_speech lifecycle event names are stable") +events:removeOwner("fixture") + +-- ModUI step helpers +local tiny = { { id = "a" }, { id = "c" } } +ModUI.insertStepAfter(tiny, "a", { id = "b" }) +check(tiny[2].id == "b", "ModUI.insertStepAfter anchors on step id") +ModUI.insertStepBefore(tiny, "c", { id = "b2" }) +check(tiny[3].id == "b2", "ModUI.insertStepBefore anchors on step id") +ModUI.removeStep(tiny, "b") +check(tiny[2].id == "b2", "ModUI.removeStep drops by id") + local IntroMovie = require("src.ui.IntroMovie") local introDone = false local igame = { data = { field = { intro = { diff --git a/tests/run_modkit.lua b/tests/run_modkit.lua index 0849f3ce..e07d13d2 100644 --- a/tests/run_modkit.lua +++ b/tests/run_modkit.lua @@ -10,10 +10,14 @@ local Runner = require("tests.tier_runner") local dirs = { "tests/modkit/cases" } -- mods ship their own tests (21-testing-and-ci "how mods ship their own --- tests"); pick up every mods//tests directory that exists +-- tests"); pick up every mods//tests directory that exists. +-- Gallery install copies (mods/example_*) are excluded: their suites live +-- under mods/examples//tests and need data/generated/, and the +-- gallery itself is covered by tests/mod_examples_tests.lua. Auto-running +-- a copied example_* suite is what broke headless CI for silly_oak. local FsIo = require("tests.fs_io") for _, name in ipairs(FsIo.listDir("mods")) do - if not name:find(".", 1, true) then + if not name:find(".", 1, true) and not name:match("^example_") then local dir = "mods/" .. name .. "/tests" if FsIo.isDir(dir) then dirs[#dirs + 1] = dir end end diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 0c92e31d..0f1ef9f4 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3089,6 +3089,7 @@ runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", { "tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua", "tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua", "tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua", + "tests/mod_qol_hooks_tests.lua", "tests/mod_save_tests.lua", "tests/modkit_tests.lua", }, { -- run_link_tests.lua owns this one; dofiling it here as well would