diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..306289ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,163 @@ +name: ci + +# The ROM-free test run (21-testing-and-ci §CI). +# +# CI has no ROM and never will: data/generated/ is produced by a SHA-1 +# verified import of a cartridge dump, and no ROM bytes are ever committed. +# That is why the suite is tiered -- T1 (primitives), T2 (engine invariants) +# and T4 (mod SDK) run against the committed tests/fixture_data dataset, so +# they need no ROM, no display and no assets beyond what is in the repo. +# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects +# data/generated/ is absent and skips it rather than failing. +# +# Runs alongside release.yml, which is untouched by this file. + +on: + push: + branches: [main] + pull_request: + +# a force-push while CI is mid-run should cancel the stale run, not queue +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + headless: + name: headless suites (no ROM) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is + # written to Lua 5.1 semantics, so CI must run the interpreter the + # game actually ships with or it would green-light 5.4-only syntax. + - name: install luajit + run: sudo apt-get update && sudo apt-get install -y luajit + + - name: interpreter version + run: luajit -v + + - name: run every ROM-free tier + run: ./scripts/test.sh + + fixture-dataset: + name: fixture dataset integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y luajit + - run: python3 -m pip install --upgrade pillow + + # the fixture PNGs are committed (they are 8x8 placeholders, not + # ROM-derived); assert they are still readable 4-shade images rather + # than regenerating them, so a corrupted commit is caught + - name: fixture assets are valid PNGs + run: | + python3 - <<'PY' + import glob, sys + from PIL import Image + paths = sorted(glob.glob("tests/fixture_data/assets/*.png")) + if not paths: + sys.exit("no fixture assets found") + for path in paths: + with Image.open(path) as image: + image.load() + print(f"ok {path} {image.size} {image.mode}") + print(f"\n{len(paths)} fixture assets valid") + PY + + # the fingerprint golden is the parity tripwire; prove it still + # matches the dataset on a clean checkout + - name: fingerprint gate + run: luajit tests/engine/gate_fingerprint.lua + + - name: parity-guarantee meta-test + run: luajit tests/engine/gate_meta_coverage.lua + + # Only the differ is under test here, and the job is named for that. The + # capture half of the golden pipeline does not exist: a POKEPORT_DRIVER + # chunk runs after main.lua has already booted the game, and + # src/core/Data.lua has no POKEPORT_DATA_DIR branch, so no LOVE process + # can be pointed at tests/fixture_data. There is deliberately no step + # here that runs scripts/test.sh with WITH_SHOTS: it would have nothing + # to capture and nothing to diff, and a job that green-lights on skipped + # work is worse than an absent one. + shot-differ: + name: screenshot differ (capture not yet wired) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: python3 -m pip install --upgrade pillow + + # 21-testing-and-ci §"Testing & acceptance criteria": compare_shots + # flags a deliberately corrupted golden and passes the clean one. + # That is the half of the pipeline this repo can actually prove. + - name: compare_shots self-test + run: | + set -e + python3 - <<'PY' + import os + from PIL import Image + os.makedirs("/tmp/g", exist_ok=True) + os.makedirs("/tmp/s", exist_ok=True) + base = Image.new("RGB", (160, 144), (255, 255, 255)) + for x in range(0, 160, 8): + for y in range(0, 144, 8): + base.putpixel((x, y), (0, 0, 0)) + base.save("/tmp/g/clean.png") + base.save("/tmp/s/clean.png") + base.save("/tmp/g/broken.png") + bad = base.copy() + for x in range(40, 60): + for y in range(40, 60): + bad.putpixel((x, y), (255, 0, 0)) + bad.save("/tmp/s/broken.png") + PY + if python3 tools/compare_shots.py /tmp/g /tmp/s; then + echo "compare_shots passed a corrupted golden -- differ is broken" + exit 1 + fi + rm /tmp/g/broken.png /tmp/s/broken.png + python3 tools/compare_shots.py /tmp/g /tmp/s + + # an empty golden directory must not read as success + - name: differ refuses to pass vacuously + run: | + set -e + mkdir -p /tmp/empty-goldens /tmp/empty-shots + if python3 tools/compare_shots.py /tmp/empty-goldens /tmp/empty-shots; then + echo "compare_shots passed with no goldens" + exit 1 + fi + + lint: + name: mod lint (no ROM-derived content) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # the MK305 dump check key-diffs shipped tables through luajit, and + # modkit treats a missing interpreter as a fatal MK100 -- without + # this install the gate would fail instead of failing open + - run: sudo apt-get update && sudo apt-get install -y luajit + - run: python3 -m pip install --upgrade pillow + + # constraint 1, enforced automatically: a committed mod that ships + # ROM-derived bytes fails the build. `lint` is the no-ROM-content + # check; `validate` is deliberately not run here because it resolves + # a mod against the fixture dataset, and a Red-content mod such as + # example_mew_starter legitimately does not resolve against it. + - name: lint every committed mod + run: | + set -e + found=0 + for mod in mods/*/; do + [ -f "$mod/manifest.json" ] || continue + found=1 + echo "== $mod" + python3 tools/modkit.py lint "${mod%/}" + done + if [ "$found" = "0" ]; then + echo "no committed mods to lint" + fi diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md new file mode 100644 index 00000000..d11c7a33 --- /dev/null +++ b/CONTRIBUTING-mods.md @@ -0,0 +1,326 @@ +# Contributing to the mod platform + +Two routes + +| You are... | Lane | Review bar | +|---|---|---| +| adding a mod to the gallery, or listing one in the showcase | [Lane A](#route-a--contributing-a-mod) | template + polish checklist + green `modkit validate` | +| changing the loader, a registry schema, an event/hook name, or a manifest field | [Lane B](#route-b--contributing-an-engine--mod-api-change) | RFC + backward-compat statement + parity test + generated docs | + +If you are not sure which lane you are in, ask this: **could my change make +somebody else's existing mod behave differently?** If yes, it is Lane B. + +--- + +## Route A — contributing a mod + +### 1. Scaffold + +```sh +python3 tools/modkit.py scaffold my_mod --profile content +``` + +`--profile` is one of `content`, `overhaul`, `total_conversion`. The +scaffold refuses to overwrite an existing directory, and prints the next +commands. + +Or copy the gallery entry closest to your intent — that is what the gallery +is for: + +| You want to... | Copy | +|---|---| +| change numbers | `mods/examples/example_balance_tweaks` | +| change art | `mods/examples/example_shiny_palette` | +| add music or cries | `mods/examples/example_jukebox` | +| add a quest, NPC or dialogue | `mods/examples/example_lost_parcel` | +| change how battles work | `mods/examples/example_weather` | +| add a screen or a tool | `mods/examples/example_dexnav` | +| build a whole new game | `mods/examples/example_mini_conversion` | + +### 2. What the PR must contain + +1. **A green `modkit validate`.** CI runs it; so should you. + + ```sh + python3 tools/modkit.py validate mods/examples/ --base imported + python3 tools/modkit.py lint mods/examples/ + ``` + + `validate` drives the *real* loader headlessly, so a mod that passes + here does not surface load errors in game. `--base imported` folds + against the full vanilla id space; without it, rules that can only be + decided against real Red content (`MK103`, the patch-target check) are + reported as skipped rather than guessed at. + +2. **A `tests/` directory** with at least one suite that loads the mod + through the headless loader and asserts its *stated effect* — not just + that it loaded. + + ```lua + package.path = "./?.lua;./?/init.lua;" .. package.path + local T = require("tests.modkit") + local Data = require("src.core.Data"); Data:load() + local run = T.sdk.loadMod("mods/examples/my_mod", { data = Data }) + T.eq(#run.errors, 0, "loads clean") + T.eq(Data.pokemon.PIKACHU.baseStats.speed, 120, "the patch landed") + run.release() + T.finish("my_mod") + ``` + + Add a `.modkitignore` listing the suite so it stays out of the + distributed package — a test requiring engine modules is a + private-require finding against the shipped archive, and `pack` treats + warnings as fatal. + +3. **A `README.md`** that opens with one sentence saying what the mod does, + names its persona, and gives the three commands to try it. No + prerequisites the scaffold did not already create. + +4. **A `mod.card`** meeting the [§3.2 schema](#modcard): + + ```lua + return { + summary = "One sentence, <=100 chars.", + author = "Your handle", -- never blank; no author is anonymous by omission + tags = { "balance", "beginner" }, + differences = { changed = {…}, added = {…}, known = {…} }, + credits = { { who = "…", for_ = "original chiptune arrangement" } }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, + } + ``` + +5. **A `CHANGELOG.md`** in keep-a-changelog format, with a heading matching + `manifest.version`. `validate` warns when the version advanced without + one. + +6. **Disabled by default.** Gallery entries live in `mods/examples/`, which + the loader's one-level discovery does not walk, so a fresh install + discovers none of them and the vanilla game is unchanged. + +7. **No ROM-derived bytes.** Art and audio ship as originals or as a + `transforms.lua` operating on the player's own cache. `modkit lint` + is the hard floor; see + [the legal posture](#legal-posture-non-negotiable). + +CI checks 1, 2, 6 and 7 mechanically. A reviewer checks 3, 4, 5 and the +polish checklist. + +### 3. Category + +`manifest.category` is a closed vocabulary. An unknown value is a warning, +not a hard error, so the list can grow without breaking old mods. + +| category | Meaning | Typical profile | +|---|---|---| +| `TWEAK` | Small data edits: stats, prices, learnsets, encounter tables | content | +| `BALANCE` | Systematic rebalance across many records or a ruleset | content / overhaul | +| `CONTENT` | New species / moves / items / maps / trainers | content | +| `QUEST` | New story, NPCs, dialogue, cutscenes | content | +| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul | +| `GRAPHICS` | Sprite / tileset / palette / font changes | content | +| `AUDIO` | Music, sfx, cries | content | +| `UI` | New or modified screens, menus, overlays | content / overhaul | +| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content | +| `TOTAL_CONVERSION` | Full re-theme; owns its own tri-ledger | total_conversion | +| `OTHER` | Fallback | any | + +`GAMEPLAY` is accepted as an alias for `TWEAK`, so `example_mew_starter` +keeps validating with the value it has shipped since before the taxonomy +existed. + +### 4. `mod.card` + +The manifest is the *engine's* contract: identity, load order, dependencies, +permissions, profile. The card is the *human-facing* one: who made this, +what it changes, what it does not do yet. It is never read by the loader's +merge — only by tooling and the manager's detail pane — so an absent or +malformed card can never break a load. + +Two fields deserve their own note: + +- **`differences`** is a self-declared tri-ledger, mirroring the discipline + the engine holds itself to. `changed` and `added` let a player see the + blast radius before installing; `known` is where you are honest about + what is rough. A card with an empty `known` on a complex mod reads as + carelessness, not polish. +- **`screenshots[].transform`** describes a screenshot by the *driver + script* that regenerates it from the player's build, rather than shipping + the pixels. That is the legal posture extended to your marketing: a + distributed mod never carries ROM-derived bytes, not even in its preview + images. + +### 5. Tags + +Lowercase kebab strings, open vocabulary. The showcase generator +lowercases and de-dupes. A recommended starting set: `beginner`, +`data-only`, `quality-of-life`, `hardcore`, `cosmetic`, `story`, `ruleset`, +`audio`, `ui`, `total-conversion`. + +--- + +## Route B — contributing an engine / mod-API change + +Changing the loader, a registry schema, an event or hook name, or a manifest +field touches the **compatibility surface** the project promises to hold +stable. Those PRs carry five obligations. + +### 1. An RFC + +`docs/rfcs/NNNN-.md`, covering: + +- **Motivation** — the mod that cannot be written today. +- **The decision it extends or amends** — name the D-number and the plan + file, so the change is traceable to the design it modifies. +- **The exact API delta** — new registry names, new schema fields, new + event/hook names and their payload shapes and call sites. +- **A migration note for existing mods** — what an author has to do, if + anything. "Nothing" is a valid and preferred answer. + +### 2. A backward-compatibility statement + +Show that the v1 surface still works: `content.X:register/override/get`, +`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields, +and `pokemon.before_give`. + +**A change that would break a v1 mod is rejected unless it is +additive-with-alias.** `mods/example_mew_starter` is the live proof: it is +api 1, uses `category = "GAMEPLAY"`, copies a whole species record because +`patch` did not exist yet, and it must keep loading unchanged. + +### 3. A parity-guarantee test + +Two tests, not one: + +- **The no-mod test** — vanilla behavior is unchanged with nothing + installed. A new hook with no subscriber must return the vanilla value; + a new registry must be a provable no-op when empty; a new event must not + allocate its payload when nothing wants it (`Runtime.wants(name)` / + `Runtime.wantsHook(name)` guard the hot paths). +- **The mod-API test** — the new seam, exercised through the *public* mod + API rather than by reaching into internals. If the test has to require a + private module to drive your seam, the seam is not finished. + +### 4. Docs with the change + +The reference pages are generated from `src/mods/Schemas.lua`, so a new +registry or a new schema field lands with its catalog entry in the same PR +and the generator runs clean: + +```sh +luajit tools/gen_registry_docs.lua # in-repo default +luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout +``` + +The prose reference lives in the GitHub wiki; the generated pages are +written into a checkout of it, so they cannot drift from the engine. + +### 5. Deprecation etiquette + +**Nothing is removed.** A superseded seam is marked deprecated in the +generated reference with its replacement named, keeps firing and working, +and is listed in the deprecations page. + +`pokemon.before_give` is the worked precedent: the `pokemon.give` hook +supersedes it, and it is grandfathered forever anyway. + +### Review + +PRs touching `src/mods/`, `src/mods/Schemas.lua`, or the event/hook catalog +need the RFC label and a green parity gate before merge. + +--- + +## The polish checklist + +Every gallery example and every community mod the guide recommends meets +this bar. `[auto]` items are checked by `modkit validate`; `[review]` items +by a human. + +### Error messages + +- `[auto]` No bare `error()` or `assert()` in mod callbacks. Every failure + path uses `mod.log:warn` / `mod.log:error` — the loader already prefixes + `[modid]` — **and names a remediation**: + + ```lua + -- no + local mew = assert(mod.content.pokemon:get("MEW"), "Mew is missing") + + -- yes + if not mod.content.pokemon:get("MEW") then + mod.log:warn("MEW missing from the merged view -- is a species mod " + .. "loaded before this one? speed patch skipped") + return + end + ``` + +- `[review]` Every registration is validated against its schema, so a typo + is a load-time message naming the field, not a nil-index crash three + screens later. + +### Empty states + +- `[review]` Every screen a mod adds renders a sentence when its data set + is empty — "No songs registered", "Nothing seen yet" — never a blank box. + `ListMenu` gives you this for free. + +### First-run experience + +- `[review]` The README opens with one sentence of what the mod does, then + the commands to try it. +- `[auto]` The mod loads clean on a fresh install — zero `Loader.errors` — + with only its declared dependencies. +- `[review]` Options have sane defaults, so the mod does something useful + before the player opens its options pane. + +### Credits and honoring authors + +- `[review]` `mod.card.credits` names every upstream contribution — art, + music arrangement, borrowed code — and what it was for. A mod that ports + another community work credits it and links it. +- `[auto]` `mod.card.author` (or `authors`) is present and non-empty. The + showcase and the manager both surface it, so no author is anonymous by + omission. +- `[review]` Asset provenance is honest: originals declared original, + cache-derived output produced by a declared transform, third-party assets + credited and license-compatible. + +### Legal posture (non-negotiable) + +- `[auto]` **No ROM-derived bytes in the packaged mod.** `modkit pack` + refuses otherwise, and `pack` runs `validate --strict`, so even warnings + block the archive. +- `[review]` A total conversion carries the TC legal callout: the Red + import still runs and supplies fallback infrastructure, the conversion + overrides on top, and it distributes recipes rather than extracted + content. + +--- + +## Versioning etiquette + +Three version numbers coexist. + +**Engine version** — `src/core/Version.lua`. Major = a breaking change to +the mod-facing schemas or API; minor = new backward-compatible seams; +patch = bugfix. + +**Mod API version** — the integer `modApi`, currently `2`. Bumped only on a +breaking change to the `mod` object surface. A manifest's `api` field pins +the surface the mod was written against, so an api-2 mod keeps working when +the engine ships api 3. + +**Your mod's version** — the manifest `version`, semver: + +| bump | when | +|---|---| +| patch | data fixes; no save-shape change, no new content ids | +| minor | new content ids, new options with defaults, new optional deps | +| major | removed or renamed content ids, a changed `mod.save` shape (needs a `mod.migrations:add(sinceVersion, fn)` entry), or a raised `game_version` floor | + +Declare the engine range you target in `game_version` (a semver range, e.g. +`">=1.0.0 <2.0.0"`). The loader checks it on load; a mismatch is a clear, +mod-attributed manager error, never a silent partial load. + +Every version change gets a `CHANGELOG.md` heading. `modkit validate` warns +when `manifest.version` advanced without one. diff --git a/README.md b/README.md index 0be85d25..126d5fb0 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,11 @@ This project does not include a ROM, emulate the Game Boy, transpile assembly, or download a disassembly. A canonical US Pokemon Red ROM is the only game content input. -```text -first boot -Pokemon Red ROM -> in-app Lua importer -> private LÖVE save directory - -> generated Lua data and PNGs - -> compact audio channel programs - -> LÖVE2D engine -``` - The ROM is verified, used during import, and then released from memory. It is not copied into the cache. Later launches load the private generated cache and do not ask for the ROM again. -## Packaged App +## Quick Start Open the desktop app. On first boot, choose your legally obtained `.gb` file or drop it onto the window. Import takes a few seconds and the game starts @@ -30,114 +22,50 @@ automatically. Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1 `ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data. -The packaged app contains neither a ROM nor pre-extracted game data. +The packaged app contains neither a ROM nor pre-extracted game data. Music, +sound effects, and cries are synthesized while the game runs from compact +audio channel programs copied out of the verified ROM. -Music, sound effects, and cries are synthesized while the game runs from -compact Game Boy audio channel programs copied out of the verified ROM. No -WAV or OGG library is bundled or generated. +## Controls -## Source Checkout +arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is +B; Escape opens START. F1 saves and F2 loads. Controllers are supported. -The source launchers retain an optional Python workflow for developers. Place -the ROM in the project folder and double-click `Play-Mac.command` or -`Play-Windows.bat`, or run: +## Running From Source + +Requires LÖVE 11.x. Place the ROM in the project folder and double-click +`Play-Mac.command` or `Play-Windows.bat`, or run: ```sh scripts/setup.sh --rom "/path/to/Pokemon Red.gb" scripts/run.sh ``` -Windows PowerShell: +then `love .` for later launches. Windows PowerShell scripts, the optional +developer data build, test suites, and cache management are covered in +[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup). -```powershell -powershell -ExecutionPolicy Bypass -File scripts\setup.ps1 -Rom C:\path\red.gb -powershell -ExecutionPolicy Bypass -File scripts\run.ps1 -``` +## Modding -The setup scripts also accept `ROM_PATH`. With no argument, they use the first -`.gb` file in the project root. +The game ships a native mod platform: content registries, events and hooks, +per-mod saves and options, and an in-game manager. The full modding book — +getting started, a twelve-rung tutorial ladder, a cookbook, and the generated +reference — lives on the +[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki). -## Developer Data Build +Shipped example mods, one per kind of author, live in [`mods/`](mods/). -Requirements: Python 3.10+ and Pillow. +## More -```sh -python3 -m pip install pillow -python3 tools/build_data.py --rom "/path/to/Pokemon Red.gb" --clean -``` - -This command produces the data modules and 495 PNGs in the source tree for -development and parity checks. It is not used by the packaged app. - -## Running - -Requires LÖVE 11.x: - -```sh -love . -``` - -Controls: arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is -B; Escape opens START. F1 saves and F2 loads. Controllers are supported. - -## Link Play - -START > LINK connects two copies directly over UDP. The host chooses HOST A -GAME and shares the shown address; the other player chooses JOIN A GAME. -The default port is 7777 and can be overridden with `POKEPORT_LINK_PORT`. - -## Save Editor - -Edit party, boxes, items, events, map location, and Pokédex flags without -playing through the game. Close the game first, then from the repo root: - -```sh -love . --editor -# or -POKEPORT_EDITOR=1 love . -# open a specific save -love . --editor --save "/path/to/save.lua" -``` - -By default it loads the game's LÖVE save (`save.lua`): - -- macOS: `~/Library/Application Support/LOVE/pokemon-love2d/save.lua (or without the LOVE in a built version)` -- Linux: `~/.local/share/love/pokemon-love2d/save.lua` -- Windows: `%APPDATA%\love\pokemon-love2d\save.lua` - -If that file is missing or you want another copy, use **Open...**, drop a -`save.lua` onto the window, or pass `--save`. Each write makes a -`save.lua.bak-YYYYMMDD-HHMMSS` backup first. - -See `tools/save-editor/README.md` for headless tests. - -## Layout - -```text -tools/ ROM decoder, save editor, and developer verification tools -data/generated/ generated Lua game data (gitignored) -data/scripts/ hand-ported map behavior -assets/generated generated graphics and compact audio cache (gitignored) -src/ hand-written LÖVE engine -scripts/ setup, run, and packaging helpers -mobile/ Android and iOS build trees -tests/ headless behavior and parity suites -docs/ architecture, behavior notes, and platform docs -``` - -See `docs/architecture.md` for runtime details and -`docs/behavior-porting-notes.md` for formula provenance. - -## Delete generated files (mac) - -```sh -rm -rf data/generated assets/generated \ - "$HOME/Library/Application Support/LOVE/pokemon-love2d/data/generated" \ - "$HOME/Library/Application Support/LOVE/pokemon-love2d/assets/generated" - -rm -f "$HOME/Library/Application Support/LOVE/pokemon-love2d/rom-cache.complete" - -``` +- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play) + — START > LINK connects two copies directly over UDP. +- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor) + — edit party, boxes, items, events, and Pokédex flags outside the game. +- `docs/architecture.md` — runtime details; + `docs/behavior-porting-notes.md` — formula provenance. ## Special Thanks -This project would not be possible without [pret](https://github.com/pret) > the pret band of decompiling maniacs > and their [pokered](https://github.com/pret/pokered) disassembly. + +This project would not be possible without [pret](https://github.com/pret) > +the pret band of decompiling maniacs > and their +[pokered](https://github.com/pret/pokered) disassembly. diff --git a/conf.lua b/conf.lua index 11485eab..601969e5 100644 --- a/conf.lua +++ b/conf.lua @@ -16,7 +16,11 @@ function love.conf(t) t.window.height = 800 else t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d" - t.window.title = "Pokemon Red (Gen 1 Recompilation Project)" + -- Version.lua has zero requires, so it is loadable this early; fall + -- back to the plain title if the source is not mounted yet + local ok, Version = pcall(require, "src.core.Version") + t.window.title = ok and Version.title() + or "Pokemon Red (Gen 1 Recompilation Project)" t.window.width = 160 * 4 t.window.height = 144 * 4 end diff --git a/data/scripts/init.lua b/data/scripts/init.lua index 9bf6b86d..76eb8cd5 100644 --- a/data/scripts/init.lua +++ b/data/scripts/init.lua @@ -5,18 +5,26 @@ -- onBoulderMoved = fn } where a script is a list of { "command", args... } -- rows executed by src/script/ScriptRunner.lua. Every hand-ported script -- cites the pokered source it was ported from. +-- +-- The modules land in src/script/MapScripts.lua as the engine's base +-- contribution: attachBase keeps the historical merge (talk tables merge +-- per TEXT constant, other hooks are replaced by later files, so +-- different files can each add NPCs to the same map), and mod +-- contributions from the map_scripts registry compose on top of it. -local registry = { - PALLET_TOWN = require("data.scripts.pallet_town"), - OAKS_LAB = require("data.scripts.oaks_lab"), - REDS_HOUSE_1F = require("data.scripts.reds_house"), - CELADON_MANSION_ROOF_HOUSE = require("data.scripts.celadon_eevee"), -} +local MapScripts = require("src.script.MapScripts") --- story-critical scripts, one table per map. Later files MERGE into --- earlier ones: talk tables merge per TEXT constant, other hooks --- (onEnter, onVictory, ...) are replaced, so different files can each --- add NPCs to the same map. +for _, mapEntry in ipairs({ + { "PALLET_TOWN", "data.scripts.pallet_town" }, + { "OAKS_LAB", "data.scripts.oaks_lab" }, + { "REDS_HOUSE_1F", "data.scripts.reds_house" }, + { "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" }, +}) do + MapScripts.attachBase(mapEntry[1], require(mapEntry[2])) +end + +-- story-critical scripts, one table per map, in the order the old merge +-- loop required them for _, file in ipairs({ "data.scripts.story", "data.scripts.story2", "data.scripts.story3", "data.scripts.story4", "data.scripts.story5", "data.scripts.story6", @@ -24,33 +32,24 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2", "data.scripts.safari", "data.scripts.seafoam", "data.scripts.gyms" }) do for mapId, mod in pairs(require(file)) do - local existing = registry[mapId] - if not existing then - registry[mapId] = mod - else - for k, v in pairs(mod) do - if k == "talk" and existing.talk then - for textConst, script in pairs(v) do - existing.talk[textConst] = script - end - else - existing[k] = v - end - end - end + MapScripts.attachBase(mapId, mod) end end local M = {} function M.get(mapId) - return registry[mapId] + return MapScripts.get(mapId) end -- script to run when the player talks to an object with this TEXT_ constant function M.talkScript(mapId, textConst) - local mod = registry[mapId] - return mod and mod.talk and mod.talk[textConst] or nil + return MapScripts.talkScript(mapId, textConst) +end + +-- attribution for that script's run: the owning mod's source, nil for base +function M.talkSource(mapId, textConst) + return MapScripts.talkSource(mapId, textConst) end return M diff --git a/docs/modding.md b/docs/modding.md index 60b366c6..b7f02581 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,58 +1,19 @@ # Native modding -The game has a built-in Lua mod runtime. Mods are installed under the LÖVE -save directory in `mods//` and are loaded after the verified ROM data has -been imported but before the title screen is created. +The modding book lives on the +[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki). -## Minimal mod +- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started) + — install a mod, write a first one, enable and disable it. +- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials) + — twelve dependency-ordered rungs, each a runnable mod. +- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook) + — task-sized recipes. +- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries) + — every registry, generated from `src/mods/Schemas.lua`. -```text -mods/example_mod/ -├── manifest.json -└── main.lua +Regenerate the reference straight into a wiki checkout: + +```sh +luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki ``` - -`manifest.json`: - -```json -{ - "id": "example_mod", - "name": "Example Mod", - "version": "1.0.0", - "entry": "main.lua", - "priority": 0, - "dependencies": [], - "optional_dependencies": [], - "conflicts": [] -} -``` - -`main.lua`: - -```lua -return function(mod) - mod.log:info("hello from a native mod") - - mod.content.pokemon:override("PIKACHU", { - name = "PIKACHU", - types = { "ELECTRIC" }, - base_stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 }, - }) - - mod.events:on("battle.start", function(context) - context.mod_message = "A native mod changed this battle." - end) -end -``` - -Mods should use registries and events instead of requiring private engine -modules. Registries currently cover Pokémon, moves, items, maps, tilesets, -encounters, trainers, sprites, music, audio, text, scripts, and UI. - -Enablement is stored in the normal persistent `options.lua` file alongside -audio, display, and battle settings, so starting a new game does not disable -the selected mods. Changes take effect after restarting the game. - -The loader deliberately does not import or execute arbitrary ROM-hack patches. -The supported content source remains the verified base Pokémon Red ROM plus -native mods. diff --git a/mods/example_mew_starter/mod.card b/mods/example_mew_starter/mod.card new file mode 100644 index 00000000..a4122e2b --- /dev/null +++ b/mods/example_mew_starter/mod.card @@ -0,0 +1,29 @@ +-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling +-- and the manager detail pane; never by the loader's merge. +-- +-- Gallery entry #0, the legacy example. Its manifest stays api 1 with +-- category GAMEPLAY on purpose: it is the compatibility proof that a mod +-- written before manifest v2 keeps loading unchanged. The v2 examples +-- live in mods/examples/. +return { + summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "beginner", "cosmetic", "data-only", "legacy" }, + differences = { + changed = { + "the Oak's Lab starter gift becomes a level 20 Mew nicknamed HOGHEAD", + "MEW's front and back battle sprites point at inverted copies", + }, + added = {}, + known = { + "assets/ is empty until tools/generate_example_mod_sprite.py runs " + .. "against an imported cache", + "api 1: uses override where a v2 mod would use patch", + }, + }, + credits = { + { who = "pret/pokered", for_ = "the Mew sprites the local generator inverts" }, + }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 1 }, +} diff --git a/mods/examples/README.md b/mods/examples/README.md new file mode 100644 index 00000000..b47d539c --- /dev/null +++ b/mods/examples/README.md @@ -0,0 +1,72 @@ +# Example mod gallery + +Seven 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. + +Copy the one closest to what you want to build. + +| # | Mod | Persona | Category | What it does | +|---|---|---|---|---| +| 0 | [`../example_mew_starter`](../example_mew_starter) | (legacy) | `GAMEPLAY` | Oak's gift becomes a L20 Mew. The api-1 compatibility proof. | +| 1 | [`example_balance_tweaks`](example_balance_tweaks) | Tweaker | `BALANCE` | Faster starters, half-price TMs, a re-slotted Route 1 | +| 2 | [`example_shiny_palette`](example_shiny_palette) | Artist | `GRAPHICS` | A teal player recolor derived from your own cache | +| 3 | [`example_jukebox`](example_jukebox) | Musician | `AUDIO` | An authored chip song, a new cry, a jukebox screen | +| 4 | [`example_lost_parcel`](example_lost_parcel) | Quest author | `QUEST` | A two-town fetch quest over vanilla NPCs | +| 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 | + +## None of these load by default + +The engine discovers mods one level below `mods/`. The gallery lives one +level deeper, in `mods/examples/`, so a fresh install finds none of them +and the vanilla game is unchanged — the parity invariant holds by +construction. + +To run one, copy it up a level: + +```sh +cp -r mods/examples/example_balance_tweaks mods/ +python3 tools/modkit.py validate mods/example_balance_tweaks --base imported +``` + +then enable it in `options.lua` (`mods = { example_balance_tweaks = true }`) +or toggle it in the F10 mod manager. + +## Coverage + +Between them the gallery writes into `pokemon`, `items`, `encounters`, +`maps`, `sprites`, `palettes`, `icons`, `music`, `cries`, `screens`, +`map_scripts`, `commands`, `tokens`, `statuses`, `rulesets`, `constants` +and `field`, and exercises: + +- the write verbs `register`, `override` and `patch`, plus `get` and + `each` on the merged view +- 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` +- `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`, + parallel scripts, `mod:` field routing, replaying an overridden base talk + handler through `MapScripts.baseTalk`, and the no-ROM-content posture + +## What every entry has + +``` +mods/examples// + manifest.json api = 2, a category from the taxonomy, a semver engine range + main.lua the entry chunk + mod.card sharing metadata: summary, author, tags, differences, credits + README.md what it demonstrates, which persona, the commands to try it + CHANGELOG.md keep-a-changelog; headings match manifest.version + tests/ one runnable suite asserting the mod's stated effect + .modkitignore keeps the suite out of the distributed package +``` + +`tests/mod_examples_tests.lua` in the engine's own suite loads all seven +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_balance_tweaks/CHANGELOG.md b/mods/examples/example_balance_tweaks/CHANGELOG.md new file mode 100644 index 00000000..2d014db6 --- /dev/null +++ b/mods/examples/example_balance_tweaks/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 + +- Base speed patches for VENUSAUR, CHARIZARD and BLASTOISE. +- TM price halving driven off `content.items:each()`. +- Route 1 grass re-slot. diff --git a/mods/examples/example_balance_tweaks/README.md b/mods/examples/example_balance_tweaks/README.md new file mode 100644 index 00000000..7bc4359f --- /dev/null +++ b/mods/examples/example_balance_tweaks/README.md @@ -0,0 +1,59 @@ +# Balance Tweaks Example + +Raises the final-stage Kanto starters to 100 base speed, halves every TM +price, and re-slots the Route 1 grass table — all without copying a single +record whole. + +**Persona: the Tweaker.** A player who wants one number changed and copies +an example to get there. This is the shortest complete mod in the gallery +and the one to start from. + +## Try it + +```sh +python3 tools/modkit.py validate mods/examples/example_balance_tweaks --base imported +luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua +``` + +Then enable it: add `example_balance_tweaks = true` under `mods` in your +`options.lua`, or toggle it in the F10 mod manager. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `content.pokemon:patch` | `main.lua` — deep-merges one leaf, leaves the rest alone | +| `content.items:each` | `main.lua` — walks the merged view to find TMs instead of listing them | +| `content.encounters:patch` | `main.lua` — a list leaf replaces wholesale even inside a patch | +| `content.:get` | `main.lua` — the guard that turns a missing id into a log line, not a crash | + +`patch` is the point. The legacy `mods/example_mew_starter` has to copy +every field of Mew to change two sprite paths, because api 1 only had +`override`. With `patch` you name the leaf: + +```lua +mod.content.pokemon:patch("VENUSAUR", { baseStats = { speed = 100 } }) +``` + +Everything not named — `learnset`, `types`, `evolutions`, `spriteFront` — +keeps its base value, and a second mod patching `baseStats.attack` on the +same species composes with this one instead of clobbering it. + +## Exact changes + +- `VENUSAUR` base speed 80 → 100 +- `BLASTOISE` base speed 78 → 100 +- `CHARIZARD` base speed 100 → 100 (already there; kept for symmetry) +- every item whose `machine.kind == "TM"` has its `price` halved + (`TM_TOXIC` 4000 → 2000, and 49 others; HMs carry `machine.kind == "HM"` + and are left alone) +- `ROUTE_1` grass `rate` 25 → 20, slot table re-weighted to include + `SPEAROW` + +Field meanings are in the generated registry reference (`modkit docs`), +section `pokemon`, `items` and `encounters`. + +## Credits + +Base stat and mart price tables come from the player's own imported ROM; +this mod ships numbers, not data. diff --git a/mods/examples/example_balance_tweaks/main.lua b/mods/examples/example_balance_tweaks/main.lua new file mode 100644 index 00000000..b70c63ad --- /dev/null +++ b/mods/examples/example_balance_tweaks/main.lua @@ -0,0 +1,44 @@ +-- Gallery #1 (Tweaker): pure data, no engine seams. Everything here is +-- patch + each, so no record is ever copied whole and another mod editing +-- the same species keeps its own fields. +return function(mod) + -- patch deep-merges only the leaves it names: learnset, sprites, types + -- and evolutions all survive this speed change untouched + for _, id in ipairs({ "VENUSAUR", "CHARIZARD", "BLASTOISE" }) do + if mod.content.pokemon:get(id) then + mod.content.pokemon:patch(id, { baseStats = { speed = 100 } }) + else + -- degrade instead of crashing: a species mod loaded ahead of this + -- one may have removed the vanilla starter line + mod.log:warn("%s missing from the merged view; speed patch skipped", id) + end + end + + -- each() walks the merged view (engine records plus every mod ahead of + -- this one), so the TM list is discovered rather than hard-coded + local halved = 0 + for id, item in mod.content.items:each() do + local machine = item.machine + if machine and machine.kind == "TM" and type(item.price) == "number" + and item.price > 0 then + mod.content.items:patch(id, { price = math.floor(item.price / 2) }) + halved = halved + 1 + end + end + mod.log:info("halved %d TM prices", halved) + + -- lists replace wholesale even inside a patch, so a re-slotted encounter + -- table is written out in full while the rate rides along as a leaf + mod.content.encounters:patch("ROUTE_1", { + grass = { + rate = 20, + slots = { + { level = 3, species = "PIDGEY" }, { level = 3, species = "RATTATA" }, + { level = 4, species = "SPEAROW" }, { level = 2, species = "RATTATA" }, + { level = 2, species = "PIDGEY" }, { level = 3, species = "SPEAROW" }, + { level = 3, species = "PIDGEY" }, { level = 4, species = "RATTATA" }, + { level = 4, species = "PIDGEY" }, { level = 5, species = "SPEAROW" }, + }, + }, + }) +end diff --git a/mods/examples/example_balance_tweaks/manifest.json b/mods/examples/example_balance_tweaks/manifest.json new file mode 100644 index 00000000..1bb3082a --- /dev/null +++ b/mods/examples/example_balance_tweaks/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "example_balance_tweaks", + "name": "Balance Tweaks Example", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "BALANCE", + "game_version": ">=1.0.0 <2.0.0", + "priority": 100, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Tweaker gallery entry: patch and each over the merged view, with no whole-record copies." +} diff --git a/mods/examples/example_balance_tweaks/mod.card b/mods/examples/example_balance_tweaks/mod.card new file mode 100644 index 00000000..aaca0afd --- /dev/null +++ b/mods/examples/example_balance_tweaks/mod.card @@ -0,0 +1,21 @@ +-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling +-- and the manager detail pane; never by the loader's merge. +return { + summary = "Faster final starters, half-price TMs, a re-slotted Route 1.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "balance", "data-only", "beginner" }, + differences = { + changed = { + "VENUSAUR and BLASTOISE base speed raised to 100 (CHARIZARD already was)", + "every TM item's price halved", + "Route 1 grass rate 25 -> 20 and SPEAROW added to the slot table", + }, + added = {}, + known = { "TM prices halve once per load, not once per install" }, + }, + credits = { + { who = "pret/pokered", for_ = "the base stat and mart price tables this patches over" }, + }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua b/mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua new file mode 100644 index 00000000..dbad3b74 --- /dev/null +++ b/mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua @@ -0,0 +1,28 @@ +-- Standalone: luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua +-- Loads the mod through the real headless loader and asserts its stated +-- effect against the player's imported dataset. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = require("src.core.Data") +Data:load() + +local run = T.sdk.loadMod("mods/examples/example_balance_tweaks", { data = Data }) +T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") +T.eq(run.mod and run.mod.state, "loaded", "reached the loaded state") + +T.eq(Data.pokemon.VENUSAUR.baseStats.speed, 100, "VENUSAUR speed patched") +T.eq(Data.pokemon.BLASTOISE.baseStats.speed, 100, "BLASTOISE speed patched") +-- the patch named one leaf, so everything else survived +T.check(#Data.pokemon.VENUSAUR.learnset > 0, "VENUSAUR keeps its learnset") +T.eq(Data.pokemon.VENUSAUR.types[1], "GRASS", "VENUSAUR keeps its types") + +T.eq(Data.items.TM_TOXIC.price, 2000, "TM price halved") +T.eq(Data.items.POTION.price, 300, "a non-TM item is untouched") + +T.eq(Data.encounters.ROUTE_1.grass.rate, 20, "Route 1 grass rate patched") +T.eq(Data.encounters.ROUTE_1.grass.slots[3].species, "SPEAROW", + "Route 1 slot table re-slotted") + +run.release() +T.finish("example_balance_tweaks") diff --git a/mods/examples/example_dexnav/CHANGELOG.md b/mods/examples/example_dexnav/CHANGELOG.md new file mode 100644 index 00000000..e580caaa --- /dev/null +++ b/mods/examples/example_dexnav/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 + +- The `ExampleDexNav` screen and its START-menu row. +- `SORT BY` and `SHOW UNSEEN` mod options. +- `countSeen`, `countOwned` and `species` exports. diff --git a/mods/examples/example_dexnav/README.md b/mods/examples/example_dexnav/README.md new file mode 100644 index 00000000..c26726d4 --- /dev/null +++ b/mods/examples/example_dexnav/README.md @@ -0,0 +1,88 @@ +# DexNav Example + +A START-menu overlay listing every species in the merged dex with its +seen/owned state, sortable, and publishing a small API other mods can call. + +**Persona: the Tool Builder.** Consume the merged view, never `require` a +private module, expose a stable inter-mod surface. This example is the +reference for all three. + +## Try it + +```sh +python3 tools/modkit.py validate mods/examples/example_dexnav --base imported +luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua +``` + +Enable it (`example_dexnav = true` under `mods` in `options.lua`, or the +F10 manager), then press START → **DEXNAV**. Its two options live in the +manager's per-mod options pane. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `content.screens:register` | `main.lua` — a factory the engine instantiates by id | +| `hooks:wrap("ui.start_menu.items")` | `main.lua` — decorate, do not replace | +| `mod.ui.insertBefore` | `main.lua` — anchor on a label, not a row index | +| `mod.options:define` / `:get` | `main.lua` — auto-rendered rows in the manager | +| `mod.exports` | `main.lua` — the inter-mod API | +| `content.pokemon:each` | `main.lua` — the whole world, engine records included | + +## Reading, not reaching + +Every fact this mod displays comes from two public sources: + +- `mod.content.pokemon:each()` — the merged species view. A tool that + hard-codes 151 breaks the moment another mod registers a species; this + one just gets longer. +- `game.save.pokedex` — the seen/owned tables, handed in by the engine. + +No `require("src.pokemon.…")`, no permission declared, nothing that a later +refactor of an engine module can break. + +## Anchoring a menu row + +```lua +mod.hooks:wrap("ui.start_menu.items", function(next, game, items) + local out = next(game, items) + if type(out) ~= "table" then return out end + return mod.ui.insertBefore(out, "SAVE", { label = "DEXNAV", onSelect = ... }) +end) +``` + +Two rules, both load-bearing: + +1. **Call `next` first, then decorate what comes back.** Build a fresh list + instead and every other mod's row disappears. +2. **Anchor on a stable label, not an index.** `insertBefore` appends when + the anchor is missing, so the row is always reachable even in a total + conversion that renamed `SAVE`. + +## Exporting an API + +```lua +mod.exports.countSeen = function(game) ... end +``` + +Another mod reads it as: + +```lua +local nav = mod.find("example_dexnav") +if nav then print(nav.exports.countSeen(game)) end +``` + +`mod.find` returns nil when the other mod is absent, disabled, failed, or +has not run yet — so a dependent degrades instead of crashing. Declare it +in `optional_dependencies` if you can live without it and `dependencies` +if you cannot. + +## Empty state + +`SHOW UNSEEN` off on a fresh save means an empty list. `ListMenu` draws +`Nothing here.` and B still exits — never a blank frame with no way out. +The title still reports `DEXNAV 0/0`, so the screen explains itself. + +## Credits + +- pret/pokered — the START-menu layout the row is anchored into. diff --git a/mods/examples/example_dexnav/main.lua b/mods/examples/example_dexnav/main.lua new file mode 100644 index 00000000..c5977cb6 --- /dev/null +++ b/mods/examples/example_dexnav/main.lua @@ -0,0 +1,98 @@ +-- Gallery #6 (Tool builder): a read-mostly overlay. Everything it knows +-- comes from the merged view through the public mod API -- no private +-- require, no engine table reached behind the loader's back -- and what it +-- knows is published as a stable export other mods can call. +local SCREEN = "ExampleDexNav" + +return function(mod) + mod.options:define({ + { key = "sort", label = "SORT BY", type = "choice", default = "dex", + choices = { { "DEX NO.", "dex" }, { "NAME", "name" } } }, + { key = "unseen", label = "SHOW UNSEEN", type = "toggle", default = true }, + }) + + -- ------- the merged view, read once per open + + -- content.pokemon:each() yields the engine's species AND every mod's, + -- which is the whole point: a tool that hard-codes 151 breaks the moment + -- someone adds a species. + local function species() + local rows = {} + for id, mon in mod.content.pokemon:each() do + rows[#rows + 1] = { id = id, name = mon.name or id, dex = mon.dex or 9999 } + end + return rows + end + + local function dexOf(game) + return (game and game.save and game.save.pokedex) or { seen = {}, owned = {} } + end + + local function counts(game) + local dex = dexOf(game) + local seen, owned = 0, 0 + for _, row in ipairs(species()) do + if dex.seen[row.id] then seen = seen + 1 end + if dex.owned[row.id] then owned = owned + 1 end + end + return seen, owned + end + + -- ------- the inter-mod API + -- Another mod reads this with mod.find("example_dexnav").exports; it is + -- the supported way to depend on this one, and the reason nothing here + -- reaches into a private module. + + mod.exports.countSeen = function(game) return (counts(game)) end + mod.exports.countOwned = function(game) return select(2, counts(game)) end + mod.exports.species = species + + -- ------- the screen + + mod.content.screens:register(SCREEN, { + new = function(game) + local dex = dexOf(game) + local showUnseen = mod.options:get("unseen") + local rows = species() + if mod.options:get("sort") == "name" then + table.sort(rows, function(a, b) return a.name < b.name end) + else + table.sort(rows, function(a, b) + if a.dex ~= b.dex then return a.dex < b.dex end + return a.id < b.id + end) + end + + local items = {} + for _, row in ipairs(rows) do + local state = dex.owned[row.id] and "OWN" + or (dex.seen[row.id] and "SEEN" or "----") + if showUnseen or state ~= "----" then + items[#items + 1] = { label = row.name, right = state, value = row.id } + end + end + + local seen, owned = counts(game) + -- ListMenu draws "Nothing here." for an empty set, so a brand new + -- save with SHOW UNSEEN off reads as a sentence, not a blank frame + return mod.ui.ListMenu.new(game, + ("DEXNAV %d/%d"):format(owned, seen), items, { + pageJump = true, + onChoose = function(_, menu) menu:close() end, + }) + end, + }) + + -- ------- reaching it + -- Call next() first, then decorate the list it returns: another mod's + -- row survives, and the vanilla rows are never rebuilt by hand. + + mod.hooks:wrap("ui.start_menu.items", function(next, game, items) + local out = next(game, items) + if type(out) ~= "table" then return out end + return mod.ui.insertBefore(out, "SAVE", { + label = "DEXNAV", + onSelect = function() mod.ui.push(game, SCREEN) end, + }) + end) +end diff --git a/mods/examples/example_dexnav/manifest.json b/mods/examples/example_dexnav/manifest.json new file mode 100644 index 00000000..7a028c3e --- /dev/null +++ b/mods/examples/example_dexnav/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "example_dexnav", + "name": "DexNav Example", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "TOOL", + "game_version": ">=1.0.0 <2.0.0", + "priority": 100, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Tool-builder gallery entry: a start-menu overlay over the merged dex, with mod options and a stable inter-mod export." +} diff --git a/mods/examples/example_dexnav/mod.card b/mods/examples/example_dexnav/mod.card new file mode 100644 index 00000000..7babc890 --- /dev/null +++ b/mods/examples/example_dexnav/mod.card @@ -0,0 +1,21 @@ +-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling +-- and the manager detail pane; never by the loader's merge. +return { + summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "ui", "tool", "quality-of-life" }, + differences = { + changed = { "the START menu gains a DEXNAV row above SAVE" }, + added = { + "ExampleDexNav screen", + "mod options: SORT BY and SHOW UNSEEN", + "exports countSeen / countOwned / species for other mods", + }, + known = { "the list is built when the screen opens, so catching a mon mid-session needs a reopen" }, + }, + credits = { + { who = "pret/pokered", for_ = "the start-menu layout the row is anchored into" }, + }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_dexnav/tests/example_dexnav_test.lua b/mods/examples/example_dexnav/tests/example_dexnav_test.lua new file mode 100644 index 00000000..ac09d58d --- /dev/null +++ b/mods/examples/example_dexnav/tests/example_dexnav_test.lua @@ -0,0 +1,54 @@ +-- Standalone: luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua +-- Exercises the export surface, the start-menu wrap and the screen factory. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Data = require("src.core.Data") +Data:load() + +local Font = require("src.render.Font") +Font.load(Data) + +local run = T.sdk.loadMod("mods/examples/example_dexnav", { data = Data }) +T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") + +local exports = run.loader.exports.example_dexnav +T.check(type(exports.countSeen) == "function", "countSeen is exported") +T.check(type(exports.species) == "function", "species is exported") +T.eq(#exports.species(), 151, "the export reads the whole merged dex") + +local game = { + data = Data, + save = { pokedex = { seen = { PIKACHU = true, MEW = true }, + owned = { PIKACHU = true } } }, +} +T.eq(exports.countSeen(game), 2, "countSeen counts the seen set") +T.eq(exports.countOwned(game), 1, "countOwned counts the owned set") + +-- an empty dex is a legal state, not a crash +T.eq(exports.countSeen({ data = Data, save = {} }), 0, "a fresh save counts zero") + +-- ------- the start-menu wrap decorates rather than replaces + +local vanilla = { { label = "POKéDEX" }, { label = "SAVE" }, { label = "QUIT" } } +local hooked = Runtime.call("ui.start_menu.items", + function(_, items) return items end, game, vanilla) +T.eq(#hooked, 4, "the wrap added exactly one row") +T.eq(hooked[2].label, "DEXNAV", "the row is anchored before SAVE") +T.eq(hooked[3].label, "SAVE", "the vanilla rows are still in order") + +-- ------- the screen factory builds a real state + +local Screens = require("src.ui.Screens") +Screens.invalidate() +local factory = Screens.get(game, "ExampleDexNav") +T.check(factory and factory.new, "the screen resolves through the registry") +local screen = factory.new(game) +T.check(screen.items ~= nil and #screen.items == 151, + "the list shows every species with SHOW UNSEEN on") +T.eq(screen.title, "DEXNAV 1/2", "the title carries the owned/seen counts") + +run.release() +Screens.invalidate() +T.finish("example_dexnav") diff --git a/mods/examples/example_jukebox/CHANGELOG.md b/mods/examples/example_jukebox/CHANGELOG.md new file mode 100644 index 00000000..57c8abe5 --- /dev/null +++ b/mods/examples/example_jukebox/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/). +Version headings match `manifest.json`'s `version`. + +## 1.0.0 + +### Added + +- `Music_ExamplePalletRain`, a two-pulse loop authored in `song.lua`. +- A `music.select` wrap that swaps the Pallet Town map theme. +- An authored chip cry for MEW. +- The `ExampleJukebox` screen and its OPTIONS row. diff --git a/mods/examples/example_jukebox/README.md b/mods/examples/example_jukebox/README.md new file mode 100644 index 00000000..390d5fc7 --- /dev/null +++ b/mods/examples/example_jukebox/README.md @@ -0,0 +1,99 @@ +# Jukebox Example + +Adds one song authored note-by-note in Lua, swaps it in as the Pallet Town +theme, replaces Mew's cry, and ships a jukebox screen that lists every song +in the merged registry. + +**Persona: the Musician.** Nothing here is a `.ogg`. The song is a +Game Boy channel program assembled at load time by `ChipAsm`. + +## Try it + +```sh +python3 tools/modkit.py validate mods/examples/example_jukebox --base imported +luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua + +# render the song to a wav to hear it without launching the game +python3 tools/modkit.py bounce Music_ExamplePalletRain --seconds 20 --out bounce +``` + +Enable it (`example_jukebox = true` under `mods` in `options.lua`, or the +F10 manager), then open **OPTIONS → JUKEBOX**. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `content.music:register` (ChipAsm DSL) | `song.lua` + `main.lua` | +| `content.cries:override` (chip program) | `main.lua` | +| `hooks:wrap("music.select")` | `main.lua` — one choke point covers map, battle and jingle music | +| `content.screens:register` | `main.lua` — a screen factory the engine instantiates by id | +| `hooks:wrap("ui.options.rows")` | `main.lua` — how the player reaches the screen | +| `content.music:each` | `main.lua` — the jukebox list is the merged view, not a hard-coded array | +| `mod:read` | `main.lua` — loading a sibling file through the loader's filesystem | + +## Authoring a song + +`song.lua` returns what `ChipAsm.song{...}` builds: a self-contained +program blob plus its channel layout. Events are Lua tables, one per +command: + +```lua +{ duty = 2 }, +{ notetype = { speed = 12, volume = 11, fade = 2 } }, +{ octave = 4 }, +{ label = "lead" }, +{ note = "E", len = 6 }, { note = "D", len = 2 }, +{ loop = { count = 0, to = "lead" } }, +``` + +The assembler is the validator. An unknown note name or an out-of-range +length raises *there*, naming the channel and event index, and `main.lua` +turns that into one mod-attributed load error. The music system never +latches on a bad program, because a bad program never reaches it. + +Two shapes share the `music` registry and are dispatched per definition, +not by a global flag: `{ chip = ... }` for an authored program and +`{ file = "..." }` for an audio file. This example uses the first; a file +track is one line: + +```lua +mod.content.music:register("Music_MyTheme", { file = mod.path .. "/theme.ogg" }) +``` + +Note the cry uses `ChipAsm.sfx{...}.chip` — the assembler returns +`{ chip = program }`, and a cry record wants the program under its own +`chip` key. + +## Deferring is the parity guarantee + +```lua +mod.hooks:wrap("music.select", function(next, chosen, ctx) + if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then + return next(SONG_ID, ctx) + end + return next(chosen, ctx) +end) +``` + +Every path that is not Pallet Town calls `next(chosen, ctx)` with the +argument it was given, so playback everywhere else is exactly what it was +before the mod loaded. + +## Empty state + +The jukebox is a `ListMenu`, which draws `Nothing here.` when its item list +is empty rather than an empty frame. That cannot happen with the engine's +45 songs present, but it is the behavior a mod screen owes the player. + +## Permissions + +This mod declares `engine_internals`, because playing a song from a screen +currently needs `require("src.core.Music")` — the mod surface has no audio +playback facade yet. Declaring it is the honest path: `modkit validate` +accepts a declared require and flags an undeclared one. + +## Credits + +- Pallet Rain arrangement: this project. +- pret/pokered: the channel command set `ChipAsm` assembles to. diff --git a/mods/examples/example_jukebox/main.lua b/mods/examples/example_jukebox/main.lua new file mode 100644 index 00000000..ccfc3e2b --- /dev/null +++ b/mods/examples/example_jukebox/main.lua @@ -0,0 +1,96 @@ +-- Gallery #3 (Musician): one authored chip song, one hook that swaps the +-- Pallet Town theme, one derived cry, and a jukebox screen that lists the +-- merged music registry. +local SONG_ID = "Music_ExamplePalletRain" + +return function(mod) + -- the song lives in its own file; mod:read + load keeps it addressable + -- through the loader's filesystem instead of the host package.path, so + -- the mod works the same installed as it does in the repo + local source = mod:read("song.lua") + if not source then + mod.log:error("song.lua missing from %s -- reinstall the mod", mod.path) + return + end + local chunk, compileErr = load(source, "@" .. mod.path .. "/song.lua") + if not chunk then + mod.log:error("song.lua did not compile: %s", tostring(compileErr)) + return + end + -- a malformed note table raises inside ChipAsm; catching it here turns + -- the whole mod into a mod-attributed load error instead of latching the + -- music system at playback time + local ok, song = pcall(chunk) + if not ok then + mod.log:error("song.lua failed to assemble: %s", tostring(song)) + return + end + + mod.content.music:register(SONG_ID, song) + + -- a derived cry: the ChipAsm effect command set (channels 5-8), keyed by + -- species exactly like the vanilla cry table + mod.content.cries:override("MEW", { + -- .chip, not the whole return: ChipAsm hands back { chip = program } + -- and a cry record carries the program under its own chip key + chip = require("src.audio.ChipAsm").sfx{ + channels = { + { hw = 1, program = { + { pitchSweep = { pace = 3, subtract = false, shift = 2 } }, + { squareNote = { len = 6, volume = 14, fade = 2, frequency = 0x5C0 } }, + { squareNote = { len = 8, volume = 12, fade = 3, frequency = 0x680 } }, + } }, + }, + }.chip, + pitch = 128, length = 128, + }) + + -- music.select is the single choke point every song choice passes + -- through. Defer to next() for everything that is not the case this mod + -- cares about: with the mod installed but off the map, playback is + -- byte-for-byte what it was. + mod.hooks:wrap("music.select", function(next, chosen, ctx) + if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then + return next(SONG_ID, ctx) + end + return next(chosen, ctx) + end) + + -- the jukebox itself: a screen factory in the screens registry + mod.content.screens:register("ExampleJukebox", { + new = function(game) + local ids = {} + for id in mod.content.music:each() do ids[#ids + 1] = id end + table.sort(ids) + local items = {} + for _, id in ipairs(ids) do + items[#items + 1] = { label = id:gsub("^Music_", ""), value = id } + end + -- ListMenu draws "Nothing here." on an empty set, so the empty state + -- is a sentence rather than a blank box + return mod.ui.ListMenu.new(game, "JUKEBOX", items, { + onChoose = function(item) + require("src.core.Music").play(game.data, item.value, true, + { reason = "direct" }) + end, + onCancel = function() + require("src.core.Music").stop() + end, + }) + end, + }) + + -- reachable from OPTIONS; call next() first and decorate what comes back, + -- so every other mod's rows survive this one + mod.hooks:wrap("ui.options.rows", function(next, game, rows) + local out = next(game, rows) + if type(out) ~= "table" then return out end + out[#out + 1] = { + id = "example_jukebox", + label = "JUKEBOX", + value = function() return "OPEN" end, + activate = function(g) mod.ui.push(g, "ExampleJukebox") end, + } + return out + end) +end diff --git a/mods/examples/example_jukebox/manifest.json b/mods/examples/example_jukebox/manifest.json new file mode 100644 index 00000000..ee73465e --- /dev/null +++ b/mods/examples/example_jukebox/manifest.json @@ -0,0 +1,16 @@ +{ + "id": "example_jukebox", + "name": "Jukebox Example", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "AUDIO", + "game_version": ">=1.0.0 <2.0.0", + "priority": 100, + "permissions": ["engine_internals"], + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Musician gallery entry: an authored ChipAsm song, a music.select hook, a new cry and a jukebox screen." +} diff --git a/mods/examples/example_jukebox/mod.card b/mods/examples/example_jukebox/mod.card new file mode 100644 index 00000000..a2271f42 --- /dev/null +++ b/mods/examples/example_jukebox/mod.card @@ -0,0 +1,24 @@ +-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling +-- and the manager detail pane; never by the loader's merge. +return { + summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "audio", "chiptune", "ui" }, + differences = { + changed = { + "Pallet Town's map theme becomes Music_ExamplePalletRain", + "MEW's cry is replaced with an authored chip effect", + }, + added = { + "Music_ExamplePalletRain song record", + "ExampleJukebox screen, reachable from the OPTIONS menu", + }, + known = { "the jukebox plays looping songs only; jingles stop on their own" }, + }, + credits = { + { who = "Pokemon Gen 1 Recompilation Project", for_ = "the Pallet Rain arrangement" }, + { who = "pret/pokered", for_ = "the channel command set ChipAsm assembles to" }, + }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_jukebox/song.lua b/mods/examples/example_jukebox/song.lua new file mode 100644 index 00000000..4aec7b52 --- /dev/null +++ b/mods/examples/example_jukebox/song.lua @@ -0,0 +1,40 @@ +-- "Pallet Rain": a two-pulse loop authored in the ChipAsm note-event DSL +-- (13-audio-modding.md). ChipAsm is on the loader's supported-require +-- list, so authoring a song needs no permissions. +-- +-- The assembler is the validator: an out-of-range length or an unknown +-- note name raises here, at load, naming the channel and event index -- +-- not silently at playback. +local ChipAsm = require("src.audio.ChipAsm") + +return ChipAsm.song{ + tempo = 0x120, + channels = { + -- lead: a four-bar descending figure that loops forever + { hw = 1, program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 11, fade = 2 } }, + { octave = 4 }, + { label = "lead" }, + { note = "E", len = 6 }, { note = "D", len = 2 }, + { note = "C", len = 6 }, { rest = 2 }, + { note = "A", len = 4 }, { note = "G", len = 4 }, + { note = "C", len = 8 }, + { note = "E", len = 6 }, { note = "G", len = 2 }, + { note = "A", len = 8 }, + { rest = 8 }, + { loop = { count = 0, to = "lead" } }, + } }, + -- counter-line: same length, one octave down, softer + { hw = 2, program = { + { duty = 1 }, + { notetype = { speed = 12, volume = 7, fade = 1 } }, + { octave = 3 }, + { label = "bass" }, + { note = "C", len = 8 }, { note = "G", len = 8 }, + { note = "A", len = 8 }, { note = "F", len = 8 }, + { note = "C", len = 8 }, { note = "G", len = 8 }, + { loop = { count = 0, to = "bass" } }, + } }, + }, +} diff --git a/mods/examples/example_jukebox/tests/example_jukebox_test.lua b/mods/examples/example_jukebox/tests/example_jukebox_test.lua new file mode 100644 index 00000000..8f904ce6 --- /dev/null +++ b/mods/examples/example_jukebox/tests/example_jukebox_test.lua @@ -0,0 +1,70 @@ +-- Standalone: luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua +-- Asserts the song assembles, the cry merges, and music.select swaps only +-- the map this mod claims. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Data = require("src.core.Data") +Data:load() + +local run = T.sdk.loadMod("mods/examples/example_jukebox", { data = Data }) +T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") + +-- ------- the authored song + +local song = Data.audio.songs.Music_ExamplePalletRain +T.check(type(song) == "table", "the song registered") +T.check(type(song.chip) == "table" and #song.chip.blob > 0, + "it assembled to a non-empty program blob") +T.eq(#song.chip.channels, 2, "both channels are laid out") +T.eq(song.chip.channels[1].address, 0x4000, + "the first channel is based at the 0x4000 window") + +-- ------- the cry + +local cry = Data.audio.cries.MEW +T.check(type(cry) == "table" and type(cry.chip) == "table", + "the MEW cry is an authored chip program") +T.check(#cry.chip.blob > 0, "the cry program is non-empty") +T.eq(cry.chip.channels[1].number, 5, + "an sfx program lives on the effect channels (5-8)") + +-- ------- the hook swaps exactly one map + +local function select(song_, ctx) + return Runtime.call("music.select", function(chosen) return chosen end, song_, ctx) +end +T.eq(select("Music_PalletTown", { reason = "map", mapId = "PALLET_TOWN" }), + "Music_ExamplePalletRain", "Pallet Town gets the new theme") +T.eq(select("Music_Routes1", { reason = "map", mapId = "ROUTE_1" }), + "Music_Routes1", "every other map defers to the vanilla choice") +T.eq(select("Music_Battle", { reason = "battle", kind = "wild" }), + "Music_Battle", "battle music defers too") +T.eq(select("Music_PalletTown", nil), "Music_PalletTown", + "a direct play with no context defers") + +-- ------- the screen and its options row + +local Font = require("src.render.Font") +Font.load(Data) +local Screens = require("src.ui.Screens") +Screens.invalidate() +local factory = Screens.get({ data = Data }, "ExampleJukebox") +T.check(factory and factory.new, "the jukebox resolves through the screens registry") +local screen = factory.new({ data = Data }) +T.check(#screen.items > 0, "the jukebox lists the merged music registry") +local listed = false +for _, item in ipairs(screen.items) do + if item.value == "Music_ExamplePalletRain" then listed = true end +end +T.check(listed, "the mod's own song is in the list") + +local rows = Runtime.call("ui.options.rows", function(_, r) return r end, + { data = Data }, { { id = "text_speed" } }) +T.eq(#rows, 2, "the options hook added exactly one row") +T.eq(rows[2].id, "example_jukebox", "the row is the jukebox entry") + +run.release() +Screens.invalidate() +T.finish("example_jukebox") diff --git a/mods/examples/example_lost_parcel/CHANGELOG.md b/mods/examples/example_lost_parcel/CHANGELOG.md new file mode 100644 index 00000000..bf028c58 --- /dev/null +++ b/mods/examples/example_lost_parcel/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/). +Version headings match `manifest.json`'s `version`. + +## 1.0.0 + +### Added + +- The two-town fetch quest over `VIRIDIAN_CITY` and `PEWTER_CITY`. +- `EXAMPLE_LOST_PARCEL_PARCEL` key item and the `{EXAMPLE_PARCEL_REWARD}` token. +- The `example_lost_parcel:count_ask` verb and the ambient parallel script. +- `example_lost_parcel:base_nerd_chat`, which replays the Pewter super + nerd's base handler through `MapScripts.baseTalk` so the branches the + quest does not own keep the vanilla YES/NO conversation intact. diff --git a/mods/examples/example_lost_parcel/README.md b/mods/examples/example_lost_parcel/README.md new file mode 100644 index 00000000..75763678 --- /dev/null +++ b/mods/examples/example_lost_parcel/README.md @@ -0,0 +1,131 @@ +# The Lost Parcel + +A courier in Viridian City dropped a parcel somewhere in Pewter City. +Fetch it and he pays you a NUGGET. + +**Persona: the Quest Author.** Two towns, two vanilla NPCs, a branching +conversation, a key item, a reward and some ambience — and not one line of +map data or engine source changed. + +## Try it + +```sh +python3 tools/modkit.py validate mods/examples/example_lost_parcel --base imported +luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua +``` + +Enable it (`example_lost_parcel = true` under `mods` in `options.lua`, or +the F10 manager), then: + +``` + VIRIDIAN CITY PEWTER CITY + ┌───────────────────┐ ┌───────────────────┐ + │ GAMBLER ◀──────┼── accept ───┼──▶ SUPER NERD │ + │ (quest giver) │ │ (has the │ + │ ▲ │ │ parcel) │ + └───────┼───────────┘ └─────────┬─────────┘ + └──────────── return ───────────────┘ +``` + +Talk to the gambler in Viridian (the one south of the Poké Mart), say +`SURE`, walk to Pewter, talk to the super nerd by the museum, walk back. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `content.map_scripts:register` (compose) | `main.lua` — two maps, no map edits | +| talk override on a real `TEXT_` constant | `main.lua` — `TEXT_VIRIDIANCITY_GAMBLER1`, `TEXT_PEWTERCITY_SUPER_NERD1` | +| `choice` + `label` + `jump_if_true/false` | `main.lua` — a five-branch conversation | +| `MOD_` flag convention | `main.lua` — `MOD_EXAMPLE_LOST_PARCEL_*` | +| `set_field "mod:key"` | `main.lua` — quest scratch state in `save.modData[mod.id]` | +| `mod.save:get/set` | `main.lua` — the same value through the loader-side namespace | +| `content.commands:register` (table form) | `main.lua` — a `foreground` verb of the mod's own | +| `MapScripts.baseTalk` (replay the overridden handler) | `main.lua` — `example_lost_parcel:base_nerd_chat` | +| `content.tokens:register` | `main.lua` — `{EXAMPLE_PARCEL_REWARD}` | +| `content.items:register` | `main.lua` — the parcel key item | +| a parallel ambient script | `main.lua` — `scripts.example_nerd_pace` + `onEnter` | +| `events:on` / `events:emit` | `main.lua` — announcing completion under `mod..*` | + +## How the compose merge works + +`map_scripts` is a **compose** registry, not a record registry. Registering +does not replace the engine's contribution for a map; it prepends to an +ordered chain, and each key composes by its own rule +(`09-scripting-and-quests.md` §4.4): + +| key | rule | +|---|---| +| `talk`, `scripts` | single winner per name; `false` suppresses and falls through | +| `onEnter`, `onVictory`, `onBoulderMoved` | all contributions run, each `pcall`-guarded | +| `onStep`, `onInteract` | first truthy return consumes the step | + +So this mod's `onEnter` for Pewter City runs *alongside* the engine's, not +instead of it. Its `talk` entry for `TEXT_PEWTERCITY_SUPER_NERD1` does win +outright — the engine's handler for that constant stops being dispatched +the moment this mod loads. Giving it back is the last branch's whole job: + +```lua +{ "label", "vanilla" }, +{ "example_lost_parcel:base_nerd_chat" }, +``` + +```lua +local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1") +base(ctx.game, ctx.overworld, ctx.npc, function() ctx.runner:resume() end) +ctx.runner:yield() +``` + +`MapScripts.baseTalk` reaches the engine handler still sitting behind the +override (`09-scripting-and-quests.md` §6) — the supported replacement for +re-wrapping it. A `{ "show_text", "TEXT_PEWTERCITY_SUPER_NERD1" }` row +*looks* like it does the same thing and does not: this NPC's base handler +is a Lua function that asks YES/NO and answers with one of two follow-ups, +while `show_text` resolves the constant to its opening line and stops. So +before the quest starts and after the parcel is taken, the player gets the +whole conversation they always got, choice included. The test drives both +answers, in both states, and asserts every line. + +This is also why the manifest declares `engine_internals`: replaying a base +handler means requiring `src.script.MapScripts`. + +## Flags, fields and mod state + +Three storage routes, three jobs: + +- **`MOD_`-prefixed flags** — the quest's public state machine. In the + normal flag namespace so `check_flag` works, prefixed so it can never + collide with a pokered event constant. +- **`set_field "mod:asked_count"`** — script-visible scratch state, routed + into `save.modData[mod.id]` by the owning contribution's attribution. + Two copies of a quest cannot collide on one key. +- **`mod.save:get/set`** — the same namespace from Lua, for code that is + not a script row. + +## Verb metadata + +The custom verb is registered in the table form: + +```lua +mod.content.commands:register("example_lost_parcel:count_ask", { + foreground = true, + fn = function(ctx) ... end, +}) +``` + +`foreground = true` marks it illegal inside a parallel script, so the +ambient runner can never touch quest state. Namespacing the verb with the +mod id keeps it from colliding with another mod's — `register` on a name +the engine already owns is an error, and replacing one requires `override`. + +## Validation + +Every row is checked against the merged command set once all entry chunks +have run. Typo a verb or jump to a label that does not exist and this mod +fails at *load* with the row number, is rolled back whole, and says so in +the manager — it never half-loads into a broken conversation. + +## Credits + +- pret/pokered — the `TEXT_` constants and base conversations this + composes with. diff --git a/mods/examples/example_lost_parcel/main.lua b/mods/examples/example_lost_parcel/main.lua new file mode 100644 index 00000000..8534a8f2 --- /dev/null +++ b/mods/examples/example_lost_parcel/main.lua @@ -0,0 +1,172 @@ +-- Gallery #4 (Quest author): the worked multi-map fetch quest from +-- 09-scripting-and-quests.md 6. A courier in Viridian City lost a parcel +-- in Pewter City; the player retrieves it for a NUGGET. +-- +-- No map is edited and no engine file is touched. Both NPCs are vanilla +-- objects addressed by their real TEXT_ constants, and the base +-- conversation is still reachable on every branch the quest does not own. +local MapScripts = require("src.script.MapScripts") + +local PARCEL = "EXAMPLE_LOST_PARCEL_PARCEL" +local REWARD = "NUGGET" + +-- flags a mod writes are MOD_-prefixed by convention, so a save never +-- confuses them with the pokered event namespace +local STARTED = "MOD_EXAMPLE_LOST_PARCEL_STARTED" +local TAKEN = "MOD_EXAMPLE_LOST_PARCEL_TAKEN" +local DONE = "MOD_EXAMPLE_LOST_PARCEL_DONE" + +-- the Pewter super nerd's object index on PEWTER_CITY; the ambient script +-- makes this one fidget while the parcel is still lying around +local NERD_INDEX = 3 + +return function(mod) + -- ------- the reward item + + mod.content.items:register(PARCEL, { + id = PARCEL, + name = "PARCEL?", + price = 0, + keyItem = true, + tossable = false, + }) + + -- ------- a text token, so the reward name is written once + + mod.content.tokens:register("EXAMPLE_PARCEL_REWARD", function(game) + local item = game and game.data and game.data.items[REWARD] + return item and item.name or REWARD + end) + + -- ------- a script verb of this mod's own + -- The table form carries dispatch metadata: foreground marks it illegal + -- inside a parallel script, which is what keeps the ambient runner from + -- ever touching quest state. + mod.content.commands:register("example_lost_parcel:count_ask", { + foreground = true, + fn = function(ctx) + -- mod: fields route into save.modData[owner], so quest scratch state + -- is attributable and two quests never collide on one key + local base = ctx.save.modData and ctx.save.modData[mod.id] + local asked = (base and base.asked_count or 0) + 1 + ctx.save.modData = ctx.save.modData or {} + ctx.save.modData[mod.id] = ctx.save.modData[mod.id] or {} + ctx.save.modData[mod.id].asked_count = asked + -- the same number through the loader-side namespace, which is what a + -- screen or another mod would read + mod.save:set("asked_count", asked) + end, + }) + + -- ------- handing a branch back to the base conversation + -- talk dispatch is single-winner, so the Pewter rows below replace the + -- engine's handler outright. Re-running the TEXT_ constant with + -- show_text would only replay its opening line: the base handler is a + -- Lua function that asks YES/NO and answers with one of two follow-ups, + -- and none of that survives a text lookup. baseTalk reaches the handler + -- still sitting behind the override (09 6), so the branches the quest + -- does not own play the whole vanilla conversation. + mod.content.commands:register("example_lost_parcel:base_nerd_chat", { + foreground = true, + fn = function(ctx) + local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1") + if not base then return end + local runner = ctx.runner + base(ctx.game, ctx.overworld, ctx.npc, function() runner:resume() end) + runner:yield() + end, + }) + + -- ------- Viridian City: the quest giver + + mod.content.map_scripts:register("VIRIDIAN_CITY", { + talk = { + TEXT_VIRIDIANCITY_GAMBLER1 = { + { "check_flag", DONE }, + { "jump_if_true", "after" }, + { "check_flag", STARTED }, + { "jump_if_true", "pending" }, + { "show_text", "I dropped a parcel\nsomewhere in\nPEWTER CITY..." }, + { "choice", { "SURE", "NO WAY" } }, + { "jump_if_false", "refused" }, + { "set_flag", STARTED }, + { "set_field", "mod:asked_count", 0 }, + { "show_text", "Thanks! A {EXAMPLE_PARCEL_REWARD}\nawaits you!" }, + { "jump", "end" }, + + { "label", "pending" }, + { "example_lost_parcel:count_ask" }, + { "check_item", PARCEL }, + { "jump_if_false", "remind" }, + { "take_item", PARCEL }, + { "give_item", REWARD }, + { "set_flag", DONE }, + { "emote", "player", "happy", 45 }, + { "show_text", "You found it!\nHere, as promised!" }, + { "jump", "end" }, + + { "label", "remind" }, + { "show_text", "It's a small brown\nparcel. PEWTER CITY!" }, + { "jump", "end" }, + + { "label", "refused" }, + { "show_text", "Aww. GYMs are\nclosed anyway..." }, + { "jump", "end" }, + + { "label", "after" }, + { "show_text", "Thanks again,\n{PLAYER}!" }, + }, + }, + }) + + -- ------- Pewter City: the parcel, and some ambience while it is lost + + mod.content.map_scripts:register("PEWTER_CITY", { + talk = { + TEXT_PEWTERCITY_SUPER_NERD1 = { + { "check_flag", STARTED }, + { "jump_if_false", "vanilla" }, + { "check_flag", TAKEN }, + { "jump_if_true", "vanilla" }, + { "show_text", "Someone dropped\nthis parcel by the\nMUSEUM." }, + { "give_item", PARCEL, 1, false }, + { "set_flag", TAKEN }, + { "show_text", "{PLAYER} got the\nparcel back!" }, + { "jump", "end" }, + + -- every branch the quest does not own replays the base handler, so + -- the vanilla conversation is never lost to the override + { "label", "vanilla" }, + { "example_lost_parcel:base_nerd_chat" }, + }, + }, + + -- all-run: this composes with the engine's own onEnter for the map + -- instead of replacing it + onEnter = function(game, ow) + local flags = game.save and game.save.flags or {} + if flags[STARTED] and not flags[TAKEN] then + ow:queueScript({ { "run_parallel", "PEWTER_CITY/example_nerd_pace" } }) + end + end, + + scripts = { + -- background-legal verbs only; the runner rejects foreground rows in + -- a parallel slot, and the script dies on map exit + example_nerd_pace = { + { "label", "top" }, + { "march_in_place", NERD_INDEX, true }, { "wait", 90 }, + { "march_in_place", NERD_INDEX, false }, { "wait", 150 }, + { "jump", "top" }, + }, + }, + }) + + -- quest completion is worth announcing to other mods; a mod may only + -- broadcast under its own prefix + mod.events:on("flag.changed", function(ev) + if ev.name == DONE and ev.value then + mod.events:emit("mod.example_lost_parcel.completed", { reward = REWARD }) + end + end) +end diff --git a/mods/examples/example_lost_parcel/manifest.json b/mods/examples/example_lost_parcel/manifest.json new file mode 100644 index 00000000..9f22dfbc --- /dev/null +++ b/mods/examples/example_lost_parcel/manifest.json @@ -0,0 +1,16 @@ +{ + "id": "example_lost_parcel", + "name": "The Lost Parcel", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "QUEST", + "game_version": ">=1.0.0 <2.0.0", + "priority": 100, + "permissions": ["engine_internals"], + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Quest-author gallery entry: a two-town fetch quest over real TEXT constants, with choices, labels, MOD_ flags and a parallel ambient script." +} diff --git a/mods/examples/example_lost_parcel/mod.card b/mods/examples/example_lost_parcel/mod.card new file mode 100644 index 00000000..573239c3 --- /dev/null +++ b/mods/examples/example_lost_parcel/mod.card @@ -0,0 +1,27 @@ +-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling +-- and the manager detail pane; never by the loader's merge. +return { + summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "quest", "story", "scripting" }, + differences = { + changed = { + "the Viridian gambler and the Pewter super nerd gain quest branches; " + .. "their vanilla lines still play on every other branch", + }, + added = { + "EXAMPLE_LOST_PARCEL_PARCEL key item", + "{EXAMPLE_PARCEL_REWARD} text token", + "example_lost_parcel:count_ask script verb", + "example_lost_parcel:base_nerd_chat script verb, which replays the " + .. "super nerd's base conversation the quest branches around", + "an ambient parallel script on PEWTER_CITY while the parcel is lost", + }, + known = { "the parcel can be tossed from the bag; the quest then stalls at the reminder line" }, + }, + credits = { + { who = "pret/pokered", for_ = "the TEXT_ constants and base conversations this composes with" }, + }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua b/mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua new file mode 100644 index 00000000..20b361d5 --- /dev/null +++ b/mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua @@ -0,0 +1,163 @@ +-- Standalone: luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua +-- Plays the quest end to end headlessly: accept, fetch, hand over. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = require("src.core.Data") +Data:load() + +local Font = require("src.render.Font") +local ScriptRunner = require("src.script.ScriptRunner") +local MapScripts = require("src.script.MapScripts") +-- the engine attaches its hand-ported scripts as the base contribution at +-- boot; the quest composes on top of them, so the harness needs them too +require("data.scripts.init") +Font.load(Data) + +local run = T.sdk.loadMod("mods/examples/example_lost_parcel", { data = Data }) +T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") +T.check(Data.items.EXAMPLE_LOST_PARCEL_PARCEL ~= nil, "the parcel item merged") +T.check(Data.tokens.EXAMPLE_PARCEL_REWARD ~= nil, "the reward token merged") +T.check(Data.commands["example_lost_parcel:count_ask"] ~= nil, + "the mod's script verb merged") + +-- a stack whose boxes are answered from OUTSIDE the coroutine: a text box +-- pushed by show_text resolves on the next drive tick, never re-entrantly +local choice = 1 +local function newGame() + local game = { data = Data, save = { + flags = {}, inventory = {}, modData = {}, + player = { name = "RED", rival = "BLUE" }, + } } + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + game.stack = stack + game.shown = {} -- first line of every text box, in order + game.asked = 0 -- YES/NO boxes the conversation put up + return game +end + +-- advance one pending box or emote hold; choice menus pick `choice` +local function settle(game, ow) + if ow.emote then + local held = ow.emote + ow.emote = nil + held.onDone() + return true + end + local top = table.remove(game.stack.states) + if not top then return false end + if top.pages then + game.lastText = top.pages[1] and top.pages[1][1] + game.shown[#game.shown + 1] = game.lastText + end + if top.items then + local item = top.items[choice] + if item and item.onSelect then item.onSelect() end + elseif top.onChoose then + -- a bare ChoiceBox: what the engine's own Lua talk handlers ask with + game.asked = game.asked + 1 + top.onChoose(choice == 1) + elseif top.onDone then + top.onDone() + end + return true +end + +-- the first line of a generated text constant, which is what a TextBox +-- paginates onto its first row +local function firstLine(s) return (tostring(s):match("^[^\n\f\v]*")) end + +-- the overworld the talk dispatch would supply; its map label is what +-- show_text resolves a bare TEXT_ constant through +local function overworldFor(mapId) + return { map = { id = mapId, def = { label = Data.maps[mapId].label } } } +end + +local function talk(game, mapId, textConst) + local rows = MapScripts.talkScript(mapId, textConst) + T.check(rows ~= nil, mapId .. "." .. textConst .. " has a talk script") + local ow = overworldFor(mapId) + local runner = ScriptRunner.new(game, ow) + runner:run(rows, { source = MapScripts.talkSource(mapId, textConst) }) + for _ = 1, 400 do + if not runner:isRunning() then break end + if not settle(game, ow) then runner:update() end + end + T.check(not runner:isRunning(), "the " .. textConst .. " script completed") +end + +-- ------- branch 1: refuse the quest + +do + choice = 2 + local game = newGame() + talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1") + T.check(not game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED, + "refusing the choice leaves the quest unstarted") +end + +-- ------- branch 2: accept, fetch, deliver + +local game = newGame() +choice = 1 +talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1") +T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED, "accepting sets the started flag") +T.eq(game.save.modData.example_lost_parcel.asked_count, 0, + "set_field mod: wrote into the mod's own save namespace") + +-- The override wins talk dispatch outright, so the branches the quest does +-- not own owe the player the whole base conversation -- which for this NPC +-- is an opening line, a YES/NO prompt and one of two follow-ups, not a +-- single line. Both answers are driven, before the quest starts and again +-- once the parcel is gone. +local NERD_INTRO = Data.text._PewterCitySuperNerd1DidYouCheckOutMuseumText +local NERD_YES = Data.text._PewterCitySuperNerd1WerentThoseFossilsAmazingText +local NERD_NO = Data.text._PewterCitySuperNerd1YouHaveToGoText +T.check(NERD_INTRO and NERD_YES and NERD_NO, + "the base super nerd conversation is in the generated text") + +local function readsVanillaNerd(when, flags) + for _, answer in ipairs({ 1, 2 }) do + local plain = newGame() + for name, value in pairs(flags or {}) do plain.save.flags[name] = value end + choice = answer + talk(plain, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1") + T.check((plain.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0, + when .. ": the base branch never hands out the parcel") + T.eq(plain.asked, 1, when .. ": the vanilla YES/NO prompt still comes up") + T.eq(#plain.shown, 2, when .. ": the opening line and a follow-up both play") + T.eq(plain.shown[1], firstLine(NERD_INTRO), when .. ": the vanilla opening line") + T.eq(plain.shown[2], firstLine(answer == 1 and NERD_YES or NERD_NO), + when .. ": the follow-up answers the choice the player made") + end +end + +readsVanillaNerd("before the quest") +readsVanillaNerd("after the parcel is taken", { + MOD_EXAMPLE_LOST_PARCEL_STARTED = true, + MOD_EXAMPLE_LOST_PARCEL_TAKEN = true, +}) +choice = 1 + +talk(game, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1") +T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_TAKEN, "the parcel is taken") +T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) > 0, + "the parcel is in the bag") + +talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1") +T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_DONE, "the quest completes") +T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0, + "the parcel is consumed") +T.check((game.save.inventory.NUGGET or 0) > 0, "the NUGGET reward is paid") +T.eq(game.save.modData.example_lost_parcel.asked_count, 1, + "the mod's own verb counted the one pending visit") + +-- the ambient script is background-legal: no foreground verb in it +local rows = MapScripts.namedScript("PEWTER_CITY", "example_nerd_pace") +T.check(rows ~= nil, "the parallel ambient script is registered") + +run.release() +T.finish("example_lost_parcel") diff --git a/mods/examples/example_mini_conversion/CHANGELOG.md b/mods/examples/example_mini_conversion/CHANGELOG.md new file mode 100644 index 00000000..3c0eccd5 --- /dev/null +++ b/mods/examples/example_mini_conversion/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/). +Version headings match `manifest.json`'s `version`. + +## 1.0.0 + +### Added + +- Three original species with cries and icons, and their art generator. +- The `SABLE_COVE` map and encounter table. +- `SABLE_TIDE_BADGE` and the one-badge constants override. +- The `SableTitle` screen and the `field.boot` override that reaches it. diff --git a/mods/examples/example_mini_conversion/README.md b/mods/examples/example_mini_conversion/README.md new file mode 100644 index 00000000..8627de0c --- /dev/null +++ b/mods/examples/example_mini_conversion/README.md @@ -0,0 +1,103 @@ +# Sable Cove (Mini Conversion) + +The smallest thing that is recognizably a *different game*: its own title +screen, its own starting town, its own three-species dex, its own single +badge — running on the same engine, with the same import. + +**Persona: the Total-Conversion Team.** This is the capstone skeleton. It +is deliberately incomplete as a game and deliberately complete as a +demonstration of which seams a conversion owns. + +## Legal callout (read this first) + +A total conversion on this engine is a **recipe, not a redistribution**. + +- The Red import still runs. It supplies the fallback infrastructure this + conversion sits on: the `OVERWORLD` tileset, the font, the move table, + the type chart. That data lives on the player's machine, decoded from + the player's own ROM. +- The conversion overrides on top. Everything under `assets/` here is + original work, plotted pixel by pixel by + `tools/make_assets.py` — run it yourself and diff the output. +- It never ships extracted content, and it never launders extracted + content into "new" species by transforming Red sprites. If your + conversion wants to *derive* art from the player's cache, that is what + `assets_transforms` is for — see + `mods/examples/example_shiny_palette/` for the worked pattern. + +## Try it + +```sh +python3 tools/modkit.py validate mods/examples/example_mini_conversion --base imported +python3 tools/modkit.py lint mods/examples/example_mini_conversion +luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua + +# regenerate the original art from its shape tables +python3 mods/examples/example_mini_conversion/tools/make_assets.py +``` + +Enable it (`example_mini_conversion = true` under `mods` in `options.lua`, +or the F10 manager) and start the game. You land on the SABLE COVE title +screen; NEW GAME spawns you in Sable Cove with 1500 money as SABLE. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `profile = "total_conversion"` | `manifest.json` — implies `affects_link` | +| `content.field:patch("boot", …)` | `main.lua` — spawn, names, money, boot screens | +| `content.constants:patch` / `:override` | `main.lua` — dex size, level cap, badge list | +| `content.pokemon:register` | `main.lua` — three species with full records | +| `content.cries:register` (ChipAsm) | `main.lua` — one authored effect per species | +| `content.icons:register` | `main.lua` — party icons keyed by species id | +| `content.maps:register` | `main.lua` — one map on the imported tileset | +| `content.encounters:register` | `main.lua` — its wild table | +| `content.items:register` | `main.lua` — the badge, which is an item | +| `content.screens:register` | `main.lua` — the title screen the boot config names | +| `events:on("game.ready")` | `main.lua` — checking the boot merge actually took | + +## patch vs override on a deep registry + +`constants` and `field` are **deep** registries. Two rules differ from the +record registries, and both bite a conversion: + +1. `register` and `patch` are the same verb. A partial payload is the + normal case; only the keys you name move. +2. **Lists append.** That is deliberate — two mods each adding a badge both + land. But a conversion wants to *replace* the badge list, and appending + would leave Kanto's eight in front of its one: + +```lua +mod.content.constants:patch("badges", { … }) -- 8 + 1 = 9 badges +mod.content.constants:override("badges", { … }) -- 1 badge +``` + +`override` is the verb that drops a list. The test asserts both behaviors. + +`field.boot` is the opposite case: `patch` is right there, because the keys +this conversion does not name (`startFacing`, the `splash` and `newGame` +screen ids) should keep the engine's values. + +## Priority + +`"priority": 900`. A conversion wants to merge *after* content mods so its +`field.boot` and `constants` win. If another mod still beats it, the +`game.ready` listener says so by name instead of leaving the player on a +map they did not expect. + +## What a real conversion adds next + +This skeleton stops at the boundary of the mechanism demonstration. A +shipping conversion continues with: + +- `map_scripts` for the story (see `mods/examples/example_lost_parcel/`) +- `maps:remove` / `pokemon:remove` tombstones to hide Kanto content +- `content.text` and `text_pointers` for its own dialogue +- `content.music` for its soundtrack (see `mods/examples/example_jukebox/`) +- `link_fields` and an honest `affects_link` so its players do not corrupt + each other's saves in a trade + +## Credits + +- All original sprite art: this project (`tools/make_assets.py`). +- pret/pokered: the `OVERWORLD` tileset, font and move table this builds on. diff --git a/mods/examples/example_mini_conversion/assets/emberkit_back.png b/mods/examples/example_mini_conversion/assets/emberkit_back.png new file mode 100644 index 00000000..514a605a Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/emberkit_back.png differ diff --git a/mods/examples/example_mini_conversion/assets/emberkit_front.png b/mods/examples/example_mini_conversion/assets/emberkit_front.png new file mode 100644 index 00000000..649929cf Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/emberkit_front.png differ diff --git a/mods/examples/example_mini_conversion/assets/emberkit_icon.png b/mods/examples/example_mini_conversion/assets/emberkit_icon.png new file mode 100644 index 00000000..3034785d Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/emberkit_icon.png differ diff --git a/mods/examples/example_mini_conversion/assets/mossling_back.png b/mods/examples/example_mini_conversion/assets/mossling_back.png new file mode 100644 index 00000000..1ca58f01 Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/mossling_back.png differ diff --git a/mods/examples/example_mini_conversion/assets/mossling_front.png b/mods/examples/example_mini_conversion/assets/mossling_front.png new file mode 100644 index 00000000..0b0dd7cc Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/mossling_front.png differ diff --git a/mods/examples/example_mini_conversion/assets/mossling_icon.png b/mods/examples/example_mini_conversion/assets/mossling_icon.png new file mode 100644 index 00000000..2e7fdbc6 Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/mossling_icon.png differ diff --git a/mods/examples/example_mini_conversion/assets/tidepup_back.png b/mods/examples/example_mini_conversion/assets/tidepup_back.png new file mode 100644 index 00000000..0b0e958a Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/tidepup_back.png differ diff --git a/mods/examples/example_mini_conversion/assets/tidepup_front.png b/mods/examples/example_mini_conversion/assets/tidepup_front.png new file mode 100644 index 00000000..af3849fe Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/tidepup_front.png differ diff --git a/mods/examples/example_mini_conversion/assets/tidepup_icon.png b/mods/examples/example_mini_conversion/assets/tidepup_icon.png new file mode 100644 index 00000000..8f6788c3 Binary files /dev/null and b/mods/examples/example_mini_conversion/assets/tidepup_icon.png differ diff --git a/mods/examples/example_mini_conversion/main.lua b/mods/examples/example_mini_conversion/main.lua new file mode 100644 index 00000000..44b5deed --- /dev/null +++ b/mods/examples/example_mini_conversion/main.lua @@ -0,0 +1,180 @@ +-- Gallery #7 (Total-conversion team): the smallest thing that is +-- recognizably a different game. Its own boot config, its own title +-- screen, its own three-species dex, its own single badge, and one map. +-- +-- LEGAL POSTURE (constraint 1): the Red import still runs and supplies the +-- fallback infrastructure this conversion sits on -- the OVERWORLD tileset, +-- the font, the move table. The conversion overrides on top. Every pixel +-- under assets/ is original work generated by tools/make_assets.py; nothing +-- here transforms extracted Red art into "new" content. +local MAP = "SABLE_COVE" +local BADGE = "SABLE_TIDE_BADGE" + +-- three original species; ids are namespaced so a mixed load cannot +-- collide with Red's +local DEX = { + { id = "SABLE_EMBERKIT", name = "EMBERKIT", dex = 1, art = "emberkit", + types = { "FIRE" }, stats = { hp = 45, attack = 60, defense = 40, + speed = 65, special = 50 } }, + { id = "SABLE_TIDEPUP", name = "TIDEPUP", dex = 2, art = "tidepup", + types = { "WATER" }, stats = { hp = 50, attack = 48, defense = 60, + speed = 45, special = 55 } }, + { id = "SABLE_MOSSLING", name = "MOSSLING", dex = 3, art = "mossling", + types = { "GRASS", "POISON" }, stats = { hp = 55, attack = 50, + defense = 55, speed = 40, + special = 60 } }, +} + +return function(mod) + local ChipAsm = require("src.audio.ChipAsm") + + -- ------- species, cries and icons + + for _, entry in ipairs(DEX) do + mod.content.pokemon:register(entry.id, { + id = entry.id, + name = entry.name, + dex = entry.dex, + types = entry.types, + baseStats = entry.stats, + catchRate = 190, + baseExp = 64, + growthRate = "MEDIUM_FAST", + level1Moves = { "TACKLE" }, + learnset = { + { level = 7, move = "GROWL" }, + { level = 13, move = "QUICK_ATTACK" }, + }, + evolutions = {}, + spriteFront = mod.path .. "/assets/" .. entry.art .. "_front.png", + spriteBack = mod.path .. "/assets/" .. entry.art .. "_back.png", + frontSize = 5, + cry = entry.id, + icon = { image = mod.path .. "/assets/" .. entry.art .. "_icon.png", + frames = 2 }, + }) + + -- one authored chip effect per species, keyed by species id exactly + -- like the vanilla cry table + mod.content.cries:register(entry.id, { + chip = ChipAsm.sfx{ + channels = { { hw = 1, program = { + { pitchSweep = { pace = 2 + entry.dex, subtract = entry.dex == 2, + shift = 3 } }, + { squareNote = { len = 5, volume = 13, fade = 2, + frequency = 0x480 + entry.dex * 0x60 } }, + } } }, + }.chip, + pitch = 128, length = 128, + }) + + mod.content.icons:register(entry.id, { + image = mod.path .. "/assets/" .. entry.art .. "_icon.png", + frames = 2, + }) + end + + -- ------- the badge, which is an item like every vanilla badge + + mod.content.items:register(BADGE, { + id = BADGE, name = "TIDEBADGE", price = 0, keyItem = true, tossable = false, + }) + + -- ------- the rules the engine used to hard-code + -- deep registry: register and patch are the same verb, and only the keys + -- named here move. Everything else keeps its imported value. + + mod.content.constants:patch("dexSize", #DEX) + mod.content.constants:patch("dexDigits", 1) + mod.content.constants:patch("levelCap", 50) + -- override, not patch: under deep semantics a list APPENDS, so patching + -- here would leave Kanto's eight badges in front of this one. override + -- is the verb that drops a list, which is exactly what a conversion wants + mod.content.constants:override("badges", { { id = BADGE, name = "TIDE" } }) + mod.content.constants:override("hmMoves", { "CUT", "SURF" }) + + -- ------- the one map + + local blocks = {} + for i = 1, 10 * 9 do blocks[i] = 1 end + mod.content.maps:register(MAP, { + id = MAP, + label = "SableCove", + index = 1000, + tileset = "OVERWORLD", + width = 10, height = 9, + blocks = blocks, + borderBlock = 11, + warps = {}, objects = {}, signs = {}, + }) + + mod.content.encounters:register(MAP, { + grass = { rate = 25, slots = { + { level = 3, species = "SABLE_EMBERKIT" }, + { level = 3, species = "SABLE_TIDEPUP" }, + { level = 3, species = "SABLE_MOSSLING" }, + { level = 4, species = "SABLE_TIDEPUP" }, + { level = 4, species = "SABLE_MOSSLING" }, + { level = 5, species = "SABLE_EMBERKIT" }, + { level = 5, species = "SABLE_TIDEPUP" }, + { level = 5, species = "SABLE_MOSSLING" }, + { level = 6, species = "SABLE_EMBERKIT" }, + { level = 6, species = "SABLE_MOSSLING" }, + } }, + }) + + -- ------- the new game itself + + mod.content.field:patch("boot", { + startMap = MAP, startX = 5, startY = 4, startFacing = "down", + playerName = "SABLE", rivalName = "CORAL", + startMoney = 1500, + lastHeal = { map = MAP, x = 5, y = 4 }, + namePresets = { player = { "SABLE", "WREN", "PIKE" }, + rival = { "CORAL", "REEF", "SHOAL" } }, + -- the conversion owns the boot flow; splash and newGame keep the + -- engine screens, which is the point of naming them individually + screens = { title = "SableTitle" }, + }) + + -- ------- the title screen + + mod.content.screens:register("SableTitle", { + new = function(game, opts) + opts = opts or {} + local Font = mod.ui.Font + local state = { game = game, isOpaque = true, blink = 0 } + + function state:update() + self.blink = (self.blink + 1) % 60 + local input = game.input + if not input then return end + if input:wasPressed("a") or input:wasPressed("start") then + if opts.onNewGame then opts.onNewGame() end + end + end + + function state:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("SABLE COVE", 40, 40) + Font.draw("A MINI CONVERSION", 12, 56) + if self.blink < 40 then Font.draw("PRESS A", 52, 104) end + end + + return state + end, + }) + + mod.events:on("game.ready", function(ev) + -- the payload carries the live Game; a conversion uses it to check + -- that its own boot config actually took + local boot = ev.game and ev.game.data and ev.game.data.field + and ev.game.data.field.boot + if not (boot and boot.startMap == MAP) then + mod.log:warn("another mod owns field.boot; raise this mod's priority " + .. "above %s to win the merge", tostring(boot and boot.startMap)) + end + end) +end diff --git a/mods/examples/example_mini_conversion/manifest.json b/mods/examples/example_mini_conversion/manifest.json new file mode 100644 index 00000000..7664cc74 --- /dev/null +++ b/mods/examples/example_mini_conversion/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "example_mini_conversion", + "name": "Sable Cove (Mini Conversion)", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "total_conversion", + "category": "TOTAL_CONVERSION", + "game_version": ">=1.0.0 <2.0.0", + "priority": 900, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Total-conversion gallery entry: a one-town game with its own boot, title, three-species dex and one badge." +} diff --git a/mods/examples/example_mini_conversion/mod.card b/mods/examples/example_mini_conversion/mod.card new file mode 100644 index 00000000..0e6efb15 --- /dev/null +++ b/mods/examples/example_mini_conversion/mod.card @@ -0,0 +1,31 @@ +-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling +-- and the manager detail pane; never by the loader's merge. +return { + summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "total-conversion", "capstone" }, + differences = { + changed = { + "field.boot spawns on SABLE_COVE as SABLE, rival CORAL, 1500 money", + "constants.dexSize 151 -> 3, dexDigits 3 -> 1, levelCap 100 -> 50", + "constants.badges replaced with one TIDEBADGE", + "constants.hmMoves replaced with CUT and SURF", + "the title screen is SableTitle", + }, + added = { + "SABLE_EMBERKIT, SABLE_TIDEPUP and SABLE_MOSSLING with cries and icons", + "SABLE_TIDE_BADGE key item", + "the SABLE_COVE map and its encounter table", + }, + known = { + "no gym, no story and no warps yet: this is the skeleton the TC guide elaborates", + "Red's maps and species stay merged and reachable; a real conversion removes them", + }, + }, + credits = { + { who = "Pokemon Gen 1 Recompilation Project", for_ = "all original sprite art under assets/" }, + { who = "pret/pokered", for_ = "the OVERWORLD tileset, font and move table this builds on" }, + }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua b/mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua new file mode 100644 index 00000000..8ae6950e --- /dev/null +++ b/mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua @@ -0,0 +1,70 @@ +-- Standalone: luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua +-- Asserts the conversion owns the boot flow, the dex and the badge list. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = require("src.core.Data") +Data:load() + +local run = T.sdk.loadMod("mods/examples/example_mini_conversion", { data = Data }) +T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") +T.eq(run.mod and run.mod.manifest.profile, "total_conversion", + "the manifest declares the total_conversion profile") + +-- ------- boot: the new game the conversion starts + +local boot = Data.field.boot +T.eq(boot.startMap, "SABLE_COVE", "boot spawns on the conversion's own map") +T.eq(boot.playerName, "SABLE", "boot renames the player") +T.eq(boot.screens.title, "SableTitle", "the conversion owns the title screen") +-- patch is a merge: the keys the conversion did not name keep their values +T.eq(boot.startFacing, "down", "an unnamed boot key survives the patch") + +-- ------- the dex + +T.eq(Data.constants.dexSize, 3, "the dex shrinks to three species") +T.eq(#Data.constants.badges, 1, "one badge replaces the eight") +T.eq(Data.constants.badges[1].id, "SABLE_TIDE_BADGE", "the badge is the mod's item") +T.check(Data.items.SABLE_TIDE_BADGE ~= nil, "the badge item merged") +-- constants is a deep registry, so untouched keys keep their imported value +T.eq(Data.constants.partyMax, 6, "an unpatched constant is unchanged") +-- and a deep list REPLACES only because the mod said override +T.eq(#Data.constants.hmMoves, 2, "override drops a deep list instead of appending") + +for _, id in ipairs({ "SABLE_EMBERKIT", "SABLE_TIDEPUP", "SABLE_MOSSLING" }) do + local mon = Data.pokemon[id] + T.check(mon ~= nil, id .. " merged into the species table") + T.check(Data.audio.cries[id] ~= nil, id .. " has a cry") + T.check(Data.icons.bySpecies[id] ~= nil, id .. " has an icon") + -- the art it points at really exists, not a path into the void. Read it + -- back through the loader's own filesystem: mod.path is whatever the + -- loader mounted the mod at, which is not the repo-relative directory + for _, path in ipairs({ mon.spriteFront, mon.spriteBack }) do + T.check(run.loader.fs.getInfo(path) ~= nil, + "sprite exists: " .. tostring(path)) + end +end + +-- ------- the map and its encounters + +local map = Data.maps.SABLE_COVE +T.check(map ~= nil, "the conversion's map merged") +T.eq(#map.blocks, map.width * map.height, "the block array matches the map size") +T.eq(#Data.encounters.SABLE_COVE.grass.slots, 10, "the map has a full slot table") + +-- ------- the title screen resolves through the registry + +local Screens = require("src.ui.Screens") +Screens.invalidate() +local factory = Screens.get({ data = Data }, "SableTitle") +T.check(factory and factory.new, "SableTitle resolves through the screens registry") +local reached = false +local state = factory.new({ data = Data, input = { + wasPressed = function(_, key) return key == "a" end } }, + { onNewGame = function() reached = true end }) +state:update() +T.check(reached, "pressing A on the title starts a new game") + +run.release() +Screens.invalidate() +T.finish("example_mini_conversion") diff --git a/mods/examples/example_mini_conversion/tools/make_assets.py b/mods/examples/example_mini_conversion/tools/make_assets.py new file mode 100644 index 00000000..8e56bad8 --- /dev/null +++ b/mods/examples/example_mini_conversion/tools/make_assets.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Regenerate this mod's original sprite art. + + python3 mods/examples/example_mini_conversion/tools/make_assets.py + +Every pixel below is plotted from the shape tables in this file, so the +output is original work and nothing is read from the player's imported +cache. The four colors are the Game Boy shade ramp; the renderer +re-shades them into the active palette, so no trueColor opt-out is needed. + +Front sheets are 40x40 (frontSize 5), backs 32x32 and icons 16x32 +(two 16x16 frames), matching what the battle and party screens expect. +""" + +import os + +from PIL import Image + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets") + +# lightest first; index 0 is the transparent-white background +SHADES = [(248, 248, 248), (168, 168, 168), (88, 88, 88), (8, 8, 8)] + +# Each species is a coarse silhouette painted from primitives: the point is +# that these are geometric marks, not creature art traced from anything. +SPECIES = { + "emberkit": {"body": "triangle", "accent": 1}, + "tidepup": {"body": "diamond", "accent": 1}, + "mossling": {"body": "hex", "accent": 2}, +} + + +def blank(w, h): + return [[0] * w for _ in range(h)] + + +def stroke(grid, x, y, shade): + if 0 <= y < len(grid) and 0 <= x < len(grid[0]): + grid[y][x] = shade + + +def triangle(grid, cx, cy, r, shade): + for row in range(r * 2): + half = row // 2 + for x in range(cx - half, cx + half + 1): + stroke(grid, x, cy - r + row, shade) + + +def diamond(grid, cx, cy, r, shade): + for dy in range(-r, r + 1): + span = r - abs(dy) + for dx in range(-span, span + 1): + stroke(grid, cx + dx, cy + dy, shade) + + +def hexagon(grid, cx, cy, r, shade): + for dy in range(-r, r + 1): + span = r if abs(dy) <= r // 2 else r - (abs(dy) - r // 2) + for dx in range(-span, span + 1): + stroke(grid, cx + dx, cy + dy, shade) + + +SHAPES = {"triangle": triangle, "diamond": diamond, "hex": hexagon} + + +def outline(grid, shade): + """Darken every lit pixel that touches an unlit one.""" + h, w = len(grid), len(grid[0]) + edges = [] + for y in range(h): + for x in range(w): + if not grid[y][x]: + continue + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = x + dx, y + dy + if not (0 <= nx < w and 0 <= ny < h) or not grid[ny][nx]: + edges.append((x, y)) + break + for x, y in edges: + grid[y][x] = shade + + +def save(grid, path): + h, w = len(grid), len(grid[0]) + img = Image.new("RGBA", (w, h)) + img.putdata([SHADES[grid[y][x]] + (255,) + for y in range(h) for x in range(w)]) + os.makedirs(os.path.dirname(path), exist_ok=True) + img.save(path) + print("wrote", os.path.relpath(path)) + + +def build(name, spec): + shape = SHAPES[spec["body"]] + accent = spec["accent"] + + front = blank(40, 40) + shape(front, 20, 22, 13, accent) + shape(front, 20, 12, 5, 2) + for x in (16, 24): + stroke(front, x, 11, 3) + stroke(front, x, 12, 3) + outline(front, 3) + save(front, os.path.join(ROOT, name + "_front.png")) + + back = blank(32, 32) + shape(back, 16, 20, 11, accent) + outline(back, 3) + save(back, os.path.join(ROOT, name + "_back.png")) + + # two 16x16 frames stacked: the party-menu bob + icon = blank(16, 32) + for frame, lift in enumerate((0, 1)): + shape(icon, 8, 9 + frame * 16 - lift, 5, accent) + outline(icon, 3) + save(icon, os.path.join(ROOT, name + "_icon.png")) + + +if __name__ == "__main__": + for name, spec in sorted(SPECIES.items()): + build(name, spec) diff --git a/mods/examples/example_shiny_palette/CHANGELOG.md b/mods/examples/example_shiny_palette/CHANGELOG.md new file mode 100644 index 00000000..51b694f7 --- /dev/null +++ b/mods/examples/example_shiny_palette/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 + +- `transforms.lua` recolor of the player overworld sheets. +- `EXAMPLE_SHINY` palette record and a `PALLET` town palette override. +- `trueColor` opt-out patches for `SPRITE_RED` and `SPRITE_RED_BIKE`. diff --git a/mods/examples/example_shiny_palette/README.md b/mods/examples/example_shiny_palette/README.md new file mode 100644 index 00000000..72a7e419 --- /dev/null +++ b/mods/examples/example_shiny_palette/README.md @@ -0,0 +1,78 @@ +# Shiny Palette Example + +Recolors the player's overworld sheets to teal and repaints Pallet Town — +and ships no ROM-derived pixels to do it. + +**Persona: the Artist.** This is the canonical answer to "how do I ship a +recolor legally": you ship the *transform*, not the image. + +## Try it + +```sh +python3 tools/modkit.py validate mods/examples/example_shiny_palette --base imported +python3 tools/modkit.py lint mods/examples/example_shiny_palette +luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua +``` + +Enable it (`example_shiny_palette = true` under `mods` in `options.lua`, or +the F10 manager) and start the game. On first load the transform runs once +and writes `save/mod-derived/example_shiny_palette/sprites/*.png`. Delete +that directory to force a re-run. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `assets_transforms` | `manifest.json` + `transforms.lua` — the recipe that derives art | +| `content.palettes:register` | `main.lua` — the v2 named-record palette shape | +| `content.palettes:override` | `main.lua` — the vanilla four-triple shape | +| `content.sprites:patch` | `main.lua` — `trueColor` opt-out, nothing else touched | +| `events:on("assets.transformed")` | `main.lua` — the empty-state warning | + +## The legal pattern + +`transforms.lua` runs inside a restricted context with exactly two +filesystem roots: read `assets/generated/**` (the player's own imported +cache) and write `save/mod-derived/example_shiny_palette/**`. There is no +`require`, no `love`, no `io`, no `os`. The only way data leaves the +sandbox is the `ctx` table. + +Because the derived file keeps the *same relative name* as the cache file +it came from, the asset resolver finds it automatically: + +``` +assets/generated/sprites/red.png <- the player's import +save/mod-derived/example_shiny_palette/sprites/red.png <- this mod's recolor +``` + +Every consumer of the first path transparently gets the second. No +`sprites:override`, no path string in `main.lua` — and `modkit lint` can +prove the repo carries no cache-derived bytes, because it carries no bytes +at all. + +The one thing that *does* need a registry entry is the 4-shade contract. +The renderer normally re-shades an overworld sheet into the current +palette's four grays, which would throw the teal away. `trueColor = true` +opts out: + +```lua +mod.content.sprites:patch("SPRITE_RED", { trueColor = true }) +``` + +`patch`, not `override`: `image`, `frames` and `walker` stay whatever the +merged view already holds, so the derived sheet keeps supplying the pixels. + +## Empty state + +No ROM imported yet? `ctx.exists(rel)` is false, the transform writes +nothing, the mod still loads, and `main.lua` logs a remediation line naming +the directory to delete once you have imported. It never errors. + +## Original assets + +`assets/accent_sparkle.png` is a 16x16 four-shade sparkle drawn for this +example. It is the only image in the directory and it is original work. + +## Credits + +- pret/pokered — the overworld sheet layout the transform recolors. diff --git a/mods/examples/example_shiny_palette/assets/accent_sparkle.png b/mods/examples/example_shiny_palette/assets/accent_sparkle.png new file mode 100644 index 00000000..2e6db6bc Binary files /dev/null and b/mods/examples/example_shiny_palette/assets/accent_sparkle.png differ diff --git a/mods/examples/example_shiny_palette/main.lua b/mods/examples/example_shiny_palette/main.lua new file mode 100644 index 00000000..2582c7a8 --- /dev/null +++ b/mods/examples/example_shiny_palette/main.lua @@ -0,0 +1,37 @@ +-- Gallery #2 (Artist): a recolor that ships no pixels. transforms.lua +-- derives the sheets from the player's own cache; this file only declares +-- the palette records and the one flag the recolor needs. +return function(mod) + -- v2 record shape: a named table of four colors, lightest first + mod.content.palettes:register("EXAMPLE_SHINY", { + colors = { + { r = 248, g = 248, b = 248 }, + { r = 120, g = 224, b = 216 }, + { r = 32, g = 128, b = 152 }, + { r = 8, g = 32, b = 64 }, + }, + }) + + -- vanilla raw shape: four {r,g,b} triples. Overriding a town palette is + -- the smallest visible artist change there is -- no assets involved. + mod.content.palettes:override("PALLET", { + { 248, 248, 248 }, { 152, 232, 224 }, { 64, 152, 168 }, { 8, 32, 64 }, + }) + + -- trueColor opts SPRITE_RED out of the 4-shade re-shade so the teal the + -- transform baked in survives to the screen. patch, not override: the + -- image path and frame count stay whatever the merged view already has, + -- which is how the derived sheet keeps supplying the pixels. + mod.content.sprites:patch("SPRITE_RED", { trueColor = true }) + mod.content.sprites:patch("SPRITE_RED_BIKE", { trueColor = true }) + + mod.events:on("assets.transformed", function(ev) + if ev.modId ~= mod.id then return end + if ev.count == 0 then + mod.log:warn("no sheets derived -- import your ROM first, then " + .. "delete save/mod-derived/%s to re-run the transform", mod.id) + else + mod.log:info("derived %d recolored sheets", ev.count) + end + end) +end diff --git a/mods/examples/example_shiny_palette/manifest.json b/mods/examples/example_shiny_palette/manifest.json new file mode 100644 index 00000000..5cec1753 --- /dev/null +++ b/mods/examples/example_shiny_palette/manifest.json @@ -0,0 +1,16 @@ +{ + "id": "example_shiny_palette", + "name": "Shiny Palette Example", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "GRAPHICS", + "game_version": ">=1.0.0 <2.0.0", + "priority": 100, + "assets_transforms": "transforms.lua", + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Artist gallery entry: a recolored player sheet derived from the player's own cache, plus two palette records." +} diff --git a/mods/examples/example_shiny_palette/mod.card b/mods/examples/example_shiny_palette/mod.card new file mode 100644 index 00000000..fed37896 --- /dev/null +++ b/mods/examples/example_shiny_palette/mod.card @@ -0,0 +1,26 @@ +-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling +-- and the manager detail pane; never by the loader's merge. +return { + summary = "A teal player recolor derived from your own cache, plus two palette records.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "cosmetic", "graphics", "beginner" }, + screenshots = { + { transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" }, + }, + differences = { + changed = { + "SPRITE_RED and SPRITE_RED_BIKE opt into trueColor", + "the PALLET town palette is recolored", + }, + added = { "EXAMPLE_SHINY palette record" }, + known = { + "the derived sheets only appear after a ROM import; without a cache " + .. "the transform writes nothing and the vanilla sheets keep rendering", + }, + }, + credits = { + { who = "pret/pokered", for_ = "the overworld sheet layout the transform recolors" }, + }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua b/mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua new file mode 100644 index 00000000..cea0cd71 --- /dev/null +++ b/mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua @@ -0,0 +1,108 @@ +-- Standalone: luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua +-- Asserts the palette records merge and the transform degrades cleanly +-- when there is no imported cache to read. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = require("src.core.Data") +Data:load() + +-- The transform reads assets/generated/** and writes save/mod-derived/**. +-- Hiding the cache is what puts this run on the no-cache path, which is +-- the branch a mod owes the player: write nothing, load anyway. The pixel +-- path needs a real LOVE run (love.image is not in the headless stub). +local MOD = "mods/examples/example_shiny_palette" + +local function noCacheFs() + local inner = T.fs.new(".") + local overlay = {} + local hidden = "assets/generated/" + local mount = "mods/example_shiny_palette" + + local function map(path) + if path == mount then return MOD end + if path and path:sub(1, #mount + 1) == mount .. "/" then + return MOD .. path:sub(#mount + 1) + end + return path + end + + local fs = { root = inner.root } + function fs.read(path) + if path:sub(1, #hidden) == hidden then return nil end + return overlay[path] or inner.read(map(path)) + end + function fs.write(path, body) overlay[path] = body return true end + function fs.createDirectory() return true end + function fs.load(path) return inner.load(map(path)) end + function fs.getInfo(path) + if path == "mods" then return { type = "directory" } end + if path:sub(1, #hidden) == hidden then return nil end + if overlay[path] then return { type = "file" } end + return inner.getInfo(map(path)) + end + function fs.getDirectoryItems(path) + if path == "mods" then return { "example_shiny_palette" } end + return inner.getDirectoryItems(map(path)) + end + return fs +end + +local run = T.sdk.loadMod(MOD, { data = Data, fs = noCacheFs() }) +T.eq(#run.errors, 0, + "loads clean with no cache to transform (" .. tostring(run.errors[1]) .. ")") +T.eq(run.mod and run.mod.manifest.assets_transforms, "transforms.lua", + "the manifest declares its transform") + +-- ------- palettes + +local shiny = Data.palettes.palettes.EXAMPLE_SHINY +T.check(shiny ~= nil, "the v2 named palette record merged") +T.eq(#shiny.colors, 4, "it carries exactly four colors") +T.eq(shiny.colors[1].r, 248, "the lightest shade is first") + +local pallet = Data.palettes.palettes.PALLET +T.eq(#pallet, 4, "the town palette override kept the raw four-triple shape") +T.eq(pallet[2][2], 232, "the override took") + +-- ------- the trueColor opt-out, applied by patch + +for _, id in ipairs({ "SPRITE_RED", "SPRITE_RED_BIKE" }) do + local sprite = Data.sprites[id] + T.eq(sprite.trueColor, true, id .. " opted into trueColor") + T.check(sprite.image ~= nil and sprite.image ~= "", + id .. " kept its sheet path (patch named only the flag)") + T.check(sprite.frames ~= nil, id .. " kept its frame count") +end + +-- ------- the recipe itself compiles and is a function(ctx) + +local chunk = assert(loadfile(MOD .. "/transforms.lua")) +local transform = chunk() +T.check(type(transform) == "function", "transforms.lua returns a function(ctx)") + +-- driven with an empty cache it must write nothing and not raise +local wrote = 0 +local ok, err = pcall(transform, { + exists = function() return false end, + readImage = function() error("must not read without exists()", 0) end, + writeImage = function() wrote = wrote + 1 end, + recolor = function(img) return img end, +}) +T.check(ok, "the recipe survives an empty cache (" .. tostring(err) .. ")") +T.eq(wrote, 0, "and writes nothing rather than failing the mod") + +-- with a cache present it derives one file per declared sheet +wrote = 0 +local read = {} +T.check(pcall(transform, { + exists = function() return true end, + readImage = function(rel) read[#read + 1] = rel return { rel } end, + writeImage = function() wrote = wrote + 1 end, + recolor = function(img) return img end, +}), "the recipe runs over a populated cache") +T.eq(wrote, #read, "every sheet it read, it wrote back") +T.check(wrote >= 1, "at least one sheet is derived") + +run.release() +T.finish("example_shiny_palette") diff --git a/mods/examples/example_shiny_palette/transforms.lua b/mods/examples/example_shiny_palette/transforms.lua new file mode 100644 index 00000000..5970cd2b --- /dev/null +++ b/mods/examples/example_shiny_palette/transforms.lua @@ -0,0 +1,32 @@ +-- Asset transform: the whole point of this example. It runs once at +-- install inside the restricted context -- read the player's own imported +-- cache, write under save/mod-derived// -- so the repo ships the +-- recipe and never a ROM-derived pixel. +-- +-- The derived path mirrors the cache path, so Assets.resolve picks it up +-- for every consumer of assets/generated/sprites/red.png with no registry +-- entry at all. A recolor is exactly this: read, recolor, write back +-- under the same relative name. +local SHEETS = { + "sprites/red.png", + "sprites/red_bike.png", +} + +-- lightest shade first; the recolor buckets every ink pixel into one of +-- these four by luminance, matching the importer's own 4-gray split +local TEAL = { + { 248, 248, 248 }, + { 120, 224, 216 }, + { 32, 128, 152 }, + { 8, 32, 64 }, +} + +return function(ctx) + for _, rel in ipairs(SHEETS) do + -- a player who has not imported yet simply gets no derived art; the + -- vanilla sheet keeps rendering and the mod stays loaded + if ctx.exists(rel) then + ctx.writeImage(ctx.recolor(ctx.readImage(rel), TEAL), rel) + end + end +end diff --git a/mods/examples/example_weather/CHANGELOG.md b/mods/examples/example_weather/CHANGELOG.md new file mode 100644 index 00000000..bc661d72 --- /dev/null +++ b/mods/examples/example_weather/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 + +- `EXAMPLE_RAIN` status record and the `example_weather_battles` ruleset. +- A `battle.damage` wrap that scales WATER and FIRE while it rains. +- Rain lifecycle driven by `battle.started` / `battle.turn_started` / `battle.ended`. diff --git a/mods/examples/example_weather/README.md b/mods/examples/example_weather/README.md new file mode 100644 index 00000000..3f912ed6 --- /dev/null +++ b/mods/examples/example_weather/README.md @@ -0,0 +1,96 @@ +# Weather Battles Example + +Adds rain: for the first five turns of every battle, WATER moves deal 1.5x +damage and FIRE moves deal 0.5x. Only under the `WEATHER` ruleset — pick +`gen1_faithful` and the mod is installed and inert. + +**Persona: the Mechanic Designer.** A new battle mechanic, no engine fork. +The status and ruleset registries plus one hook carry the whole thing. + +## Try it + +```sh +python3 tools/modkit.py validate mods/examples/example_weather --base imported +luajit mods/examples/example_weather/tests/example_weather_test.lua +``` + +Enable it (`example_weather = true` under `mods` in `options.lua`, or the +F10 manager), then **OPTIONS → RULESET → WEATHER**. + +## What it demonstrates + +| Seam | Where | +|---|---| +| `content.statuses:register` | `main.lua` — declaring a field effect | +| `content.rulesets:register` | `main.lua` — a ruleset the OPTIONS menu lists automatically | +| `content.rulesets:get` | `main.lua` — deriving from vanilla without requiring a private module | +| `hooks:wrap("battle.damage")` | `main.lua` — the one behavior change | +| `events:on("battle.started" / "battle.turn_started" / "battle.ended")` | `main.lua` — the rain counter | +| `mod.save:get/set` | `main.lua` — per-mod state, not a global | + +## Parity, at the mod level + +The engine's promise is that a mod-free game is unchanged. This example +makes the same promise one level up: a *player* who installs it but does +not select the ruleset gets vanilla battles. + +```lua +if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then + return next(ctx) +end +``` + +`next(ctx)` with the arguments it was handed *is* the vanilla call. No +allocation, no rounding, no reordering — the same number the engine would +have produced. Everything above that line is a gate, and every gate that +fails defers. + +## Preserving multiple returns + +`Damage.compute` returns two values: the damage number and an info table +carrying the crit flag and the type multiplier. A wrapper that returns only +the first silently throws the second away, and the battle log stops saying +"A critical hit!". + +```lua +local damage, info = next(ctx) +if type(damage) ~= "number" then return damage, info end +return math.max(1, math.floor(damage * scale)), info +``` + +Hook chains preserve every return value, so passing `info` back through is +all it takes. + +## Deriving a ruleset from vanilla + +A ruleset record is the *whole* rule table — `oneIn256Miss`, +`critUsesBaseSpeed`, `randMin`, `randMax` and the rest. Registering one +that only sets `name` would silently drop every Gen 1 quirk. So this mod +reads the vanilla record out of the merged registry and copies it: + +```lua +local base = mod.content.rulesets:get("gen1_faithful") +local weather = {} +for key, value in pairs(base) do weather[key] = value end +weather.name = "WEATHER" +weather.exampleWeather = true +``` + +`:get` on a registry is the public path to engine content. It needs no +permission, and it composes: if another mod patched `gen1_faithful` first, +this ruleset inherits that patch too. + +`exampleWeather` is not in the ruleset schema. Unknown fields on a record +registry are preserved rather than rejected — that is what makes rulesets +extensible, and it is how the damage hook recognizes its own ruleset +without a second lookup. + +## Missing dependency, handled + +If another mod removed `gen1_faithful`, `:get` returns nil. This example +logs a remediation line and returns — the rest of the game keeps working +and the manager shows one attributed message. No `assert`, no crash. + +## Credits + +- pret/pokered — the damage formula the hook scales. diff --git a/mods/examples/example_weather/main.lua b/mods/examples/example_weather/main.lua new file mode 100644 index 00000000..98a40717 --- /dev/null +++ b/mods/examples/example_weather/main.lua @@ -0,0 +1,86 @@ +-- Gallery #5 (Mechanic designer): a new battle mechanic with no engine +-- fork. A rain field effect scales WATER and FIRE damage, lives behind an +-- opt-in ruleset, and keeps its own counter in mod.save. +-- +-- The parity lesson is the ruleset gate: install this mod, leave the +-- ruleset on gen1_faithful, and every battle is byte-for-byte vanilla +-- because the hook returns next(...) untouched. +local RULESET = "example_weather_battles" +local RAIN_TURNS = 5 + +local BOOST = { WATER = 1.5 } +local DAMPEN = { FIRE = 0.5 } + +return function(mod) + -- ------- the field effect, declared as a status record + + mod.content.statuses:register("EXAMPLE_RAIN", { + id = "EXAMPLE_RAIN", + label = "RAIN", + hudLabel = "RAIN", + -- a field effect is never inflicted on a battler; the record is the + -- declaration the HUD and other mods read, the hook is the behavior + canInflict = function() return false end, + }) + + -- ------- the ruleset that turns it on + -- Read the vanilla record out of the merged registry rather than + -- requiring the module: same table, no engine_internals permission. + + local base = mod.content.rulesets:get("gen1_faithful") + if not base then + mod.log:error("gen1_faithful missing from the rulesets registry; " + .. "another mod removed it, so %s cannot be derived", RULESET) + return + end + local weather = {} + for key, value in pairs(base) do weather[key] = value end + weather.name = "WEATHER" + -- the marker the damage hook gates on; unknown fields ride through the + -- schema untouched, which is what makes rulesets extensible + weather.exampleWeather = true + mod.content.rulesets:register(RULESET, weather) + + -- ------- rain lifecycle, in this mod's own save namespace + + local function raining() + return (mod.save:get("turnsLeft", 0)) > 0 + end + + mod.events:on("battle.started", function(ev) + local ruleset = ev.battle and ev.battle.ruleset + if ruleset and ruleset.exampleWeather then + mod.save:set("turnsLeft", RAIN_TURNS) + else + mod.save:set("turnsLeft", 0) + end + end) + + mod.events:on("battle.turn_started", function() + local left = mod.save:get("turnsLeft", 0) + if left > 0 then mod.save:set("turnsLeft", left - 1) end + end) + + mod.events:on("battle.ended", function() + mod.save:set("turnsLeft", 0) + end) + + -- ------- the one behavior change + + mod.hooks:wrap("battle.damage", function(next, ctx) + -- the two gates, cheapest first: the player has to have picked the + -- ruleset, and it has to still be raining + if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then + return next(ctx) + end + local moveType = ctx.move and ctx.move.type + local scale = BOOST[moveType] or DAMPEN[moveType] + if not scale then return next(ctx) end + + -- Damage.compute returns (damage, info); pass the second value through + -- untouched or the crit and type-effectiveness flags vanish + local damage, info = next(ctx) + if type(damage) ~= "number" then return damage, info end + return math.max(1, math.floor(damage * scale)), info + end) +end diff --git a/mods/examples/example_weather/manifest.json b/mods/examples/example_weather/manifest.json new file mode 100644 index 00000000..077aa859 --- /dev/null +++ b/mods/examples/example_weather/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "example_weather", + "name": "Weather Battles Example", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "overhaul", + "category": "MECHANIC", + "game_version": ">=1.0.0 <2.0.0", + "priority": 100, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "Mechanic-designer gallery entry: a rain field effect behind an opt-in ruleset, driven by the battle.damage hook." +} diff --git a/mods/examples/example_weather/mod.card b/mods/examples/example_weather/mod.card new file mode 100644 index 00000000..ae389044 --- /dev/null +++ b/mods/examples/example_weather/mod.card @@ -0,0 +1,24 @@ +-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling +-- and the manager detail pane; never by the loader's merge. +return { + summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.", + author = "Pokemon Gen 1 Recompilation Project", + contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project", + tags = { "battle", "mechanic", "ruleset", "hardcore" }, + differences = { + changed = { + "under the WEATHER ruleset only: WATER move damage x1.5 and FIRE x0.5 while it rains", + }, + added = { + "example_weather_battles ruleset, selectable in OPTIONS", + "EXAMPLE_RAIN field-effect status record", + }, + known = { + "rain is unconditional at battle start and has no on-screen indicator yet", + }, + }, + credits = { + { who = "pret/pokered", for_ = "the damage formula the hook scales" }, + }, + compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 }, +} diff --git a/mods/examples/example_weather/tests/example_weather_test.lua b/mods/examples/example_weather/tests/example_weather_test.lua new file mode 100644 index 00000000..6aa28e1b --- /dev/null +++ b/mods/examples/example_weather/tests/example_weather_test.lua @@ -0,0 +1,60 @@ +-- Standalone: luajit mods/examples/example_weather/tests/example_weather_test.lua +-- Drives the battle.damage hook through the runtime bus, both with the +-- ruleset on and with it off. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Data = require("src.core.Data") +Data:load() + +local run = T.sdk.loadMod("mods/examples/example_weather", { data = Data }) +T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")") + +local weather = Data.rulesets.example_weather_battles +T.check(weather ~= nil, "the ruleset merged and is selectable") +T.eq(weather.name, "WEATHER", "the ruleset carries a display name") +T.eq(weather.oneIn256Miss, Data.rulesets.gen1_faithful.oneIn256Miss, + "the derived ruleset keeps every gen1_faithful rule") +T.check(Data.statuses.EXAMPLE_RAIN ~= nil, "the rain status record merged") + +-- the vanilla stand-in the hook wraps; 100 damage and an info table +local function vanilla() return 100, { crit = false, typeMult = 10 } end + +local function hit(ruleset, moveType) + return Runtime.call("battle.damage", vanilla, + { ruleset = ruleset, move = { type = moveType } }) +end + +-- ------- ruleset off: the mod is installed and changes nothing + +Runtime.emit("battle.started", { battle = { ruleset = Data.rulesets.gen1_faithful } }) +T.eq(hit(Data.rulesets.gen1_faithful, "WATER"), 100, + "gen1_faithful is untouched with the mod installed") +T.eq(hit(Data.rulesets.gen1_faithful, "FIRE"), 100, + "FIRE is untouched under gen1_faithful too") + +-- ------- ruleset on: rain scales WATER up and FIRE down + +Runtime.emit("battle.started", { battle = { ruleset = weather } }) +T.eq(hit(weather, "WATER"), 150, "rain boosts WATER damage") +T.eq(hit(weather, "FIRE"), 50, "rain dampens FIRE damage") +T.eq(hit(weather, "NORMAL"), 100, "every other type is untouched") + +local damage, info = hit(weather, "WATER") +T.eq(damage, 150, "the scaled damage is the first return") +T.check(info ~= nil and info.typeMult == 10, + "the info table survives the wrap") + +-- ------- the counter runs out + +for _ = 1, 5 do Runtime.emit("battle.turn_started", {}) end +T.eq(hit(weather, "WATER"), 100, "rain stops after its turn count") + +Runtime.emit("battle.started", { battle = { ruleset = weather } }) +T.eq(hit(weather, "WATER"), 150, "a new battle starts the rain again") +Runtime.emit("battle.ended", {}) +T.eq(hit(weather, "WATER"), 100, "battle.ended clears the rain") + +run.release() +T.finish("example_weather") diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 00000000..bcd2accb --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# Unified test entry point (21-testing-and-ci §CI). +# +# Runs every tier that this checkout can run and exits non-zero if any of +# them fails. The tier split is what makes that possible: T1/T2/T4 need +# nothing but the committed fixture dataset, so they run anywhere -- +# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs +# data/generated/, so it is skipped automatically when the ROM has never +# been imported rather than failing the run. +# +# scripts/test.sh every tier this checkout can run +# scripts/test.sh --quick skip the slow content tier +# scripts/test.sh --bless re-pin the fingerprint goldens +# WITH_SHOTS=1 scripts/test.sh also capture and diff golden shots +# (fails today -- see the T5 block below) +# +# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but +# the engine targets LuaJIT/5.1 semantics so luajit is the default). + +set -uo pipefail + +cd "$(dirname "$0")/.." + +LUA=${LUA:-luajit} +BLESS=0 +QUICK=0 +SHOTS=${WITH_SHOTS:-0} + +for arg in "$@"; do + case "$arg" in + --bless) BLESS=1 ;; + --bless-shots) SHOTS=1; BLESS=1 ;; + --quick) QUICK=1 ;; + --help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +if ! command -v "$LUA" >/dev/null 2>&1; then + echo "no lua interpreter '$LUA' on PATH (set LUA=...)" >&2 + exit 2 +fi + +# The save-directory sandbox (conf.lua reads POKEPORT_IDENTITY) is scoped +# to the shot tier, which is the only one that starts a real LOVE process +# and could write into a developer's save folder. Exporting it for the +# whole run instead would change what SaveIO.defaultPath() returns, and the +# save-editor suite pins that to the default identity. +SANDBOX_IDENTITY="ci-$$" + +FAILED=() +run_tier() { + local label="$1"; shift + echo "" + echo "==============================================================" + echo " $label" + echo "==============================================================" + if "$@"; then + echo "-- $label: PASS" + else + echo "-- $label: FAIL" + FAILED+=("$label") + fi +} + +# ------- ROM-free tiers: these are what CI runs + +run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua +run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua + +# The modded-link desync suite (symmetric mod, handshake fail-closed, +# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as +# tests/modkit/cases/link_desync.lua. +# +# tests/run_link_tests.lua is a different matter: it calls Data:load() at +# :27 and so needs data/generated/. It is grouped with the content tier +# until that bootstrap can take an injected dataset. +# ------- content tier: only meaningful with an imported ROM + + +# tests/run_tests.lua carries two pre-existing failures that are stale +# about the chip-audio architecture rather than real defects: +# +# Pikachu cry WAV exists nothing writes .wav any more -- cries are +# synthesized at play time from +# Data.audio.cries + programs.bin +# low-health alarm sfx extracted the importer deliberately does not +# extract it; Sound.startLoop falls back to +# ChipAudio.newLowHealthAlarm (Sound.lua:268) +# +# They are left in place (fixing them is a separate, reviewed change), so +# the tier passes on exactly this baseline and fails the moment a third +# failure appears or one of these two changes identity. Ignoring the exit +# code outright would hide every future content regression. +KNOWN_CONTENT_FAILURES=2 +KNOWN_CONTENT_LINES="FAIL Pikachu cry WAV exists +FAIL low-health alarm sfx extracted" + +run_content_behavior() { + local out + out=$("$LUA" tests/run_tests.lua 2>&1) + local count + count=$(printf '%s\n' "$out" | grep -c '^FAIL ' || true) + local lines + lines=$(printf '%s\n' "$out" | grep '^FAIL ' | sort) + + if [ "$count" -eq "$KNOWN_CONTENT_FAILURES" ] \ + && [ "$lines" = "$(printf '%s\n' "$KNOWN_CONTENT_LINES" | sort)" ]; then + printf '%s\n' "$out" | tail -3 + echo "(the $KNOWN_CONTENT_FAILURES known stale audio assertions, unchanged)" + return 0 + fi + + printf '%s\n' "$out" | grep '^FAIL ' || true + printf '%s\n' "$out" | tail -2 + echo "expected exactly $KNOWN_CONTENT_FAILURES known failures; got $count" + return 1 +} + +if [ -f data/generated/maps.lua ]; then + if [ "$QUICK" = "1" ]; then + echo "" + echo "-- T3 content: skipped (--quick)" + else + run_tier "T3 content behavior (Red)" run_content_behavior + run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua + run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua + fi +else + echo "" + echo "-- T3 content + run_link_tests: skipped (no data/generated/ --" + echo " import a ROM to run them; the modded-link cases ran in T4)" +fi + +# ------- golden screenshots: needs love + a display + +if [ "$SHOTS" = "1" ]; then + SHOT_DIR=${SHOT_DIR:-/tmp/pokeport-shots} + export SHOT_DIR + mkdir -p "$SHOT_DIR" + SHOT_DRIVER=tests/drivers/shots_fixture.lua + + # The fixture goldens are not capturable yet. A driver only ever runs + # after main.lua's bootGame(), so it cannot redirect Data:load(), and + # src/core/Data.lua has no POKEPORT_DATA_DIR branch -- 21-testing-and-ci + # §"Engine changes" specifies one, but it is not implemented, so a LOVE + # process has no way to boot tests/fixture_data. On a ROM-less checkout + # main.lua does not even reach the game: RomImporter.isReady() is false + # and it opens the importer instead. + # + # WITH_SHOTS is opt-in, so asking for a tier that cannot run is an error, + # not a skip. Reporting "pass" here is what made the whole pipeline look + # delivered while never diffing a single pixel. + if [ ! -f "$SHOT_DRIVER" ]; then + echo "" + echo "-- T5 shots: NOT WIRED ($SHOT_DRIVER does not exist)." + echo " Fixture capture needs the POKEPORT_DATA_DIR override in" + echo " src/core/Data.lua so LOVE can boot tests/fixture_data." + FAILED+=("T5 shots (requested but not wired)") + elif ! command -v love >/dev/null 2>&1; then + echo "" + echo "-- T5 shots: love is not on PATH but WITH_SHOTS was requested" + FAILED+=("T5 shots (love missing)") + else + RUNNER="love ." + command -v xvfb-run >/dev/null 2>&1 && RUNNER="xvfb-run -a love ." + run_tier "T5 shot capture" \ + env POKEPORT_IDENTITY="$SANDBOX_IDENTITY" POKEPORT_DRIVER="$SHOT_DRIVER" $RUNNER + if [ "$BLESS" = "1" ]; then + run_tier "T5 shot bless" \ + python3 tools/compare_shots.py tests/goldens/shots "$SHOT_DIR" --bless + else + run_tier "T5 shot diff" \ + python3 tools/compare_shots.py tests/goldens/shots "$SHOT_DIR" + fi + fi +fi + +# ------- fingerprint blessing + +if [ "$BLESS" = "1" ] && [ "$SHOTS" != "1" ]; then + echo "" + echo "re-pinning fingerprint goldens (deliberate parity change -- record it" + echo "in docs/known-differences.md or docs/new-features.md)" + "$LUA" tests/bless_fingerprints.lua || FAILED+=("fingerprint bless") +fi + +# ------- verdict + +echo "" +echo "==============================================================" +if [ ${#FAILED[@]} -eq 0 ]; then + echo " ALL TIERS PASSED" + echo "==============================================================" + exit 0 +fi + +echo " ${#FAILED[@]} TIER(S) FAILED" +for tier in "${FAILED[@]}"; do echo " - $tier"; done +echo "==============================================================" +exit 1 diff --git a/src/audio/ChipAsm.lua b/src/audio/ChipAsm.lua new file mode 100644 index 00000000..95e14ae5 --- /dev/null +++ b/src/audio/ChipAsm.lua @@ -0,0 +1,408 @@ +-- Authoring DSL for the channel bytecode ChipAudio's Channel:nextEvent +-- decodes: note-event Lua tables in, a self-contained program blob out. +-- The blob is mounted as pseudo-bank 0 by ChipAudio, so addresses are based +-- at 0x4000 exactly like the ROM's own 0x4000-window programs. +-- +-- Two passes: the first sizes every event and records where each label and +-- each event index lands, the second emits bytes with call/loop targets +-- resolved. Nothing here touches love.*, so mods assemble at load time and +-- headless tools assemble without a graphics context. + +local ChipAsm = {} + +local BASE_ADDRESS = 0x4000 +local FRAME_TICKS = 256 + +local NOTES = { + C = 0, ["C#"] = 1, Db = 1, D = 2, ["D#"] = 3, Eb = 3, E = 4, + F = 5, ["F#"] = 6, Gb = 6, G = 7, ["G#"] = 8, Ab = 8, A = 9, + ["A#"] = 10, Bb = 10, B = 11, +} + +-- mirrors ChipAudio's snapTicks so authored drums land on the same sample +-- grid as the ROM's own drum tables +local function snapTicks(ticks) + return math.floor((ticks * 735 + 256) / 512) +end + +-- ------- validation + +local Cursor = {} +Cursor.__index = Cursor + +local function cursor(channel, scope) + return setmetatable({ channel = channel, scope = scope, index = 0 }, Cursor) +end + +function Cursor:fail(message) + error(("ChipAsm: channel %d %sevent %d: %s") + :format(self.channel, self.scope, self.index, message), 0) +end + +function Cursor:int(value, low, high, what) + if type(value) ~= "number" or value ~= math.floor(value) then + self:fail(("%s must be an integer, got %s"):format(what, tostring(value))) + end + if value < low or value > high then + self:fail(("%s out of range %d-%d: %d"):format(what, low, high, value)) + end + return value +end + +function Cursor:length(value) + return self:int(value or 1, 1, 16, "len") +end + +-- the fade nibble is signed: bit 3 set means a decay of the low three bits +function Cursor:fade(value) + value = self:int(value or 0, -7, 7, "fade") + if value < 0 then return 8 - value end + return value +end + +function Cursor:pitch(event) + if event.pitch ~= nil then return self:int(event.pitch, 0, 11, "pitch") end + local name = event.note + if type(name) ~= "string" then + self:fail("note must be a name or an explicit pitch") + end + local pitch = NOTES[name] + if not pitch then self:fail(("unknown note %q"):format(name)) end + return pitch +end + +-- ------- pass 1: events to sized chunks +-- a chunk is a byte, or a two-byte reference to a label / event index that +-- pass 2 resolves once every channel's size is known + +local function reference(cur, target) + return { + label = target, offsets = cur.offsets, ref = cur, index = cur.index, + } +end + +local function emitters(cur, hw) + local E = {} + + function E.label() end + + function E.note(event, out) + if hw == 4 then cur:fail("channel 4 plays drums, not notes") end + out[#out + 1] = cur:pitch(event) * 16 + cur:length(event.len) - 1 + end + + function E.rest(event, out) + local len = event.rest == true and event.len or event.rest + out[#out + 1] = 0xC0 + cur:length(len) - 1 + end + + function E.drum(event, out) + if hw ~= 4 then cur:fail("drums only play on channel 4") end + local id = cur:int(event.drum, 0, 255, "drum") + local len = cur:length(event.len) - 1 + -- ids from 11 up do not fit the note nibble and carry an extra byte + if id >= 11 then + out[#out + 1] = 0xB0 + len + out[#out + 1] = id + else + out[#out + 1] = id * 16 + len + end + end + + -- the interpreter reads the packed byte per hardware channel: none on + -- noise, wave level plus instrument on channel 3, volume plus fade on the + -- two square channels + function E.notetype(event, out) + local spec = event.notetype + out[#out + 1] = 0xD0 + cur:int(spec.speed or 12, 0, 15, "speed") + if hw == 4 then return end + if hw == 3 then + out[#out + 1] = cur:int(spec.waveLevel or 0, 0, 3, "waveLevel") * 16 + + cur:int(spec.waveInstrument or 0, 0, 15, "waveInstrument") + else + out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16 + + cur:fade(spec.fade) + end + end + + function E.octave(event, out) + out[#out + 1] = 0xE0 + 8 - cur:int(event.octave, 1, 8, "octave") + end + + function E.perfectPitch(_, out) + out[#out + 1] = 0xE8 + end + + function E.vibrato(event, out) + local spec = event.vibrato + out[#out + 1] = 0xEA + out[#out + 1] = cur:int(spec.delay or 0, 0, 255, "vibrato delay") + out[#out + 1] = cur:int(spec.depth or 0, 0, 15, "vibrato depth") * 16 + + cur:int(spec.rate or 0, 0, 15, "vibrato rate") + end + + function E.slide(event, out) + local spec = event.slide + out[#out + 1] = 0xEB + out[#out + 1] = cur:int(spec.len or 0, 0, 255, "slide len") + out[#out + 1] = (8 - cur:int(spec.octave or 4, 1, 8, "slide octave")) * 16 + + cur:pitch(spec) + end + + function E.duty(event, out) + out[#out + 1] = 0xEC + out[#out + 1] = cur:int(event.duty, 0, 3, "duty") + end + + function E.dutyPattern(event, out) + local packed = 0 + for slot = 1, 4 do + packed = packed * 4 + cur:int(event.dutyPattern[slot], 0, 3, "dutyPattern") + end + out[#out + 1] = 0xFC + out[#out + 1] = packed + end + + function E.tempo(event, out) + local tempo = cur:int(event.tempo, 0, 0xFFFF, "tempo") + out[#out + 1] = 0xED + out[#out + 1] = math.floor(tempo / 0x100) + out[#out + 1] = tempo % 0x100 + end + + function E.pan(event, out) + out[#out + 1] = 0xEE + out[#out + 1] = cur:int(event.pan, 0, 255, "pan") + end + + function E.executeMusic(_, out) + out[#out + 1] = 0xF8 + end + + function E.call(event, out) + out[#out + 1] = 0xFD + out[#out + 1] = reference(cur, event.call) + end + + function E.ret(_, out) + out[#out + 1] = 0xFF + end + + function E.loop(event, out) + out[#out + 1] = 0xFE + out[#out + 1] = cur:int(event.loop.count or 0, 0, 255, "loop count") + out[#out + 1] = reference(cur, event.loop.to) + end + + -- sfx-only: the 0x20-0x2F note form carries its own volume and fade plus + -- either a raw frequency register or a noise parameter + function E.squareNote(event, out) + local spec = event.squareNote + local register = cur:int(spec.frequency or 0, 0, 0x7FF, "frequency") + out[#out + 1] = 0x20 + cur:length(spec.len) - 1 + out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16 + + cur:fade(spec.fade) + out[#out + 1] = register % 0x100 + out[#out + 1] = math.floor(register / 0x100) + end + + function E.noiseNote(event, out) + local spec = event.noiseNote + out[#out + 1] = 0x20 + cur:length(spec.len) - 1 + out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16 + + cur:fade(spec.fade) + out[#out + 1] = cur:int(spec.parameter or 0, 0, 255, "parameter") + end + + function E.pitchSweep(event, out) + local spec = event.pitchSweep + out[#out + 1] = 0x10 + out[#out + 1] = cur:int(spec.pace or 0, 0, 7, "sweep pace") * 16 + + (spec.subtract and 8 or 0) + + cur:int(spec.shift or 0, 0, 7, "sweep shift") + end + + return E +end + +-- the event key that names the command, checked in a fixed order so an +-- event carrying `len` alongside `note` is still a note +local KEYS = { + "label", "note", "pitch", "rest", "drum", "notetype", "octave", + "perfectPitch", "vibrato", "slide", "duty", "dutyPattern", "tempo", "pan", + "executeMusic", "call", "ret", "loop", "squareNote", "noiseNote", + "pitchSweep", +} + +-- an unresolved reference is one chunk but two bytes, so offsets are counted +-- rather than read off the chunk list's length +local function byteSize(chunks) + local size = 0 + for _, chunk in ipairs(chunks) do + size = size + (type(chunk) == "table" and 2 or 1) + end + return size +end + +local function assembleStream(program, cur, E, out, offsets, labels) + cur.offsets = offsets + for index, event in ipairs(program) do + cur.index = index + if type(event) ~= "table" then cur:fail("event must be a table") end + local offset = byteSize(out) + offsets[index] = offset + local kind + for _, key in ipairs(KEYS) do + if event[key] ~= nil then kind = key break end + end + if not kind then cur:fail("no command in event") end + if kind == "pitch" then kind = "note" end + if kind == "label" then labels[event.label] = offset end + E[kind](event, out) + end +end + +-- a stream that cannot fall off its end needs no terminator; anything else +-- gets the endchannel byte the interpreter stops on +local function endsItself(program) + local last = program[#program] + if type(last) ~= "table" then return false end + if last.ret then return true end + return last.loop ~= nil and (last.loop.count or 0) == 0 +end + +local function assembleChannel(spec, hw, number, prelude) + local cur = cursor(number, "") + local out, offsets, labels = {}, {}, {} + for _, byte in ipairs(prelude or {}) do out[#out + 1] = byte end + local program = spec.program or {} + assembleStream(program, cur, emitters(cur, hw), out, offsets, labels) + if not endsItself(program) then out[#out + 1] = 0xFF end + -- subroutines follow the body so every call target is inside the blob + local names = {} + for name in pairs(spec.subroutines or {}) do names[#names + 1] = name end + table.sort(names) + for _, name in ipairs(names) do + labels[name] = byteSize(out) + local sub = spec.subroutines[name] + local subCursor = cursor(number, ("subroutine %q "):format(name)) + assembleStream(sub, subCursor, emitters(subCursor, hw), out, {}, labels) + if not endsItself(sub) then out[#out + 1] = 0xFF end + end + return { bytes = out, labels = labels } +end + +-- ------- pass 2: resolve the references and pack the blob + +local function targetAddress(chunk, labels, base) + local target = chunk.label + local offset + if type(target) == "number" then + offset = chunk.offsets[target] + if not offset then + chunk.ref.index = chunk.index + chunk.ref:fail(("loop target event %d does not exist"):format(target)) + end + else + offset = labels[target] + if not offset then + chunk.ref.index = chunk.index + chunk.ref:fail(("unknown label %q"):format(tostring(target))) + end + end + return offset + base +end + +local function pack(channels, blobBase) + local pieces = {} + for _, channel in ipairs(channels) do + local base = channel.base + blobBase + for _, byte in ipairs(channel.bytes) do + if type(byte) == "table" then + local address = targetAddress(byte, channel.labels, base) + pieces[#pieces + 1] = string.char(address % 0x100) + pieces[#pieces + 1] = string.char(math.floor(address / 0x100) % 0x100) + else + pieces[#pieces + 1] = string.char(byte % 0x100) + end + end + end + return table.concat(pieces) +end + +-- friendly drum rows to the segment lists Engine:noiseInstrument caches +local function drumSegments(rows) + local drums = {} + for id, program in pairs(rows) do + local segments, ticks = {}, 0 + for index, row in ipairs(program) do + local length = row.len or 1 + if type(length) ~= "number" or length < 1 or length > 16 then + error(("ChipAsm: drum %s row %d: len out of range 1-16") + :format(tostring(id), index), 0) + end + local duration = length * FRAME_TICKS + segments[#segments + 1] = { + startSample = snapTicks(ticks), + endSample = snapTicks(ticks + duration), + volume = row.volume or 0, + fade = row.fade or 0, + parameter = row.parameter or 0, + } + ticks = ticks + duration + end + drums[id] = segments + end + return drums +end + +local function assemble(spec, sfx) + local channels, size = {}, 0 + for index, channelSpec in ipairs(spec.channels or {}) do + local hw = channelSpec.hw or index + if type(hw) ~= "number" or hw < 1 or hw > 4 then + error(("ChipAsm: channel %d: hw must be 1-4"):format(index), 0) + end + -- the global tempo rides on the first channel, the way the ROM's own + -- songs write it + local prelude + if index == 1 and spec.tempo and not sfx then + local tempo = cursor(index, ""):int(spec.tempo, 0, 0xFFFF, "tempo") + prelude = { 0xED, math.floor(tempo / 0x100), tempo % 0x100 } + end + local built = assembleChannel(channelSpec, hw, index, prelude) + built.base = size + -- effect programs live on channels 5-8, which is how the interpreter + -- tells an effect's command set from a song's + built.number = sfx and hw + 4 or hw + size = size + byteSize(built.bytes) + channels[#channels + 1] = built + end + local blob = pack(channels, BASE_ADDRESS) + local layout = {} + for _, channel in ipairs(channels) do + layout[#layout + 1] = { + number = channel.number, + address = BASE_ADDRESS + channel.base, + } + end + return { + chip = { + blob = blob, + channels = layout, + waves = spec.waves, + drums = spec.drums and drumSegments(spec.drums) or nil, + engine = spec.engine or 1, + }, + } +end + +function ChipAsm.song(spec) + return assemble(spec, false) +end + +function ChipAsm.sfx(spec) + return assemble(spec, true) +end + +return ChipAsm diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index 00e16dc0..b98f2590 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -422,8 +422,16 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts) -- effect runs AFTER each block displays, so block 1 shows normal. -- PlayAnimation pushes rOBP0 around every subanimation row (:246-251 -- / :259-262), so the ambient palette returns when the toss ends. - local ballFlicker = opts - and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL") + -- opts.ballFlicker carries the ball record's flicker flag; the id + -- check covers callers that only pass the ball item. + local wantsFlicker + if opts and opts.ballFlicker ~= nil then + wantsFlicker = opts.ballFlicker + else + wantsFlicker = opts + and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL") + end + local ballFlicker = wantsFlicker and (moveId == "TOSS_ANIM" or moveId == "GREATTOSS_ANIM" or moveId == "ULTRATOSS_ANIM") local obp0Flip = false diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 08204d29..02c1311b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -10,14 +10,18 @@ -- bide, recharge, confusion, screens, substitute, transform, ...) is -- ported from engine/battle/core.asm; see docs/behavior-porting-notes.md. +local Assets = require("src.render.Assets") local Catching = require("src.battle.Catching") local Damage = require("src.battle.Damage") +local EffectRegistry = require("src.battle.EffectRegistry") local Experience = require("src.battle.Experience") local Font = require("src.render.Font") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") local TrainerAI = require("src.battle.TrainerAI") local TurnOrder = require("src.battle.TurnOrder") @@ -51,14 +55,17 @@ local imagePadBottom = {} -- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- rebuilt for any battle pic, whatever code loaded it local imageMeta = {} --- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy -local function getImage(path, pal) +-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy. +-- trueColor art (14 §the 4-shade contract) opts out of the quantize +-- entirely, so its palette variant collapses back onto the plain path. +local function getImage(path, pal, trueColor) if not path then return nil end + if trueColor then pal = nil end local key = pal and (path .. "#" .. pal.name) or path if not imageCache[key] then local img, pad = nil, 0 if love.image and love.image.newImageData then - local id = love.image.newImageData(path) + local id = Assets.imageData(path) if pal then local c = pal.colors id:mapPixel(function(_, _, r, g, b, a) @@ -82,15 +89,23 @@ local function getImage(path, pal) img = love.graphics.newImage(id) pad = h - 1 - bottom else - img = love.graphics.newImage(path) -- headless stub: no pixel access + img = Assets.image(path) -- headless stub: no pixel access end imageCache[key] = img imagePadBottom[img] = pad - imageMeta[img] = { path = path, pal = pal } + imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil } end return imageCache[key] end +-- hot reload: the next getImage re-resolves every pic through the asset +-- search path and re-measures its ground padding +function BattleState.invalidate() + imageCache, imagePadBottom, imageMeta = {}, {}, {} +end + +Assets.register(BattleState.invalidate) + -- the species' SGB palette (data/pokemon/palettes.asm), or nil local function monPalette(data, species) local p = data.palettes @@ -114,6 +129,8 @@ local function fadeImage(img, bgp) if not bgp or not img then return img end local meta = imageMeta[img] if not meta then return img end + -- a full-color pic has no DMG shades to remap + if meta.trueColor then return img end local PaletteFX = require("src.render.PaletteFX") local base = meta.pal and meta.pal.colors or PaletteFX.GRAYS local name = (meta.pal and meta.pal.name or "GB") @@ -137,20 +154,19 @@ function BattleState:picImage(img) return fadeImage(img, self:activeBgp()) end --- Gen 1 trainer Pokémon have fixed DVs (engine/battle/core.asm) +-- Gen 1 trainer Pokémon have fixed DVs (engine/battle/core.asm); +-- constants.trainerDvs overrides, this is the imported-cache fallback local TRAINER_DVS = { attack = 9, defense = 8, speed = 8, special = 8, hp = 8 } --- Status-move effects whose pokered handlers call MoveHitTest (sleep/ --- poison/paralyze/confusion/leech seed/disable and the primary --- stat-down moves). Everything else in MoveEffects.primary is --- self-targeting and never rolls accuracy. Mimic also hit-tests but --- runs its own mid-move flow (resolveMimic). -local ACC_CHECKED_STATUS = { - SLEEP_EFFECT = true, POISON_EFFECT = true, PARALYZE_EFFECT = true, - CONFUSION_EFFECT = true, LEECH_SEED_EFFECT = true, DISABLE_EFFECT = true, - ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true, - DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true, - ACCURACY_DOWN1_EFFECT = true, +-- charge-turn texts by move id; the move record's chargeText field wins +-- (ChargeEffect's per-move text pointers) +local CHARGE_TEXT = { + FLY = "%s\nflew up high!", + DIG = "%s\ndug a hole!", + RAZOR_WIND = "%s\nmade a whirlwind!", + SOLARBEAM = "%s\ntook in sunlight!", + SKULL_BASH = "%s\nlowered its head!", + SKY_ATTACK = "%s\nis glowing!", } -- pokered's / text macros (home/text.asm @@ -206,12 +222,14 @@ end local function makeBattler(data, mon, isPlayer, save) local def = data.pokemon[mon.species] + local badgeBoosts = data.constants and data.constants.badgeBoosts local badges = nil if isPlayer and save then - -- Gen 1 badge stat boosts (x9/8) + -- Gen 1 badge stat boosts (x9/8); the badge set follows the merged + -- badgeBoosts rows so a retuned list changes what gets baked in badges = {} - for _, b in ipairs({ "BOULDERBADGE", "THUNDERBADGE", "SOULBADGE", "VOLCANOBADGE" }) do - if save.inventory[b] then badges[b] = true end + for _, row in ipairs(badgeBoosts or Damage.BADGE_BOOSTS) do + if save.inventory[row.badge] then badges[row.badge] = true end end end return { @@ -220,6 +238,9 @@ local function makeBattler(data, mon, isPlayer, save) name = mon.nickname or def.name, isPlayer = isPlayer, badges = badges, + -- merged registry views consumed by the pure battle modules + badgeBoosts = badgeBoosts, + statuses = data.statuses, shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) stages = {}, -- volatile state; Transform/Conversion/Mimic override the cur* fields @@ -227,7 +248,7 @@ local function makeBattler(data, mon, isPlayer, save) curTypes = def.types, curMoves = mon.moves, sprite = getImage(isPlayer and def.spriteBack or def.spriteFront, - monPalette(data, mon.species)), + monPalette(data, mon.species), def.trueColor), } end @@ -244,7 +265,8 @@ function BattleState:speciesSprite(species, isPlayerSide) local PaletteFX = require("src.render.PaletteFX") local colors = PaletteFX.monPal(self.data, species, true) return getImage(isPlayerSide and def.spriteBack or def.spriteFront, - colors and { name = "GRAYMON", colors = colors } or nil) + colors and { name = "GRAYMON", colors = colors } or nil, + def.trueColor) end local function markSeen(game, species) @@ -278,9 +300,26 @@ local function newBattle(game) local self = setmetatable({}, BattleState) self.game = game self.data = game.data - self.ruleset = Rulesets[game.save.options and game.save.options.ruleset or "gen1_faithful"] - or Rulesets.gen1_faithful + -- ruleset from the merged registry (the requires above are the same + -- records on a mod-free boot); an unknown save value falls back to the + -- default with a notice instead of silently switching behavior + local rulesets = game.data.rulesets or Rulesets + local selected = game.save.options and game.save.options.ruleset + local fallback = (game.data.constants and game.data.constants.defaultRuleset) + or "gen1_faithful" + local ruleset = selected and rulesets[selected] + if selected and not ruleset then + Logger.warn("unknown ruleset %s; using %s", tostring(selected), fallback) + end + self.ruleset = ruleset or rulesets[fallback] or Rulesets.gen1_faithful self.rng = function(a, b) return love.math.random(a, b) end + -- side/field substrate: vanilla writes nothing here, but every battle + -- carries the stable shape mods hang screens/hazards/tokens on + self.sides = { + { index = 1, battlers = {}, screens = {}, hazards = {}, tokens = {} }, + { index = 2, battlers = {}, screens = {}, hazards = {}, tokens = {} }, + } + self.field = { weather = nil, tokens = {}, sides = self.sides } TypeChart.load(game.data) -- the subanimation player (data/battle_anims via battle_anims.lua) if game.data.battle_anims then @@ -380,17 +419,35 @@ function BattleState.newTrainer(game, oppClass, partyIndex) self.enemyAIMods = self.trainer.aiMods local partyDef = self.trainer.parties[partyIndex or 1] assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex))) + if Runtime.wantsHook("trainer.party") then + partyDef = Runtime.call("trainer.party", function(_, _, party) + return party + end, oppClass, partyIndex or 1, partyDef) or partyDef + end + local trainerDvs = (game.data.constants and game.data.constants.trainerDvs) + or TRAINER_DVS self.enemyParty = {} for _, slot in ipairs(partyDef) do local mon = Pokemon.new(game.data, slot.species, slot.level) -- fixed trainer DVs, recomputed stats - mon.dvs = TRAINER_DVS + mon.dvs = trainerDvs mon.stats = require("src.pokemon.Stats").calc(game.data.pokemon[slot.species], - slot.level, TRAINER_DVS) + slot.level, trainerDvs) mon.hp = mon.stats.hp table.insert(self.enemyParty, mon) end applySpecialMoves(game.data, oppClass, partyIndex or 1, self.enemyParty) + -- a party slot's own moves list wins over the legacy boss-move tables + for i, slot in ipairs(partyDef) do + local mon = self.enemyParty[i] + if mon and slot.moves then + mon.moves = {} + for _, moveId in ipairs(slot.moves) do + local mdef = game.data.moves[moveId] + table.insert(mon.moves, { id = moveId, pp = mdef and mdef.pp or 0 }) + end + end + end self.enemyIndex = 1 local playerMon = Party.firstHealthy(game.save.party) if not playerMon then @@ -492,6 +549,31 @@ function BattleState:uiNext(factory) table.insert(self.queue, self.nextInsert, { ui = factory }) end +-- ui rows compose screens unpushed (updateQueue pushes them), so +-- Screens.push's mod-screen degrade can't cover them; mirror it here, +-- stamping the id the same way +function BattleState:buildScreen(id, ...) + local game = self.game + local factory = Screens.get(game, id) + local inst + if factory.__modOwned then + -- a broken mod screen degrades to the builtin, never a dead end + local ok, result = pcall(factory.new, game, ...) + if ok and result then + inst = result + else + Logger.error("mod screen '%s' failed: %s -- using builtin", + id, tostring(result)) + Screens.invalidate() + inst = require("src.ui." .. id).new(game, ...) + end + else + inst = factory.new(game, ...) + end + inst.screenId = inst.screenId or id + return inst +end + -- insert a wait for the HP bars to finish draining (UpdateHPBar): -- the queue holds until every battler's displayed HP catches up function BattleState:drainNext() @@ -635,7 +717,9 @@ function BattleState:updateQueue() local ok = pcall(self.animPlayer.start, self.animPlayer, item.anim, item.attackerIsPlayer, (item.shakes or item.ball) - and { shakes = item.shakes, ball = item.ball } + and { shakes = item.shakes, ball = item.ball, + ballFlicker = item.ball + and self:ballFlicker(item.ball) or nil } or nil) self.animPlaying = ok end @@ -739,9 +823,30 @@ function BattleState:computeMusicKind() return "wild" end +-- side tables mirror the singles battlers; called before every +-- battler-switch notification so sides[i].battlers[1] stays honest +function BattleState:syncSides() + self.sides[1].battlers[1] = self.player + self.sides[2].battlers[1] = self.enemy +end + +function BattleState:sideOf(battler) + return (battler and battler.isPlayer) and self.sides[1] or self.sides[2] +end + +-- battle.started's kind verb: the mutated ghost/safari/oldman variants +-- override the constructor's wild/trainer/link +function BattleState:battleKind() + if self.ghost then return "ghost" end + if self.safari then return "safari" end + if self.demo then return "oldman" end + return self.kind +end + function BattleState:enter() if self.dead then self.game.stack:pop() + Runtime.emit("battle.ended", { battle = self, result = "skipped" }) if self.onFinish then self.onFinish("skipped") end return end @@ -801,6 +906,13 @@ function BattleState:enter() end self.phase = "messages" self.afterQueue = "menu" + self:syncSides() + Runtime.emit("battle.started", { + battle = self, kind = self:battleKind(), + trainerId = self.trainer and self.trainer.id, + species = self.enemy and self.enemy.mon.species, + level = self.enemy and self.enemy.mon.level, + }) end -- any pop (finish, script teardown) must silence the alarm loop @@ -1021,7 +1133,7 @@ function BattleState:resolveMimic(user, target, move, moveInst) self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, { wait = 50 }) if target.invulnerable - or not Damage.accuracyRoll(self.ruleset, move, user, target, self.rng) then + or not self:accuracyRoll(move, user, target) then self:sayNext("But, it failed!") return end @@ -1185,10 +1297,72 @@ function BattleState:moveDef(moveInst) return self.data.moves[moveInst.id] end +-- the merged move_effects record for an effect id; the module records +-- cover battles built without a loader +function BattleState:effectRecord(effect) + local effects = self.data.move_effects + if effects then return effects[effect] end + return MoveEffects.RECORDS[effect] +end + +-- the merged ball record (Catching.attempt handles the unknown-id default) +function BattleState:ballDef(ball) + local balls = self.data.balls + return balls and balls[ball] or Catching.BALLS[ball] +end + +-- the HUD label drawn in place of the level for a statused mon +function BattleState:statusLabel(mon) + local record = Status.recordFor(self.data.statuses, mon.status) + if record then + return record.hudLabel or record.label or mon.status + end + return mon.status +end + +-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy +function BattleState:accuracyRoll(move, user, target) + if Runtime.wantsHook("battle.accuracy") then + return Runtime.call("battle.accuracy", function(c) + return Damage.accuracyRoll(c.ruleset, c.move, c.user, c.target, c.rng) + end, { battle = self, ruleset = self.ruleset, move = move, + user = user, target = target, rng = self.rng }) + end + return Damage.accuracyRoll(self.ruleset, move, user, target, self.rng) +end + +-- Damage.compute, hooked as battle.damage; the ctx table is only built +-- when a chain is installed, so the no-mod path allocates nothing +function BattleState:computeDamage(user, target, move, opts) + if Runtime.wantsHook("battle.damage") then + return Runtime.call("battle.damage", function(c) + return Damage.compute(c.ruleset, c.user, c.target, c.move, c.opts) + end, { battle = self, ruleset = self.ruleset, user = user, + target = target, move = move, opts = opts, rng = self.rng }) + end + return Damage.compute(self.ruleset, user, target, move, opts) +end + +-- Catching.attempt against the merged registry, hooked as catch.rate +function BattleState:catchAttempt(ball, rateOverride) + if Runtime.wantsHook("catch.rate") then + local battle = self + return Runtime.call("catch.rate", function(b, mon, def, o) + return Catching.attempt(b, mon, def, o.rng, o.rateOverride, + { ballDef = battle:ballDef(b), statuses = battle.data.statuses, + battle = battle }) + end, ball, self.enemy.mon, self.enemy.def, + { rng = self.rng, rateOverride = rateOverride, battle = self }) + end + return Catching.attempt(ball, self.enemy.mon, self.enemy.def, self.rng, + rateOverride, { ballDef = self:ballDef(ball), + statuses = self.data.statuses, battle = self }) +end + -- wAICount: item/switch uses per enemy Pokémon for this trainer class function BattleState:aiUsesFor() if self.kind ~= "trainer" or not self.trainer then return 0 end - local class = require("data.scripts.ai_classes")[self.trainer.id] + local class = TrainerAI.classFor(self) return class and class.uses or 0 end @@ -1201,9 +1375,27 @@ function BattleState:markParticipant() end end +-- the whole choke point is hooked (battle.enemy_action), so a mod can +-- rewrite any trainer's choice without registering brains function BattleState:enemyAction() + if Runtime.wantsHook("battle.enemy_action") then + return Runtime.call("battle.enemy_action", function(battle) + return battle:vanillaEnemyAction() + end, self) + end + return self:vanillaEnemyAction() +end + +function BattleState:vanillaEnemyAction() local locked = self:lockedAction(self.enemy) if locked then return locked end + -- an ai_classes brain (or one on the trainer record) supersedes the + -- class action and move scoring entirely + if self.kind == "trainer" and self.trainer then + local class = TrainerAI.classFor(self) + local brain = self.trainer.brain or (class and class.brain) + if brain then return brain(self) end + end -- class AI may spend the turn on an item or a switch local classAct = TrainerAI.classAction(self) if classAct then return classAct end @@ -1217,9 +1409,21 @@ end function BattleState:resolveTurn(playerAction) local enemyAction = self:enemyAction() - local pFirst = TurnOrder.firstMover(self.player, orderMove(playerAction, self.data), - self.enemy, orderMove(enemyAction, self.data), - self.rng) + self.turnCount = (self.turnCount or 0) + 1 + Runtime.emit("battle.turn_started", { + battle = self, turn = self.turnCount, + playerAction = playerAction, enemyAction = enemyAction, + }) + local pMove = orderMove(playerAction, self.data) + local eMove = orderMove(enemyAction, self.data) + local pFirst + if Runtime.wantsHook("battle.turn_order") then + pFirst = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c) + return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie) + end, self.player, pMove, self.enemy, eMove, { rng = self.rng }) + else + pFirst = TurnOrder.firstMover(self.player, pMove, self.enemy, eMove, self.rng) + end local order if pFirst then order = { { self.player, self.enemy, playerAction }, @@ -1231,7 +1435,6 @@ function BattleState:resolveTurn(playerAction) self.phase = "messages" self.afterQueue = "menu" - self.turnCount = (self.turnCount or 0) + 1 for _, entry in ipairs(order) do self:act(function() @@ -1247,7 +1450,13 @@ function BattleState:resolveSwitch(newMon) self.afterQueue = "menu" self:act(function() self:restoreMimicked(self.player) -- the battle copy leaves with it + local previous = self.player self.player = makeBattler(self.data, newMon, true, self.game.save) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[1], battler = self.player, + previous = previous, + }) self:markParticipant() self.sendingOut = true self:sayNext(self:sendOutText(self.player.name)) @@ -1279,7 +1488,7 @@ function BattleState:endOfTurn() { self.enemy, self.player, "enemy" } }) do local b, opp, side = pair[1], pair[2], pair[3] if b.mon.hp > 0 then - local msgs = Status.residual(b, opp) + local msgs = Status.residual(b, opp, self) for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved if b.toxicCounter then @@ -1295,6 +1504,30 @@ function BattleState:endOfTurn() b.trappingTurns = nil end end + self:tickTokens() + Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 }) +end + +-- side/field tokens ({ id, turns?, onResidual?, onExpire? }) tick after +-- the residual sweep; with the tables empty this is a nil check per list +local function tickTokenList(battle, tokens, holder) + if tokens[1] == nil then return end + for i = #tokens, 1, -1 do + local token = tokens[i] + if token.turns then token.turns = token.turns - 1 end + if token.onResidual then token.onResidual(battle, holder) end + if token.turns and token.turns <= 0 then + if token.onExpire then token.onExpire(battle, holder) end + table.remove(tokens, i) + end + end +end + +function BattleState:tickTokens() + for _, side in ipairs(self.sides) do + tickTokenList(self, side.tokens, side) + end + tickTokenList(self, self.field.tokens, self.field) end -- --------------------------------------------------------------------- @@ -1770,9 +2003,15 @@ function BattleState:executeAction(user, target, action) end if action.special == "aiSwitch" then self.aiUses = (self.aiUses or 1) - 1 + local previous = self.enemy local oldName = self.enemy.name self.enemyIndex = action.index self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[2], battler = self.enemy, + previous = previous, + }) self.aiUses = self:aiUsesFor() markSeen(self.game, self.enemy.mon.species) -- _AIBattleWithdrawText: "X with-/drew Y!" @@ -1862,7 +2101,7 @@ end -- Runs Status.beforeMove plus the shared interruption bookkeeping; -- returns true when the user's action is interrupted. function BattleState:statusInterrupt(user, target) - local canMove, msgs, selfHit = Status.beforeMove(user, self.rng) + local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end if selfHit then -- confusion self-hit (core.asm:3428-3434): clears everything in @@ -1870,10 +2109,10 @@ function BattleState:statusInterrupt(user, target) -- 40-power typeless hit against the mon's own defense -- with the -- OPPONENT's Reflect still applying (the screen check keeps -- reading the opponent's battle status) - local dmg = Damage.compute(self.ruleset, user, user, - { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 }, - { rng = self.rng, forceCrit = false, typeless = true, - screens = target }) + local dmg = self:computeDamage(user, user, + { id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 }, + { rng = self.rng, forceCrit = false, typeless = true, + screens = target }) self:sayNext("It hurt itself in\nits confusion!") self:clearVolatiles(user, true) self:applyDamage(user, dmg) @@ -1909,12 +2148,16 @@ function BattleState:clearVolatiles(user, selfHit) end -- performMove runs a move (possibly via Metronome/Mirror Move recursion). +-- Decomposed into a staged pipeline over the merged move_effects record: +-- announcement -> callsMove -> charge -> perform -> primary run -> the +-- damaging pipeline (EffectRegistry.runDamaging). function BattleState:performMove(user, target, moveInst, isCalled) local move = self:moveDef(moveInst) if not move then Logger.warn("unknown move instance %s", tostring(moveInst.id)) return end + local record = self:effectRecord(move.effect) -- charge release? local releasing = user.charging == moveInst and user.chargeReady @@ -1930,387 +2173,90 @@ function BattleState:performMove(user, target, moveInst, isCalled) moveInst.pp = math.max(0, moveInst.pp - 1) end - local effect = move.effect - self.moveAnimRow = nil if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then self:sayNext(("%s\nused %s!"):format(displayName(user), move.name)) -- the move's animation plays right after the announcement; the -- damage path attaches the target's hit blink to this row so the -- blink follows the animation (pokered's order). Mimic is the - -- exception: PlayCurrentMoveAnimation runs only after a successful - -- copy (effects.asm:1268), never on a miss -- applyMimic queues it - if effect ~= "MIMIC_EFFECT" then + -- exception (announceAnim = false): PlayCurrentMoveAnimation runs + -- only after a successful copy, never on a miss -- applyMimic queues it + if not (record and record.announceAnim == false) then self.nextInsert = (self.nextInsert or 0) + 1 self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer } table.insert(self.queue, self.nextInsert, self.moveAnimRow) end end + Runtime.emit("battle.move_used", { + battle = self, user = user, target = target, move = move, + isCalled = isCalled or false, + }) - -- Metronome / Mirror Move - if effect == "METRONOME_EFFECT" then - local order = self.data.constants.moveOrder - local pick - repeat - pick = order[self.rng(1, #order)] - until pick ~= "METRONOME" and pick ~= "STRUGGLE" and self.data.moves[pick] - self:performMove(user, target, { id = pick, pp = 1 }, true) - return - end - if effect == "MIRROR_MOVE_EFFECT" then - local last = target.lastMove - if not last then - self:sayNext("The MIRROR MOVE\nfailed!") - return + local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled) + + -- Metronome / Mirror Move re-entry; a nil pick means the record + -- already said its failure text + if record and record.callsMove then + local pick = record.callsMove(ctx) + if pick then + self:performMove(user, target, { id = pick, pp = 1 }, true) end - self:performMove(user, target, { id = last, pp = 1 }, true) return end - user.lastMove = move.id - -- charge moves: first turn just charges; Fly AND Dig go - -- semi-invulnerable (ChargeEffect sets INVULNERABLE for both) - if (effect == "CHARGE_EFFECT" or effect == "FLY_EFFECT") and not releasing then + -- charge moves: first turn just charges; the text comes from the move + -- record (chargeText) and the invulnerability from semiInvulnerable, + -- falling back to the id tables (Fly AND Dig go semi-invulnerable: + -- ChargeEffect sets INVULNERABLE for both) + if record and record.charge and not releasing then user.charging = moveInst user.chargeReady = true - local chargeText = ({ - FLY = "%s\nflew up high!", - DIG = "%s\ndug a hole!", - RAZOR_WIND = "%s\nmade a whirlwind!", - SOLARBEAM = "%s\ntook in sunlight!", - SKULL_BASH = "%s\nlowered its head!", - SKY_ATTACK = "%s\nis glowing!", - })[move.id] or "%s\nis charging up!" - if effect == "FLY_EFFECT" or move.id == "DIG" then + local invulnerable = move.semiInvulnerable + if invulnerable == nil then + invulnerable = record.charge.invulnerable or move.id == "DIG" + end + if invulnerable then user.invulnerable = true end + local chargeText = move.chargeText or CHARGE_TEXT[move.id] + or "%s\nis charging up!" self:sayNext(chargeText:format(displayName(user))) return end - if effect == "SWITCH_AND_TELEPORT_EFFECT" then - -- SwitchAndTeleportEffect (effects.asm:810-909): in a wild battle - -- it auto-succeeds when the user's level >= the opponent's; - -- otherwise roll rand[0, userLevel+enemyLevel] and FAIL when the - -- roll is below opponentLevel/4. Teleport's failure text is "But - -- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer - -- battles Teleport fails and Roar/Whirlwind are "unaffected". - if self.kind == "wild" then - local uLvl, tLvl = user.mon.level, target.mon.level - local ok = uLvl >= tLvl - if not ok then - ok = self.rng(0, uLvl + tLvl) >= math.floor(tLvl / 4) - end - if ok then - if move.id == "ROAR" then - self:sayNext(("%s\nran away scared!"):format(displayName(target))) - elseif move.id == "WHIRLWIND" then - self:sayNext(("%s\nwas blown away!"):format(displayName(target))) - else - self:sayNext(("%s\nran from battle!"):format(displayName(user))) - end - self.result = "run" - self.afterQueue = "finish" - elseif move.id == "TELEPORT" then - self:sayNext("But, it failed!") - else - self:sayNext(("It didn't affect\n%s!"):format(displayName(target))) - end - elseif move.id == "TELEPORT" then - self:sayNext("But, it failed!") - else - self:sayNext(("%s\nis unaffected!"):format(displayName(target))) - end - return - end - - if effect == "BIDE_EFFECT" then - user.bideTurns = self.rng(2, 3) - user.bideDamage = 0 - self:sayNext(("%s\nis storing energy!"):format(displayName(user))) - return - end - - -- Mimic runs its own mid-move flow: hit test, then the copy menu - -- (player) or a random roll (enemy / link), all on the queue - if effect == "MIMIC_EFFECT" then - self:resolveMimic(user, target, move, moveInst) + -- fully custom resolution (Bide, Roar/Teleport, Mimic) + if record and record.perform then + record.perform(ctx) return end -- pure status moves - local primary = MoveEffects.primary[effect] - if move.power == 0 and primary then + if move.power == 0 and record and record.kind == "primary" and record.run then -- accuracy-checked status effects run MoveHitTest, which has no -- 100%-accuracy early-out (even Thunder Wave misses on the 255 -- roll) and misses outright against a mid-Fly/Dig target; the -- never-miss paths (X ACCURACY) live inside Damage.accuracyRoll - if ACC_CHECKED_STATUS[effect] + if record.accuracyChecked and (target.invulnerable - or not Damage.accuracyRoll(self.ruleset, move, user, target, self.rng)) then + or not self:accuracyRoll(move, user, target)) then self:sayNext(("%s's\nattack missed!"):format(displayName(user))) return end - for _, m in ipairs(primary(self, user, target, move, moveInst)) do + for _, m in ipairs(record.run(ctx)) do self:sayNext(m) end self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar return end - if move.power == 0 and not MoveEffects.special[effect] then - MoveEffects.warnUnknown(effect) + if move.power == 0 and not (record and record.kind == "full") then + MoveEffects.warnUnknown(move.effect) self:sayNext("But, it failed!") return end - -- damaging move --------------------------------------------------------- - - -- Swift ignores semi-invulnerability (MoveHitTest returns hit for - -- SWIFT_EFFECT before the INVULNERABLE check) - if target.invulnerable and effect ~= "SWIFT_EFFECT" then - self:sayNext(("%s's\nattack missed!"):format(displayName(user))) - return - end - - if effect == "OHKO_EFFECT" then - -- fails against faster opponents (Gen 1 rule) and immune types - if TypeChart.effectiveness(move.type, target.curTypes) == 0 then - self:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) - return - end - if TurnOrder.effectiveSpeed(user) < TurnOrder.effectiveSpeed(target) then - self:sayNext("But, it failed!") - return - end - end - - -- Dream Eater only works on sleeping targets (checked before damage) - if effect == "DREAM_EATER_EFFECT" and target.mon.status ~= "SLP" then - self:sayNext("But, it failed!") - return - end - - local hits = 1 - if effect == "TWO_TO_FIVE_ATTACKS_EFFECT" then - local r = self.rng(0, 7) - hits = ({ 2, 2, 2, 3, 3, 3, 4, 5 })[r + 1] - elseif effect == "ATTACK_TWICE_EFFECT" or effect == "TWINEEDLE_EFFECT" then - hits = 2 - end - - -- TrappingEffect runs BEFORE the hit test and clears the target's - -- Hyper Beam recharge, even if the trapping move then misses - -- (effects.asm:1091-1092 ClearHyperBeam) - if effect == "TRAPPING_EFFECT" and not user.trappingTurns then - target.mustRecharge = nil - end - - -- accuracy (Swift never misses) - if effect ~= "SWIFT_EFFECT" then - if not Damage.accuracyRoll(self.ruleset, move, user, target, self.rng) then - if effect == "JUMP_KICK_EFFECT" then - self:sayNext(("%s's\nattack missed!"):format(displayName(user))) - self:sayNext(("%s\nkept going and\ncrashed!"):format(displayName(user))) - self:applyDamage(user, 1) - if user.mon.hp <= 0 then self:onFaint(user) end - elseif effect == "EXPLODE_EFFECT" then - self:sayNext(("%s's\nattack missed!"):format(displayName(user))) - self:selfDestruct(user) - else - self:sayNext(("%s's\nattack missed!"):format(displayName(user))) - end - user.trappingTurns = nil - return - end - end - - -- damage per hit - local dmg, info - if move.id == "COUNTER" then - -- HandleCounterMove: 2x the last damage dealt in battle, only if - -- the opponent's last move was Normal/Fighting with >0 power (and - -- not Counter itself); wDamage is shared, so any last damage counts - local lastId = target.lastMove - local lm = lastId and lastId ~= "COUNTER" and self.data.moves[lastId] - local counterable = lm and (lm.power or 0) > 0 - and (lm.type == "NORMAL" or lm.type == "FIGHTING") - if not counterable or (self.lastDamage or 0) == 0 then - self:sayNext(("%s's\nattack missed!"):format(displayName(user))) - return - end - dmg = math.min(65535, self.lastDamage * 2) - info = { crit = false, typeMult = 10 } - elseif effect == "SPECIAL_DAMAGE_EFFECT" or effect == "SUPER_FANG_EFFECT" then - -- fixed damage still respects type immunity (AdjustDamageForMoveType - -- flags the miss before the special-damage override) - if TypeChart.effectiveness(move.type, target.curTypes) == 0 then - self:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) - return - end - if effect == "SUPER_FANG_EFFECT" then - dmg = math.max(1, math.floor(target.mon.hp / 2)) - else - dmg = self:specialDamage(user, target, move) - if not dmg then - self:sayNext("But, it failed!") - return - end - end - info = { crit = false, typeMult = 10 } - elseif effect == "OHKO_EFFECT" then - dmg = 65535 - info = { crit = false, typeMult = 10 } - else - dmg, info = Damage.compute(self.ruleset, user, target, move, - { rng = self.rng, explode = effect == "EXPLODE_EFFECT" }) - end - - if info.typeMult == 0 then - self:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) - if effect == "EXPLODE_EFFECT" then self:selfDestruct(user) end - return - end - if info.missed then - -- 0.25x floored the damage to zero: the original registers a miss - self:sayNext(("%s's\nattack missed!"):format(displayName(user))) - if effect == "EXPLODE_EFFECT" then self:selfDestruct(user) end - return - end - self.lastDamage = dmg -- wDamage (shared by both sides, read by Counter) - - -- the hit blink + damage sound ride the queue behind the animation: - -- on the move's anim row when one was announced, else on a bare hit - -- row (thrash/rage continuations), placed BEFORE the drain rows the - -- hits loop inserts so the blink precedes the bar drain - local hitRow = self.moveAnimRow - if not hitRow then - self.nextInsert = (self.nextInsert or 0) + 1 - hitRow = { hitRow = true } - table.insert(self.queue, self.nextInsert, hitRow) - end - - local totalDealt = 0 - local hitCount, brokeSub = 0, false - for h = 1, hits do - if target.mon.hp <= 0 then break end - local hadSub = target.substituteHP ~= nil - totalDealt = totalDealt + self:applyDamage(target, dmg) - hitCount = h - if hadSub and not target.substituteHP then - -- AttackSubstitute: breaking the substitute ends a multi-hit move - brokeSub = true - break - end - end - hits = hitCount > 0 and hitCount or hits - if totalDealt > 0 then - -- the original's per-hit sound: normal / super / not-very-effective - local hitSfx = info.typeMult > 10 and "Super_Effective" - or info.typeMult < 10 and "Not_Very_Effective" or "Damage" - hitRow.hit = { sfx = hitSfx, - blink = self:animationsOn() and target or nil } - end - -- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right - -- after the damage lands, BEFORE DisplayEffectiveness (core.asm - -- .moveDidNotMiss); the multi-hit count follows the last hit - if info.crit then self:sayNext("Critical hit!") end - if effect == "OHKO_EFFECT" then - self:sayNext("One-hit KO!") - end - if info.typeMult > 10 then - self:sayNext("It's super\neffective!") - elseif info.typeMult < 10 then - self:sayNext("It's not very\neffective...") - end - if hits > 1 then - -- player: _MultiHitText; enemy: _HitXTimesText (always plural) - if user.isPlayer then - self:sayNext(("Hit the enemy\n%d times!"):format(hits)) - else - self:sayNext(("Hit %d times!"):format(hits)) - end - end - - -- post-damage effect bookkeeping - if effect == "RECOIL_EFFECT" or moveInst.struggle then - -- recoil.asm reads the RAW computed wDamage (not the HP actually - -- removed): overkill and substitute hits recoil at full strength - local recoil = math.max(1, math.floor(dmg / (moveInst.struggle and 2 or 4))) - self:sayNext(("%s's\nhit with recoil!"):format(displayName(user))) - self:applyDamage(user, recoil) - elseif effect == "DRAIN_HP_EFFECT" or effect == "DREAM_EATER_EFFECT" then - -- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and - -- heals that amount, so Counter would see the halved value - local heal = math.max(1, math.floor(dmg / 2)) - self.lastDamage = heal - user.mon.hp = math.min(user.mon.stats.hp, user.mon.hp + heal) - self:drainNext() - if effect == "DREAM_EATER_EFFECT" then - self:sayNext(("%s's\ndream was eaten!"):format(displayName(target))) - else - self:sayNext(("Sucked health from\n%s!"):format(displayName(target))) - end - elseif effect == "EXPLODE_EFFECT" then - self:selfDestruct(user) - elseif effect == "HYPER_BEAM_EFFECT" then - -- no recharge when the target faints OR its substitute breaks - if target.mon.hp > 0 and not brokeSub then - user.mustRecharge = true - end - elseif effect == "PAY_DAY_EFFECT" then - self.payDay = (self.payDay or 0) + 2 * user.mon.level - self:sayNext("Coins scattered\neverywhere!") - elseif effect == "TRAPPING_EFFECT" then - if not user.trappingTurns then - -- TrappingEffect (effects.asm:1080-1103) rolls wNumAttacksLeft - -- as 1-4 (weights 3/8 3/8 1/8 1/8): that many CONTINUATION - -- attacks follow this first hit, 2-5 attacks total. The victim - -- is held while the counter runs (live mirror in lockedAction). - local r = self.rng(0, 7) - user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1] - user.trapDamage = dmg - -- remember the move so its animation can replay on each locked - -- continuation (core.asm:3554-3566 -> GetPlayerAnimationType) - user.trapMove = move.id - end - elseif effect == "THRASH_PETAL_DANCE_EFFECT" then - if not user.thrashTurns then - user.thrashTurns = self.rng(2, 3) -- 3-4 attacks total, then confusion - user.thrashMove = moveInst - user.thrashAnnounced = true - else - user.thrashTurns = user.thrashTurns - 1 - if user.thrashTurns <= 0 then - user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil - if not user.confusedTurns then - user.confusedTurns = self.rng(2, 5) - self:sayNext(("%s\nbecame confused!"):format(displayName(user))) - end - end - end - elseif effect == "RAGE_EFFECT" then - user.rageMove = moveInst - end - - -- secondary side effects (blocked by fainting) - local secondary = MoveEffects.secondary[effect] - if secondary and target.mon.hp > 0 and totalDealt > 0 then - for _, m in ipairs(secondary(self, user, target, move)) do - self:sayNext(m) - end - end - if not MoveEffects.special[effect] and not MoveEffects.secondary[effect] - and not MoveEffects.primary[effect] and effect ~= "NO_ADDITIONAL_EFFECT" then - MoveEffects.warnUnknown(effect) - end - - if target.mon.hp <= 0 then - self:onFaint(target) - end - if user.mon.hp <= 0 then - self:onFaint(user) - end + -- damaging pipeline, driven by the record's stage callbacks + EffectRegistry.runDamaging(self, ctx, record) end function BattleState:continueTrapping(user, target) @@ -2382,19 +2328,6 @@ function BattleState:applyDamage(target, dmg) return dealt end --- fixed-damage moves (engine/battle/core.asm SpecialDamage) -function BattleState:specialDamage(user, target, move) - local id = move.id - if id == "SONICBOOM" then return 20 end - if id == "DRAGON_RAGE" then return 40 end - if id == "SEISMIC_TOSS" or id == "NIGHT_SHADE" then return user.mon.level end - if id == "PSYWAVE" then - local max = math.max(1, math.floor(user.mon.level * 3 / 2) - 1) - return self.rng(1, max) - end - return nil -end - -- --------------------------------------------------------------------- -- fainting / exp / party -- --------------------------------------------------------------------- @@ -2402,6 +2335,7 @@ end function BattleState:onFaint(battler) if battler.faintQueued then return end battler.faintQueued = true + Runtime.emit("battle.fainted", { battle = self, battler = battler }) -- the faint slide + cry ride the queue (after the move animation and -- the HP-bar drain, pokered's order); the slide finishes before the -- faint text via a queued hold @@ -2454,6 +2388,9 @@ function BattleState:enemyMonFainted() local levels, gained = Experience.apply(self.data, mon, self.enemy.def, self.enemy.mon.level, self.kind == "trainer", split, mon.traded) + Runtime.emit("battle.exp_gained", { + battle = self, mon = mon, gained = gained, levels = levels, + }) local name = mon.nickname or self.data.pokemon[mon.species].name if announce then -- GainedText (experience.asm:342-354): "X gained" plus one of @@ -2526,12 +2463,17 @@ function BattleState:enemyMonFainted() local ChoiceBox = require("src.ui.ChoiceBox") return ChoiceBox.new(game, function(yes) if not yes then return end - local PartyMenu = require("src.ui.PartyMenu") - game.stack:push(PartyMenu.new(game, { + Screens.push(game, "PartyMenu", { battle = self, onSwitch = function(mon) if mon ~= self.player.mon and mon.hp > 0 then + local previous = self.player self.player = makeBattler(self.data, mon, true, game.save) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[1], + battler = self.player, previous = previous, + }) self:markParticipant() self.nextInsert = 0 self.sendingOut = true @@ -2545,12 +2487,18 @@ function BattleState:enemyMonFainted() end) end end, - })) + }) end) end) end self:act(function() + local previous = self.enemy self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[2], battler = self.enemy, + previous = previous, + }) self.aiUses = self:aiUsesFor() markSeen(self.game, self.enemy.mon.species) self:markParticipant() @@ -2597,6 +2545,7 @@ function BattleState:learnMove(mon, moveId) end if #mon.moves < 4 then table.insert(mon.moves, { id = moveId, pp = mdef.pp }) + Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) self:sayNext(("%s learned\n%s!"):format(mon.nickname or self.data.pokemon[mon.species].name, mdef.name)) return @@ -2604,10 +2553,8 @@ function BattleState:learnMove(mon, moveId) -- the "trying to learn" preamble lives inside MoveLearnMenu:enter; -- ordered insert so multi-level gains keep each level's checks -- between its own stat box and the next "grew to level" text - local game = self.game self:uiNext(function() - local MoveLearnMenu = require("src.ui.MoveLearnMenu") - return MoveLearnMenu.new(game, mon, moveId) + return self:buildScreen("MoveLearnMenu", mon, moveId) end) end @@ -2653,8 +2600,7 @@ function BattleState:openReplacementMenu() self.phase = "messages" self.afterQueue = "menu" self:ui(function() - local PartyMenu = require("src.ui.PartyMenu") - return PartyMenu.new(game, { + return self:buildScreen("PartyMenu", { battle = self, onSwitch = function(mon) if mon.hp <= 0 then @@ -2662,7 +2608,13 @@ function BattleState:openReplacementMenu() return -- the menu-phase guard reopens the menu end self:restoreMimicked(self.player) + local previous = self.player self.player = makeBattler(self.data, mon, true, game.save) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[1], battler = self.player, + previous = previous, + }) self:markParticipant() self.nextInsert = 0 self.sendingOut = true @@ -2705,13 +2657,15 @@ function BattleState:safariAction(choice) self:say(("%s used\nSAFARI BALL!"):format(playerName)) self:act(function() require("src.core.Sound").play(self.data, "Ball_Toss") - local caught, shakes = Catching.attempt("SAFARI_BALL", self.enemy.mon, - self.enemy.def, self.rng, - self.safariCatchRate) + self.lastBall = "SAFARI_BALL" + local caught, shakes = self:catchAttempt("SAFARI_BALL", self.safariCatchRate) + Runtime.emit("battle.ball_thrown", { + battle = self, ball = "SAFARI_BALL", caught = caught, shakes = shakes, + }) -- SAFARI_BALL is neither POKE nor GREAT, so TossBallAnimation -- lands on the ULTRATOSS arc (no flicker: SAFARI_BALL is $08, -- above DoBallTossSpecialEffects's <= ULTRA_BALL check) - self:ballChain("ULTRATOSS_ANIM", caught, shakes, "SAFARI_BALL") + self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL") if caught then -- ItemUseBallText05's sound_caught_mon: fanfare with the text self:actNext(function() @@ -2785,9 +2739,20 @@ end -- Gen 1 escape formula (engine/battle/core.asm TryRunningFromBattle), -- shared by the RUN menu choice and the faint dialogue's NO branch; --- counts a run attempt each call. +-- counts a run attempt each call. Hooked as battle.run. function BattleState:runRoll(pSpd, eSpd) self.runAttempts = (self.runAttempts or 0) + 1 + if Runtime.wantsHook("battle.run") then + local battle = self + return Runtime.call("battle.run", function(c) + return battle:runRollVanilla(c.pSpd, c.eSpd) + end, { battle = self, pSpd = pSpd, eSpd = eSpd, + attempts = self.runAttempts, rng = self.rng }) + end + return self:runRollVanilla(pSpd, eSpd) +end + +function BattleState:runRollVanilla(pSpd, eSpd) if self.ghost then return true -- IsGhostBattle -> always escapes end @@ -2830,12 +2795,10 @@ function BattleState:tryRun() end function BattleState:openItems() - local BagMenu = require("src.ui.BagMenu") - local game = self.game self.phase = "messages" self.afterQueue = "menu" self:ui(function() - return BagMenu.new(game, { battle = self }) + return self:buildScreen("BagMenu", { battle = self }) end) end @@ -2875,14 +2838,14 @@ function BattleState:storeCaughtMon() local dex = game.save.pokedex local species = self.enemy.mon.species local isNew = dex ~= nil and not dex.owned[species] + local destination = "party" markOwned(game, species) stampOT(game.save, self.enemy.mon) if isNew then -- _ItemUseBallText06 + ShowPokedexData self:sayNext(("New POKéDEX data\nwill be added for\n%s!"):format(self.enemy.name)) self:uiNext(function() - local DexEntryMenu = require("src.ui.DexEntryMenu") - return DexEntryMenu.new(game, species) + return self:buildScreen("DexEntryMenu", species) end) end if Party.add(game.save.party, self.enemy.mon) then @@ -2897,18 +2860,17 @@ function BattleState:storeCaughtMon() :format(enemyName), function() game.stack:push(ChoiceBox.new(game, function(yes) if not yes then return end - local ok, NamingScreen = pcall(require, "src.ui.NamingScreen") - if not ok then return end - game.stack:push(NamingScreen.new(game, { + pcall(Screens.push, game, "NamingScreen", { title = "NICKNAME?", maxLen = 10, onDone = function(name) if name and #name > 0 then caught.nickname = name end end, - })) + }) end)) end) end) else + destination = "box" local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) if boxNum then -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL @@ -2919,6 +2881,10 @@ function BattleState:storeCaughtMon() self:sayNext("But every BOX\nis full!") end end + Runtime.emit("pokemon.caught", { + battle = self, mon = self.enemy.mon, species = species, isNew = isNew, + ball = self.lastBall, destination = destination, game = game, + }) self.result = "caught" self.afterQueue = "finish" end @@ -2947,14 +2913,23 @@ function BattleState:ballChain(tossAnim, caught, shakes, ball) end) end --- TossBallAnimation picks the toss arc from wCurItem: POKE->TOSS, +-- TossBallAnimation picks the toss arc from the ball record's tossAnim; +-- an unknown ball keeps the wCurItem mapping: POKE->TOSS, -- GREAT->GREATTOSS, everything else (ULTRA/MASTER/SAFARI...)->ULTRATOSS -local function tossAnimFor(ball) +function BattleState:tossAnimFor(ball) + local def = self:ballDef(ball) + if def and def.tossAnim then return def.tossAnim end return ball == "POKE_BALL" and "TOSS_ANIM" or ball == "GREAT_BALL" and "GREATTOSS_ANIM" or "ULTRATOSS_ANIM" end +-- the Master/Ultra OBJ-palette flicker, from the ball record +function BattleState:ballFlicker(ball) + local def = self:ballDef(ball) + return (def and def.flicker) or false +end + -- called by BagMenu when a ball is thrown function BattleState:throwBall(ball) self:say(("%s used\n%s!"):format(self.game.save.player.name, @@ -2972,7 +2947,7 @@ function BattleState:throwBall(ball) -- wCurItem, so a Master/Ultra toss keeps its flicker), dodged -- ($10 anim data, no wobbles), and the turn is spent like any -- failed throw - self:animNext(tossAnimFor(ball), true, nil, ball) + self:animNext(self:tossAnimFor(ball), true, nil, ball) self:sayNext("It dodged the\nthrown BALL!") self:sayNext("This POKéMON\ncan't be caught!") self:act(function() @@ -2981,13 +2956,15 @@ function BattleState:throwBall(ball) self:act(function() self:endOfTurn() end) return end - local caught, shakes = Catching.attempt(ball, self.enemy.mon, - self.enemy.def, self.rng) + self.lastBall = ball + local caught, shakes = self:catchAttempt(ball) + Runtime.emit("battle.ball_thrown", { + battle = self, ball = ball, caught = caught, shakes = shakes, + }) -- ItemUseBall's 20-frame beat, then the toss chain for the outcome - -- (TossBallAnimation maps POKE->TOSS, GREAT->GREATTOSS, else ULTRATOSS) self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, { wait = 20 }) - self:ballChain(tossAnimFor(ball), caught, shakes, ball) + self:ballChain(self:tossAnimFor(ball), caught, shakes, ball) if caught then -- ItemUseBallText05 carries sound_caught_mon (item_effects.asm: -- 608-614): the fanfare sounds with the caught message, before @@ -3008,12 +2985,10 @@ function BattleState:throwBall(ball) end function BattleState:openParty() - local PartyMenu = require("src.ui.PartyMenu") - local game = self.game self.phase = "messages" self.afterQueue = "menu" self:ui(function() - return PartyMenu.new(game, { + return self:buildScreen("PartyMenu", { battle = self, onSwitch = function(mon) if mon == self.player.mon then @@ -3065,6 +3040,7 @@ function BattleState:finish() -- (home/overworld.asm:2343-2348) require("src.core.Music").restoreMap(self.data) self.game.stack:pop() + Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) if self.onFinish then self.onFinish(self.result or "run") end end @@ -3619,7 +3595,7 @@ function BattleState:drawHUDs(slide) love.graphics.setColor(0, 0, 0, 1) Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) if self.enemy.mon.status then - Font.draw(self.enemy.mon.status, 40, 8) + Font.draw(self:statusLabel(self.enemy.mon), 40, 8) else hudTile(0x6E, 32, 8) -- Font.draw(tostring(self.enemy.mon.level), 40, 8) @@ -3661,7 +3637,7 @@ function BattleState:drawHUDs(slide) love.graphics.setColor(0, 0, 0, 1) Font.draw(self.player.name, nameX(10, self.player.name), 56) if self.player.mon.status then - Font.draw(self.player.mon.status, 120, 64) + Font.draw(self:statusLabel(self.player.mon), 120, 64) else hudTile(0x6E, 112, 64) -- Font.draw(tostring(self.player.mon.level), 120, 64) @@ -3741,7 +3717,9 @@ function BattleState:drawTextArea() else local def = self.data.moves[sel.id] Font.draw("TYPE/", 8, 72) - Font.draw(def.type or "", 16, 80) + -- the type record's display name (a mod type shows its name, and + -- PSYCHIC_TYPE prints PSYCHIC like the original) + Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80) local maxPP = def.pp + (sel.ppUps or 0) * math.floor(def.pp / 5) Font.draw(("%2d/%2d"):format(sel.pp, maxPP), 40, 88) end diff --git a/src/battle/Catching.lua b/src/battle/Catching.lua index 1948d049..d7e6d94f 100644 --- a/src/battle/Catching.lua +++ b/src/battle/Catching.lua @@ -1,45 +1,64 @@ -- Gen 1 catch algorithm (engine/items/item_effects.asm, ItemUseBall). +local Status = require("src.battle.Status") + local Catching = {} -local BALL_RAND_MAX = { MASTER_BALL = 0, POKE_BALL = 255, GREAT_BALL = 200, - ULTRA_BALL = 150, SAFARI_BALL = 150 } -local BALL_HP_FACTOR = { POKE_BALL = 12, GREAT_BALL = 8, ULTRA_BALL = 12, - SAFARI_BALL = 12 } +-- randMax is the ceiling of the catch roll, hpFactor the X of the HP term, +-- wobbleFactor the ballFactor2 divisor of the wobble math. MASTER_BALL +-- never rolls (autoCatch), so its factors are unused. tossAnim picks the +-- TossBallAnimation arc and flicker the Master/Ultra OBJ-palette strobe +-- (DoBallTossSpecialEffects). +local BALLS = { + MASTER_BALL = { randMax = 0, autoCatch = true, + tossAnim = "ULTRATOSS_ANIM", flicker = true }, + POKE_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 255, + tossAnim = "TOSS_ANIM" }, + GREAT_BALL = { randMax = 200, hpFactor = 8, wobbleFactor = 200, + tossAnim = "GREATTOSS_ANIM" }, + ULTRA_BALL = { randMax = 150, hpFactor = 12, wobbleFactor = 150, + tossAnim = "ULTRATOSS_ANIM", flicker = true }, + SAFARI_BALL = { randMax = 150, hpFactor = 12, wobbleFactor = 150, + tossAnim = "ULTRATOSS_ANIM" }, +} +Catching.BALLS = BALLS --- Returns caught, shakes (0-3). rateOverride replaces the species catch --- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate). --- --- On failure the ball wobbles per the original's shake calculation: --- Y = rate*100/ballFactor2 (255/200/150), Z = X*Y/255 + status2 (5/10) --- where X is the HP factor; Z<10: 0 shakes, <30: 1, <70: 2, else 3. --- (We use the HP factor for X on both failure paths; the original reads --- a stale quotient when the first roll fails.) -function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride) - rng = rng or love.math.random - if ball == "MASTER_BALL" then return true, 3 end - local randMax = BALL_RAND_MAX[ball] or 255 +-- an unknown ball falls back to POKE_BALL's roll and the 150 wobble +-- divisor, which is what the old per-field `or` defaults resolved to +local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 } + +function Catching.registerInto(registry, _, owner) + for id, record in pairs(BALLS) do + registry:register(id, record, owner) + end +end + +-- The stock ItemUseBall math. On failure the ball wobbles per the +-- original's shake calculation: Y = rate*100/ballFactor2 (255/200/150), +-- Z = X*Y/255 + status2 (5/10) where X is the HP factor; Z<10: 0 shakes, +-- <30: 1, <70: 2, else 3. (We use the HP factor for X on both failure +-- paths; the original reads a stale quotient when the first roll fails.) +local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses) + if def.autoCatch then return true, 3 end + local randMax = def.randMax local rate = rateOverride or targetDef.catchRate - local statusBonus = 0 + -- the status subtraction and the wobble bonus come off the merged + -- status record (SLP/FRZ 25 and +10, the rest 12 and +5) local s = targetMon.status - if s == "SLP" or s == "FRZ" then - statusBonus = 25 - elseif s == "PSN" or s == "BRN" or s == "PAR" then - statusBonus = 12 - end + local record = Status.recordFor(statuses, s) + local statusBonus = record and record.catchBonus or 0 -- HP factor (X) local maxhp = targetMon.stats.hp local hpQuarter = math.max(1, math.floor(targetMon.hp / 4)) - local factor = BALL_HP_FACTOR[ball] or 12 + local factor = def.hpFactor or DEFAULT_BALL.hpFactor -- the 255 cap applies only after BOTH divisions (ItemUseBall keeps -- the intermediate in 16 bits); capping early collapses the value local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter)) local function shakes() - local ballFactor2 = ball == "POKE_BALL" and 255 - or ball == "GREAT_BALL" and 200 or 150 + local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor local y = math.floor(rate * 100 / ballFactor2) local z if y > 255 then @@ -47,10 +66,8 @@ function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride) else z = math.floor(f * y / 255) end - if s == "SLP" or s == "FRZ" then - z = z + 10 - elseif s then - z = z + 5 + if s then + z = z + ((record and record.shakeBonus) or 5) end if z < 10 then return 0 elseif z < 30 then return 1 elseif z < 70 then return 2 else return 3 end @@ -63,4 +80,29 @@ function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride) return false, shakes() end +-- Returns caught, shakes (0-3). rateOverride replaces the species catch +-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate). +-- opts (all optional): ballDef = the merged ball record, statuses = the +-- merged statuses table, battle = the running battle. A ball record's +-- attempt fn supersedes the whole formula; its ctx.vanillaAttempt() runs +-- the stock math with the ctx's (possibly rewritten) rateOverride. +function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride, opts) + rng = rng or love.math.random + opts = opts or {} + local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL + local statuses = opts.statuses + if def.attempt then + local ctx = { + ballDef = def, targetMon = targetMon, targetDef = targetDef, + rng = rng, rateOverride = rateOverride, battle = opts.battle, + } + ctx.vanillaAttempt = function() + return stockAttempt(def, targetMon, targetDef, rng, ctx.rateOverride, + statuses) + end + return def.attempt(ctx) + end + return stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses) +end + return Catching diff --git a/src/battle/Damage.lua b/src/battle/Damage.lua index 11f904a0..47c0962d 100644 --- a/src/battle/Damage.lua +++ b/src/battle/Damage.lua @@ -3,27 +3,68 @@ -- -- Battlers carry curStats/curTypes (Transform/Conversion can override the -- species values) plus reflect/lightScreen/focusEnergy volatile flags. +-- Battlers built by makeBattler also carry the merged badgeBoosts rows and +-- statuses records; hand-built battlers fall back to the vanilla tables. +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") local Stats = require("src.pokemon.Stats") +local Status = require("src.battle.Status") local TypeChart = require("src.battle.TypeChart") local Damage = {} -- Moves with a boosted critical-hit rate (engine/battle/core.asm --- CriticalHitTest checks these move ids explicitly). +-- CriticalHitTest checks these move ids explicitly). The move-record +-- highCrit field wins; this list covers pre-existing imported caches. local HIGH_CRIT = { KARATE_CHOP = true, RAZOR_LEAF = true, CRABHAMMER = true, SLASH = true, } +-- ApplyBadgeStatBoosts (engine/battle/core.asm): x9/8 per badge on the +-- named battle stat. Data.constants.badgeBoosts replaces this via the +-- battler's badgeBoosts field; these rows are the vanilla values. +Damage.BADGE_BOOSTS = { + { badge = "BOULDERBADGE", stat = "attack", num = 9, den = 8 }, + { badge = "THUNDERBADGE", stat = "defense", num = 9, den = 8 }, + { badge = "SOULBADGE", stat = "speed", num = 9, den = 8 }, + { badge = "VOLCANOBADGE", stat = "special", num = 9, den = 8 }, +} + +-- the boost a battler's badge set applies to one battle stat, or nil +local function badgeBoost(battler, stat) + local badges = battler.badges + if not badges then return nil end + for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do + if row.stat == stat and badges[row.badge] then return row end + end + return nil +end + +-- the merged status record for a battler's persistent condition, or nil +local function statusRecord(battler) + return Status.recordFor(battler.statuses, battler.mon.status) +end + -- Critical chance test, following CriticalHitTest's shift chain exactly --- (each left shift caps at 255): b = baseSpeed/2, then x2 (or /2 with +-- (each left shift caps at 255): b = speed/2, then x2 (or /2 with -- Focus Energy's famous right-shift bug), then x4 for high-crit moves -- or /2 for normal ones. Net rates: normal = speed/512, high-crit = -- speed*4/256 (capped), Focus Energy bug = 1/4 the usual. -function Damage.critRoll(ruleset, attacker, moveId, rng) +-- critUsesBaseSpeed (default true, the Gen 1 rule) reads the species +-- base speed; a ruleset that sets it false uses the current in-battle +-- speed with stages applied. +function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit) rng = rng or love.math.random local function shl(x) return math.min(255, x * 2) end - local b = math.floor(attacker.def.baseStats.speed / 2) + local speed + if ruleset.critUsesBaseSpeed == false then + speed = Stats.applyStage(attacker.curStats.speed, + attacker.stages and attacker.stages.speed or 0) + else + speed = attacker.def.baseStats.speed + end + local b = math.floor(speed / 2) if attacker.focusEnergy then if ruleset.focusEnergyBug then b = math.floor(b / 2) -- srl instead of sla @@ -33,7 +74,8 @@ function Damage.critRoll(ruleset, attacker, moveId, rng) else b = shl(b) end - if HIGH_CRIT[moveId] then + if highCrit == nil then highCrit = HIGH_CRIT[moveId] end + if highCrit then b = shl(shl(b)) else b = math.floor(b / 2) @@ -63,13 +105,27 @@ function Damage.accuracyRoll(ruleset, move, attacker, defender, rng) return rng(0, 255) < acc end -local function isSpecial(moveType) - -- Gen 1: WATER/GRASS/FIRE/ICE/ELECTRIC/PSYCHIC/DRAGON are special - return moveType == "WATER" or moveType == "GRASS" or moveType == "FIRE" - or moveType == "ICE" or moveType == "ELECTRIC" or moveType == "PSYCHIC_TYPE" - or moveType == "DRAGON" +local warnedTypes = {} + +-- Gen 1 splits physical from special by TYPE: the move's own category +-- field wins, then the merged type record's, then physical (with one +-- warning per unknown type). +local function categoryOf(move) + local category = move.category or TypeChart.category(move.type) + if category == nil then + if move.type ~= nil and not warnedTypes[move.type] then + warnedTypes[move.type] = true + Logger.warn("move type %s has no category; treated as physical", + tostring(move.type)) + end + category = "physical" + end + return category +end + +function Damage.isSpecial(moveType) + return TypeChart.category(moveType) == "special" end -Damage.isSpecial = isSpecial -- Compute damage. attacker/defender are battler tables. -- opts: rng, forceCrit, explode (halves defense), typeless (confusion @@ -80,16 +136,23 @@ Damage.isSpecial = isSpecial function Damage.compute(ruleset, attacker, defender, move, opts) opts = opts or {} local rng = opts.rng or love.math.random - if move.power == 0 then + if move.power == 0 or move.category == "status" then return 0, { crit = false, typeMult = 10 } end local crit = opts.forceCrit if crit == nil then - crit = Damage.critRoll(ruleset, attacker, move.id, rng) + if Runtime.wantsHook("battle.crit") then + crit = Runtime.call("battle.crit", function(c) + return Damage.critRoll(c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit) + end, { ruleset = ruleset, attacker = attacker, moveId = move.id, + rng = rng, highCrit = move.highCrit }) + else + crit = Damage.critRoll(ruleset, attacker, move.id, rng, move.highCrit) + end end - local special = isSpecial(move.type) + local special = categoryOf(move) == "special" local atkStat = special and "special" or "attack" local defStat = special and "special" or "defense" @@ -105,28 +168,23 @@ function Damage.compute(ruleset, attacker, defender, move, opts) -- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts: -- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder), -- Volcano -> special - local badges = attacker.badges - if badges then - if not special and badges.BOULDERBADGE then - atk = math.floor(atk * 9 / 8) - elseif special and badges.VOLCANOBADGE then - atk = math.floor(atk * 9 / 8) - end + local atkBoost = badgeBoost(attacker, atkStat) + if atkBoost then + atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8)) end - local dbadges = defender.badges - if dbadges then - if not special and dbadges.THUNDERBADGE then - dfn = math.floor(dfn * 9 / 8) - elseif special and dbadges.VOLCANOBADGE then - dfn = math.floor(dfn * 9 / 8) - end + local defBoost = badgeBoost(defender, defStat) + if defBoost then + dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8)) end - -- burn halves physical attack (applied as part of the stat in Gen 1). + -- burn halves physical attack (applied as part of the stat in Gen 1; + -- the status record's statPenalty names the stat it cuts). -- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the -- unmodified attack over the burn-halved battle stat, lifting the -- penalty until the next stat recompute. - if not special and attacker.mon.status == "BRN" and not attacker.hazeStatReset then - atk = math.max(1, math.floor(atk / 2)) + local record = statusRecord(attacker) + local penalty = record and record.statPenalty + if penalty and penalty.stat == atkStat and not attacker.hazeStatReset then + atk = math.max(1, math.floor(atk / penalty.div)) end -- screens double the effective defense (crits bypass them). The -- confusion self-hit is the quirk case: HandleSelfConfusionDamage diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua new file mode 100644 index 00000000..2bf023d2 --- /dev/null +++ b/src/battle/EffectRegistry.lua @@ -0,0 +1,258 @@ +-- The move-effect execution surface: the ctx facade handed to every +-- move_effects record callback, and the staged damaging pipeline that +-- performMove drives through the record's stage fields +-- (gate/neverMiss/hitCount/beforeAccuracy/chooseDamage/onMiss/afterDamage +-- plus the post-damage secondary run). The ctx is the only supported +-- surface handlers receive; everything else is engine-internal. + +local MoveEffects = require("src.battle.MoveEffects") +local Runtime = require("src.mods.Runtime") +local StatusRegistry = require("src.battle.StatusRegistry") + +local EffectRegistry = {} + +-- pokered's / text macros print "Enemy " before the enemy +-- mon's nickname (home/text.asm PlaceMoveUsersName) +local function displayName(b) + return b.isPlayer and b.name or ("Enemy " .. b.name) +end +EffectRegistry.displayName = displayName + +-- built once per performMove call; closes over the battle +function EffectRegistry.makeCtx(battle, user, target, move, moveInst, isCalled) + local ctx + ctx = { + battle = battle, data = battle.data, rng = battle.rng, + ruleset = battle.ruleset, + user = user, target = target, move = move, moveInst = moveInst, + isCalled = isCalled or false, + field = battle.field, + displayName = displayName, + say = function(text) battle:sayNext(text) end, + sayNext = function(text) battle:sayNext(text) end, + anim = function(animName, isPlayer) + battle:animNext(animName, isPlayer == nil and user.isPlayer or isPlayer) + end, + drain = function() battle:drainNext() end, + -- applyDamage plus the faint queue, like the crash/self-hit paths + damage = function(who, amount) + local dealt = battle:applyDamage(who, amount) + if who.mon.hp <= 0 then battle:onFaint(who) end + return dealt + end, + inflict = function(who, statusId, opts) + return StatusRegistry.inflict(battle, who, statusId, opts) + end, + cure = function(who) + who.mon.status = nil + who.toxicCounter = nil + end, + changeStage = function(who, stat, delta, fromEnemy) + return MoveEffects.changeStage(battle, who, stat, delta, fromEnemy) + end, + computeDamage = function(opts) + return battle:computeDamage(user, target, move, opts) + end, + accuracyRoll = function() + return battle:accuracyRoll(move, user, target) + end, + callMove = function(moveId) + return battle:performMove(user, target, { id = moveId, pp = 1 }, true) + end, + side = function(who) return battle:sideOf(who) end, + } + return ctx +end + +-- multi-hit count: the record's hitCount wins, then the move's multiHit +-- field, then a single hit +local function hitCount(ctx, record) + if record and record.hitCount then + return record.hitCount(ctx) or 1 + end + local dist = ctx.move.multiHit + if dist == nil then return 1 end + if type(dist) == "number" then return dist end + local r = ctx.rng(0, #dist - 1) + return dist[r + 1] +end + +-- The damaging pipeline, extracted from the performMove monolith: every +-- stage keeps the original's exact check order and rng consumption +-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy -> +-- damage choice -> hits -> messages -> after-damage -> secondary run). +function EffectRegistry.runDamaging(battle, ctx, record) + local user, target = ctx.user, ctx.target + local move, moveInst = ctx.move, ctx.moveInst + local neverMiss = record and record.neverMiss + + -- Swift ignores semi-invulnerability (MoveHitTest returns hit for + -- SWIFT_EFFECT before the INVULNERABLE check) + if target.invulnerable and not neverMiss then + battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) + return + end + + -- OHKO speed gate, Dream Eater sleep gate + if record and record.gate then + local ok, failMsg = record.gate(ctx) + if not ok then + if failMsg then battle:sayNext(failMsg) end + return + end + end + + local hits = hitCount(ctx, record) + + if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end + + if not neverMiss then + if not battle:accuracyRoll(move, user, target) then + battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) + -- Jump Kick crash, Explode self-destruct + if record and record.onMiss then record.onMiss(ctx, "accuracy") end + user.trappingTurns = nil + return + end + end + + -- damage per hit + local dmg, info + if move.id == "COUNTER" then + -- HandleCounterMove: 2x the last damage dealt in battle, only if + -- the opponent's last move was counterable with >0 power (and not + -- Counter itself); wDamage is shared, so any last damage counts. + -- counterable defaults to the Normal/Fighting whitelist. + local lastId = target.lastMove + local lm = lastId and lastId ~= "COUNTER" and battle.data.moves[lastId] + local counterable = false + if lm and (lm.power or 0) > 0 then + if lm.counterable ~= nil then + counterable = lm.counterable + else + counterable = lm.type == "NORMAL" or lm.type == "FIGHTING" + end + end + if not counterable or (battle.lastDamage or 0) == 0 then + battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) + return + end + dmg = math.min(65535, battle.lastDamage * 2) + info = { crit = false, typeMult = 10 } + elseif record and record.chooseDamage then + -- Counter/Super Fang/OHKO/fixed damage; (nil, msg) means the move + -- failed with that text already chosen + local chosen, extra = record.chooseDamage(ctx) + if not chosen then + if extra then battle:sayNext(extra) end + return + end + dmg, info = chosen, extra or { crit = false, typeMult = 10 } + else + dmg, info = battle:computeDamage(user, target, move, + { rng = battle.rng, explode = (record and record.explode) or nil }) + end + + if info.typeMult == 0 then + battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) + if record and record.onMiss then record.onMiss(ctx, "immune") end + return + end + if info.missed then + -- 0.25x floored the damage to zero: the original registers a miss + battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) + if record and record.onMiss then record.onMiss(ctx, "floored") end + return + end + battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter) + + -- the hit blink + damage sound ride the queue behind the animation: + -- on the move's anim row when one was announced, else on a bare hit + -- row (thrash/rage continuations), placed BEFORE the drain rows the + -- hits loop inserts so the blink precedes the bar drain + local hitRow = battle.moveAnimRow + if not hitRow then + battle.nextInsert = (battle.nextInsert or 0) + 1 + hitRow = { hitRow = true } + table.insert(battle.queue, battle.nextInsert, hitRow) + end + + local totalDealt = 0 + local landed, brokeSub = 0, false + for h = 1, hits do + if target.mon.hp <= 0 then break end + local hadSub = target.substituteHP ~= nil + local dealt = battle:applyDamage(target, dmg) + totalDealt = totalDealt + dealt + landed = h + if Runtime.wants("battle.damage_dealt") then + Runtime.emit("battle.damage_dealt", { + battle = battle, user = user, target = target, move = move, + damage = dealt, crit = info.crit, typeMult = info.typeMult, + }) + end + if hadSub and not target.substituteHP then + -- AttackSubstitute: breaking the substitute ends a multi-hit move + brokeSub = true + break + end + end + hits = landed > 0 and landed or hits + if totalDealt > 0 then + -- the original's per-hit sound: normal / super / not-very-effective + local hitSfx = info.typeMult > 10 and "Super_Effective" + or info.typeMult < 10 and "Not_Very_Effective" or "Damage" + hitRow.hit = { sfx = hitSfx, + blink = battle:animationsOn() and target or nil } + end + -- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right + -- after the damage lands, BEFORE DisplayEffectiveness (core.asm + -- .moveDidNotMiss); the multi-hit count follows the last hit + if info.crit then battle:sayNext("Critical hit!") end + if info.ohko then battle:sayNext("One-hit KO!") end + if info.typeMult > 10 then + battle:sayNext("It's super\neffective!") + elseif info.typeMult < 10 then + battle:sayNext("It's not very\neffective...") + end + if hits > 1 then + -- player: _MultiHitText; enemy: _HitXTimesText (always plural) + if user.isPlayer then + battle:sayNext(("Hit the enemy\n%d times!"):format(hits)) + else + battle:sayNext(("Hit %d times!"):format(hits)) + end + end + + -- post-damage effect bookkeeping (recoil/drain/trap/thrash/...) + ctx.rawDamage, ctx.totalDealt = dmg, totalDealt + ctx.brokeSub, ctx.hits = brokeSub, hits + if record and record.afterDamage then + record.afterDamage(ctx, totalDealt) + elseif moveInst.struggle then + -- struggle recoils even when its effect id resolves to no record + local recoil = math.max(1, math.floor(dmg / 2)) + battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user))) + battle:applyDamage(user, recoil) + end + + -- secondary side effects (blocked by fainting) + if record and record.run and record.kind ~= "primary" + and target.mon.hp > 0 and totalDealt > 0 then + for _, m in ipairs(record.run(ctx)) do + battle:sayNext(m) + end + end + if record == nil then + MoveEffects.warnUnknown(move.effect) + end + + if target.mon.hp <= 0 then + battle:onFaint(target) + end + if user.mon.hp <= 0 then + battle:onFaint(user) + end +end + +return EffectRegistry diff --git a/src/battle/Experience.lua b/src/battle/Experience.lua index ebd9caf3..78ca0fdf 100644 --- a/src/battle/Experience.lua +++ b/src/battle/Experience.lua @@ -5,6 +5,7 @@ -- participant's stat exp. local Growth = require("src.pokemon.Growth") +local Runtime = require("src.mods.Runtime") local Stats = require("src.pokemon.Stats") local Experience = {} @@ -21,14 +22,26 @@ local Experience = {} -- Sequential floor divisions equal one floor division by the product, -- so callers pass numParticipants = 2*participants for the first pass -- and 2*participants*partyCount for the whole-party pass. -function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants, traded) +-- +-- consts is Data.constants; a constants.exp record can retune the +-- divisor and the traded/trainer multipliers, with the values above as +-- the defaults. +function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants, + traded, consts) + local divisor, tradedMult, trainerMult = 7, nil, nil + local tuning = consts and consts.exp + if tuning then + divisor = tuning.divisor or divisor + tradedMult = tuning.tradedMult + trainerMult = tuning.trainerMult + end local base = math.floor(defeatedDef.baseExp / math.max(1, numParticipants or 1)) - local exp = math.floor(base * level / 7) + local exp = math.floor(base * level / divisor) if traded then - exp = math.floor(exp * 3 / 2) + exp = math.floor(exp * (tradedMult or 1.5)) end if isTrainer then - exp = math.floor(exp * 3 / 2) + exp = math.floor(exp * (trainerMult or 1.5)) end return math.max(1, exp) end @@ -46,18 +59,36 @@ function Experience.apply(data, mon, defeatedDef, level, isTrainer, local gain = math.floor(defeatedDef.baseStats[key] / statShare) mon.statExp[key] = math.min(65535, (mon.statExp[key] or 0) + gain) end - local gained = Experience.gainFor(defeatedDef, level, isTrainer, - numParticipants, traded) + local consts = data.constants + local gained + if Runtime.wantsHook("exp.gain") then + gained = Runtime.call("exp.gain", function(c) + return Experience.gainFor(c.defeatedDef, c.level, c.isTrainer, + c.participants, c.traded, consts) + end, { defeatedDef = defeatedDef, level = level, isTrainer = isTrainer, + participants = numParticipants, traded = traded, mon = mon }) + else + gained = Experience.gainFor(defeatedDef, level, isTrainer, + numParticipants, traded, consts) + end mon.exp = mon.exp + gained + local cap = consts and consts.levelCap or 100 local levels = {} - local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp) - while mon.level < math.min(newLevel, 100) do + local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp, cap, + data.growth_rates) + while mon.level < math.min(newLevel, cap) do mon.level = mon.level + 1 local old = mon.stats mon.stats = Stats.calc(speciesDef, mon.level, mon.dvs, mon.statExp) mon.hp = math.min(mon.stats.hp, mon.hp + (mon.stats.hp - old.hp)) table.insert(levels, mon.level) + if Runtime.wants("pokemon.level_up") then + Runtime.emit("pokemon.level_up", { + mon = mon, level = mon.level, prevLevel = mon.level - 1, + learnable = Experience.movesLearnedAt(speciesDef, mon.level), + }) + end end return levels, gained end diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index e69c3b72..686729ad 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -5,8 +5,16 @@ -- -- Substitutes block status/stat effects and side effects aimed at their -- owner, like Gen 1. +-- +-- The primary/secondary tables keep their v1 signatures; MoveEffects.full +-- carries the stage callbacks the damaging pipeline consults, and RECORDS +-- is the registry view of all three -- the merged Data.move_effects a +-- battle dispatches on serves these same objects. local Logger = require("src.core.Logger") +local StatusRegistry = require("src.battle.StatusRegistry") +local TurnOrder = require("src.battle.TurnOrder") +local TypeChart = require("src.battle.TypeChart") local MoveEffects = {} @@ -53,6 +61,7 @@ local function changeStage(battle, who, stat, delta, fromEnemy) end return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) } end +MoveEffects.changeStage = changeStage local function statUp(stat, delta) return function(battle, user, target) @@ -70,54 +79,10 @@ end -- status -- --------------------------------------------------------------------- -local STATUS_LABEL = { - SLP = "fell asleep", PSN = "was poisoned", BRN = "was burned", - FRZ = "was frozen solid", -} - --- opts: toxic (start the Toxic counter), moveType (for the type --- gates), secondary (side-effect of a damaging move). +-- kept as the module's inflict entry: the registry-backed rules live in +-- StatusRegistry (per-status canInflict/onInflict on the merged records) local function inflictStatus(battle, target, status, opts) - opts = opts or {} - if target.mon.status then return {} end - -- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute) - -- and every secondary status, but NOT primary Sleep or Thunder Wave, - -- their handlers never check the substitute in Gen 1. - if target.substituteHP and (opts.secondary or status == "PSN") then - return {} - end - for _, t in ipairs(target.curTypes) do - -- can't poison Poison-types (primary or secondary) - if status == "PSN" and t == "POISON" then return {} end - -- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types - if status == "PAR" and opts.moveType == "ELECTRIC" and t == "GROUND" then - return {} - end - -- FreezeBurnParalyzeEffect: a secondary status never lands when - -- the move's type matches either of the target's types (Body Slam - -- can't paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) - if opts.secondary and status ~= "PSN" and opts.moveType == t then - return {} - end - -- keep the canonical immunities for any non-secondary path - if (status == "BRN" and t == "FIRE") or (status == "FRZ" and t == "ICE") then - return {} - end - end - target.mon.status = status - if status == "SLP" then - target.sleepTurns = battle.rng(1, 7) - end - if opts.toxic then - target.toxicCounter = 1 - -- _BadlyPoisonedText - return { ("%s's\nbadly poisoned!"):format(displayName(target)) } - end - if status == "PAR" then - -- _ParalyzedMayNotAttackText (primary and secondary paralysis) - return { ("%s's\nparalyzed! It may\nnot attack!"):format(displayName(target)) } - end - return { ("%s\n%s!"):format(displayName(target), STATUS_LABEL[status]) } + return StatusRegistry.inflict(battle, target, status, opts) end local function statusMove(status) @@ -131,6 +96,7 @@ local function statusMove(status) local msgs = inflictStatus(battle, target, status, { toxic = move and move.id == "TOXIC", moveType = move and move.type, + source = move and move.id, }) if #msgs == 0 then return { "But, it failed!" } @@ -151,6 +117,7 @@ local function statusSide(status, chance) return inflictStatus(battle, target, status, { moveType = move and move.type, secondary = true, + source = move and move.id, }) end end @@ -397,11 +364,326 @@ MoveEffects.secondary = { -- the second hit reroutes to PoisonEffect with POISON_SIDE_EFFECT1: -- 20 percent + 1 (52/256) if battle.rng(0, 255) >= 52 then return {} end - return inflictStatus(battle, target, "PSN", { secondary = true }) + return inflictStatus(battle, target, "PSN", + { secondary = true, source = "TWINEEDLE" }) end, } --- effects fully handled inside BattleState's damage pipeline +-- --------------------------------------------------------------------- +-- full records: the damaging pipeline's stage callbacks +-- --------------------------------------------------------------------- + +-- Status-move effects whose pokered handlers call MoveHitTest (sleep/ +-- poison/paralyze/confusion/leech seed/disable and the primary +-- stat-down moves). Everything else in MoveEffects.primary is +-- self-targeting and never rolls accuracy. Mimic also hit-tests but +-- runs its own mid-move flow (resolveMimic). +local ACC_CHECKED = { + SLEEP_EFFECT = true, POISON_EFFECT = true, PARALYZE_EFFECT = true, + CONFUSION_EFFECT = true, LEECH_SEED_EFFECT = true, DISABLE_EFFECT = true, + ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true, + DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true, + ACCURACY_DOWN1_EFFECT = true, +} + +-- fixed-damage moves (engine/battle/core.asm SpecialDamage); the move +-- field wins, previously imported caches fall back to the id table +local FIXED_DAMAGE = { + SONICBOOM = 20, DRAGON_RAGE = 40, + SEISMIC_TOSS = "level", NIGHT_SHADE = "level", PSYWAVE = "half_level_rand", +} +MoveEffects.FIXED_DAMAGE = FIXED_DAMAGE + +local function fixedDamageFor(ctx) + local spec = ctx.move.fixedDamage + if spec == nil then spec = FIXED_DAMAGE[ctx.move.id] end + if type(spec) == "function" then return spec(ctx) end + if spec == "level" then return ctx.user.mon.level end + if spec == "half_level_rand" then + -- PSYWAVE: rand(1, floor(level*3/2) - 1) + local max = math.max(1, math.floor(ctx.user.mon.level * 3 / 2) - 1) + return ctx.rng(1, max) + end + return spec +end + +local function plainInfo() + return { crit = false, typeMult = 10 } +end + +-- multi-hit count: the move's multiHit field (a count or a distribution) +-- with the effect's classic distribution as the fallback +local function hitsFrom(dist, ctx) + if type(dist) == "number" then return dist end + local r = ctx.rng(0, #dist - 1) + return dist[r + 1] +end + +-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals +-- that amount, so Counter would see the halved value +local function drainHalf(text) + return function(ctx) + local heal = math.max(1, math.floor(ctx.rawDamage / 2)) + ctx.battle.lastDamage = heal + local mon = ctx.user.mon + mon.hp = math.min(mon.stats.hp, mon.hp + heal) + ctx.drain() + ctx.say(text:format(displayName(ctx.target))) + end +end + +-- fixed damage still respects type immunity (AdjustDamageForMoveType +-- flags the miss before the special-damage override) +local function immuneMsg(ctx) + if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then + return ("It doesn't affect\n%s!"):format(displayName(ctx.target)) + end + return nil +end + +MoveEffects.full = { + NO_ADDITIONAL_EFFECT = {}, + + TWO_TO_FIVE_ATTACKS_EFFECT = { + hitCount = function(ctx) + return hitsFrom(ctx.move.multiHit or { 2, 2, 2, 3, 3, 3, 4, 5 }, ctx) + end, + }, + ATTACK_TWICE_EFFECT = { + hitCount = function(ctx) + return hitsFrom(ctx.move.multiHit or 2, ctx) + end, + }, + -- hits twice AND keeps its secondary poison run (registered below) + TWINEEDLE_EFFECT = { + hitCount = function(ctx) + return hitsFrom(ctx.move.multiHit or 2, ctx) + end, + }, + + SPECIAL_DAMAGE_EFFECT = { + chooseDamage = function(ctx) + local blocked = immuneMsg(ctx) + if blocked then return nil, blocked end + local dmg = fixedDamageFor(ctx) + if not dmg then return nil, "But, it failed!" end + return dmg, plainInfo() + end, + }, + SUPER_FANG_EFFECT = { + chooseDamage = function(ctx) + local blocked = immuneMsg(ctx) + if blocked then return nil, blocked end + return math.max(1, math.floor(ctx.target.mon.hp / 2)), plainInfo() + end, + }, + OHKO_EFFECT = { + -- fails against faster opponents (Gen 1 rule) and immune types + gate = function(ctx) + local blocked = immuneMsg(ctx) + if blocked then return false, blocked end + if TurnOrder.effectiveSpeed(ctx.user) < TurnOrder.effectiveSpeed(ctx.target) then + return false, "But, it failed!" + end + return true + end, + chooseDamage = function() + return 65535, { crit = false, typeMult = 10, ohko = true } + end, + }, + + RECOIL_EFFECT = { + afterDamage = function(ctx) + -- recoil.asm reads the RAW computed wDamage (not the HP actually + -- removed): overkill and substitute hits recoil at full strength + local recoil = math.max(1, math.floor(ctx.rawDamage + / (ctx.moveInst.struggle and 2 or 4))) + ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user))) + ctx.battle:applyDamage(ctx.user, recoil) + end, + }, + DRAIN_HP_EFFECT = { + afterDamage = drainHalf("Sucked health from\n%s!"), + }, + DREAM_EATER_EFFECT = { + -- only works on sleeping targets (checked before damage) + gate = function(ctx) + if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end + return true + end, + afterDamage = drainHalf("%s's\ndream was eaten!"), + }, + + -- charge moves: first turn just charges; Fly AND Dig go + -- semi-invulnerable (ChargeEffect sets INVULNERABLE for both) + CHARGE_EFFECT = { charge = {} }, + FLY_EFFECT = { charge = { invulnerable = true } }, + + TRAPPING_EFFECT = { + -- TrappingEffect runs BEFORE the hit test and clears the target's + -- Hyper Beam recharge, even if the trapping move then misses + -- (effects.asm:1091-1092 ClearHyperBeam) + beforeAccuracy = function(ctx) + if not ctx.user.trappingTurns then + ctx.target.mustRecharge = nil + end + end, + afterDamage = function(ctx) + local user = ctx.user + if not user.trappingTurns then + -- TrappingEffect (effects.asm:1080-1103) rolls wNumAttacksLeft + -- as 1-4 (weights 3/8 3/8 1/8 1/8): that many CONTINUATION + -- attacks follow this first hit, 2-5 attacks total. The victim + -- is held while the counter runs (live mirror in lockedAction). + local r = ctx.rng(0, 7) + user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1] + user.trapDamage = ctx.rawDamage + -- remember the move so its animation can replay on each locked + -- continuation (core.asm:3554-3566 -> GetPlayerAnimationType) + user.trapMove = ctx.move.id + end + end, + }, + THRASH_PETAL_DANCE_EFFECT = { + afterDamage = function(ctx) + local user = ctx.user + if not user.thrashTurns then + user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion + user.thrashMove = ctx.moveInst + user.thrashAnnounced = true + else + user.thrashTurns = user.thrashTurns - 1 + if user.thrashTurns <= 0 then + user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil + if not user.confusedTurns then + user.confusedTurns = ctx.rng(2, 5) + ctx.say(("%s\nbecame confused!"):format(displayName(user))) + end + end + end + end, + }, + JUMP_KICK_EFFECT = { + onMiss = function(ctx, reason) + if reason ~= "accuracy" then return end + ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user))) + ctx.damage(ctx.user, 1) + end, + }, + EXPLODE_EFFECT = { + explode = true, -- Damage.compute halves the defense + onMiss = function(ctx) + ctx.battle:selfDestruct(ctx.user) + end, + afterDamage = function(ctx) + ctx.battle:selfDestruct(ctx.user) + end, + }, + 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 + ctx.user.mustRecharge = true + end + end, + }, + PAY_DAY_EFFECT = { + afterDamage = function(ctx) + local battle = ctx.battle + battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level + ctx.say("Coins scattered\neverywhere!") + end, + }, + SWIFT_EFFECT = { neverMiss = true }, + RAGE_EFFECT = { + afterDamage = function(ctx) + ctx.user.rageMove = ctx.moveInst + end, + }, + + BIDE_EFFECT = { + perform = function(ctx) + local user = ctx.user + user.bideTurns = ctx.rng(2, 3) + user.bideDamage = 0 + ctx.say(("%s\nis storing energy!"):format(displayName(user))) + end, + }, + SWITCH_AND_TELEPORT_EFFECT = { + -- SwitchAndTeleportEffect (effects.asm:810-909): in a wild battle + -- it auto-succeeds when the user's level >= the opponent's; + -- otherwise roll rand[0, userLevel+enemyLevel] and FAIL when the + -- roll is below opponentLevel/4. Teleport's failure text is "But + -- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer + -- battles Teleport fails and Roar/Whirlwind are "unaffected". + perform = function(ctx) + local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move + if battle.kind == "wild" then + local uLvl, tLvl = user.mon.level, target.mon.level + local ok = uLvl >= tLvl + if not ok then + ok = ctx.rng(0, uLvl + tLvl) >= math.floor(tLvl / 4) + end + if ok then + if move.id == "ROAR" then + ctx.say(("%s\nran away scared!"):format(displayName(target))) + elseif move.id == "WHIRLWIND" then + ctx.say(("%s\nwas blown away!"):format(displayName(target))) + else + ctx.say(("%s\nran from battle!"):format(displayName(user))) + end + battle.result = "run" + battle.afterQueue = "finish" + elseif move.id == "TELEPORT" then + ctx.say("But, it failed!") + else + ctx.say(("It didn't affect\n%s!"):format(displayName(target))) + end + elseif move.id == "TELEPORT" then + ctx.say("But, it failed!") + else + ctx.say(("%s\nis unaffected!"):format(displayName(target))) + end + end, + }, + METRONOME_EFFECT = { + callsMove = function(ctx) + local order = ctx.data.constants.moveOrder + local pick + repeat + pick = order[ctx.rng(1, #order)] + until pick ~= "METRONOME" and pick ~= "STRUGGLE" and ctx.data.moves[pick] + return pick + end, + }, + MIRROR_MOVE_EFFECT = { + callsMove = function(ctx) + local last = ctx.target.lastMove + if not last then + ctx.say("The MIRROR MOVE\nfailed!") + return nil + end + return last + end, + }, + -- Mimic runs its own mid-move flow: hit test, then the copy menu + -- (player) or a random roll (enemy / link), all on the queue. + -- PlayCurrentMoveAnimation runs only after a successful copy + -- (effects.asm:1268), never on a miss -- so no announcement anim row. + MIMIC_EFFECT = { + announceAnim = false, + perform = function(ctx) + ctx.battle:resolveMimic(ctx.user, ctx.target, ctx.move, ctx.moveInst) + end, + }, +} + +-- --------------------------------------------------------------------- +-- the registry view +-- --------------------------------------------------------------------- + +-- effects fully handled inside the damaging pipeline; kept as the v1 +-- compat set (BattleState dispatched on it before the records existed) MoveEffects.special = { NO_ADDITIONAL_EFFECT = true, TWO_TO_FIVE_ATTACKS_EFFECT = true, ATTACK_TWICE_EFFECT = true, SPECIAL_DAMAGE_EFFECT = true, @@ -415,6 +697,40 @@ MoveEffects.special = { TWINEEDLE_EFFECT = true, MIMIC_EFFECT = true, } +-- the (battle, user, target, move, moveInst) handlers adapted to the ctx +-- facade the registry records expose +local function shim(fn) + return function(ctx) + return fn(ctx.battle, ctx.user, ctx.target, ctx.move, ctx.moveInst) + end +end + +local RECORDS = {} +MoveEffects.RECORDS = RECORDS +for id, fn in pairs(MoveEffects.primary) do + RECORDS[id] = { kind = "primary", run = shim(fn), + accuracyChecked = ACC_CHECKED[id] or nil } +end +for id, fn in pairs(MoveEffects.secondary) do + RECORDS[id] = { kind = "secondary", run = shim(fn) } +end +for id, spec in pairs(MoveEffects.full) do + local record = { kind = "full" } + for key, value in pairs(spec) do record[key] = value end + -- TWINEEDLE: full record with its secondary run honored post-damage + local secondary = MoveEffects.secondary[id] + if secondary then record.run = shim(secondary) end + RECORDS[id] = record +end + +-- One record per effect, the same objects performMove dispatches on: the +-- merged Data.move_effects and this table agree by construction. +function MoveEffects.registerInto(registry, _, owner) + for id, record in pairs(RECORDS) do + registry:register(id, record, owner) + end +end + local warned = {} function MoveEffects.warnUnknown(effect) diff --git a/src/battle/Status.lua b/src/battle/Status.lua index f4410d60..a0c5e3f4 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -1,9 +1,151 @@ -- Per-turn status/volatile condition handling (Gen 1 semantics). +-- +-- The persistent conditions live in Status.RECORDS; a battle passes its +-- merged Data.statuses so mod statuses join the same beforeMove gauntlet +-- and residual sweep. Callers without a battle (pure-module tests) fall +-- back to the vanilla records, which is bit-identical behavior. local Status = {} --- Returns canMove, messages, selfHit (true -> hurt itself in confusion) -function Status.beforeMove(battler, rng) +-- pokered's / text macros print "Enemy " before the enemy +-- mon's nickname; these records only know the raw name -- BattleState +-- splices the prefix in (prefixEnemy), same as always +local function name(battler) + return battler.name +end + +-- statuses with beforeMovePriority above this run before the engine's +-- held/disable/confusion volatiles; at or below, after (sleep 40 and +-- freeze 30 come first, paralysis 10 comes last, like the original +-- CheckPlayerStatusConditions order) +local VOLATILE_PRIORITY = 20 + +local function hasType(battler, wanted) + for _, t in ipairs(battler.curTypes or {}) do + if t == wanted then return true end + end + return false +end + +-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the +-- Toxic counter (HandlePoisonBurnLeechSeed) +local function damageOverTime(what) + return function(battler) + local mon = battler.mon + local base = math.max(1, math.floor(mon.stats.hp / 16)) + local dmg = base + if battler.toxicCounter then + dmg = base * battler.toxicCounter + battler.toxicCounter = battler.toxicCounter + 1 + end + mon.hp = math.max(0, mon.hp - dmg) + return { ("%s's\nhurt by %s!"):format(name(battler), what) } + end +end + +-- The five persistent conditions as records: the beforeMove gauntlet, the +-- residual sweep, the inflict text/immunities (StatusRegistry.inflict), +-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the +-- burn/paralysis stat cut (Damage.compute, TurnOrder.effectiveSpeed) all +-- read these fields, so a mod's sixth status plugs into every consumer. +Status.RECORDS = { + SLP = { + id = "SLP", label = "SLP", hudLabel = "SLP", + catchBonus = 25, shakeBonus = 10, + beforeMovePriority = 40, + beforeMove = function(battler) + battler.sleepTurns = (battler.sleepTurns or 1) - 1 + if battler.sleepTurns <= 0 then + battler.mon.status = nil + return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn + end + return false, { name(battler) .. "\nis fast asleep!" } + end, + onInflict = function(battle, target, opts, display) + target.sleepTurns = battle.rng(1, 7) + return { ("%s\nfell asleep!"):format(display) } + end, + }, + FRZ = { + id = "FRZ", label = "FRZ", hudLabel = "FRZ", + catchBonus = 25, shakeBonus = 10, + beforeMovePriority = 30, + beforeMove = function(battler) + return false, { name(battler) .. "\nis frozen solid!" } + end, + canInflict = function(target) return not hasType(target, "ICE") end, + onInflict = function(_, _, _, display) + return { ("%s\nwas frozen solid!"):format(display) } + end, + }, + PSN = { + id = "PSN", label = "PSN", hudLabel = "PSN", + catchBonus = 12, shakeBonus = 5, + residual = damageOverTime("poison"), + canInflict = function(target) return not hasType(target, "POISON") end, + onInflict = function(_, target, opts, display) + if opts.toxic then + target.toxicCounter = 1 + -- _BadlyPoisonedText + return { ("%s's\nbadly poisoned!"):format(display) } + end + return { ("%s\nwas poisoned!"):format(display) } + end, + }, + BRN = { + id = "BRN", label = "BRN", hudLabel = "BRN", + catchBonus = 12, shakeBonus = 5, + statPenalty = { stat = "attack", div = 2 }, + residual = damageOverTime("the burn"), + canInflict = function(target) return not hasType(target, "FIRE") end, + onInflict = function(_, _, _, display) + return { ("%s\nwas burned!"):format(display) } + end, + }, + PAR = { + id = "PAR", label = "PAR", hudLabel = "PAR", + catchBonus = 12, shakeBonus = 5, + statPenalty = { stat = "speed", div = 4 }, + beforeMovePriority = 10, + beforeMove = function(battler, rng) + -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) + if rng(0, 255) < 63 then + return false, { name(battler) .. "'s\nfully paralyzed!" } + end + return true, {} + end, + canInflict = function(target, opts) + -- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types + return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND")) + end, + onInflict = function(_, _, _, display) + -- _ParalyzedMayNotAttackText (primary and secondary paralysis) + return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) } + end, + }, +} + +function Status.registerInto(registry, _, owner) + for id, record in pairs(Status.RECORDS) do + registry:register(id, record, owner) + end +end + +-- the merged view when a battle is on hand, the vanilla records otherwise +function Status.recordFor(statuses, id) + if id == nil then return nil end + return (statuses or Status.RECORDS)[id] +end + +local function battleStatuses(battle) + return battle and battle.data and battle.data.statuses +end + +-- Returns canMove, messages, selfHit (true -> hurt itself in confusion). +-- The active status record's beforeMove runs at its priority slot: above +-- VOLATILE_PRIORITY before the held/disable/confusion block (sleep, +-- freeze), at or below after it (paralysis) -- the original's order. +function Status.beforeMove(battler, rng, battle) local mon = battler.mon -- Haze curing this mon's sleep/freeze forfeits its pending move for -- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected @@ -14,71 +156,68 @@ function Status.beforeMove(battler, rng) end if battler.flinched then battler.flinched = false - return false, { battler.name .. "\nflinched!" } + return false, { name(battler) .. "\nflinched!" } end - if mon.status == "SLP" then - battler.sleepTurns = (battler.sleepTurns or 1) - 1 - if battler.sleepTurns <= 0 then - mon.status = nil - return false, { battler.name .. "\nwoke up!" } -- wakes but loses the turn - end - return false, { battler.name .. "\nis fast asleep!" } + local record = Status.recordFor(battleStatuses(battle), mon.status) + local handler = record and record.beforeMove + local priority = handler and (record.beforeMovePriority or 0) + local msgs = {} + local function runStatus() + local canMove, statusMsgs, selfHit = handler(battler, rng, battle) + for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end + return canMove, selfHit end - if mon.status == "FRZ" then - return false, { battler.name .. "\nis frozen solid!" } + if handler and priority > VOLATILE_PRIORITY then + local canMove, selfHit = runStatus() + if not canMove or selfHit then return canMove, msgs, selfHit end + handler = nil end if battler.boundTurns and battler.boundTurns > 0 then battler.boundTurns = battler.boundTurns - 1 - return false, { battler.name .. "\ncan't move!" } + msgs[#msgs + 1] = name(battler) .. "\ncan't move!" + return false, msgs end - local msgs = {} if battler.disabledTurns then battler.disabledTurns = battler.disabledTurns - 1 if battler.disabledTurns <= 0 then battler.disabledTurns, battler.disabledSlot = nil, nil - table.insert(msgs, battler.name .. "'s\ndisabled no more!") + table.insert(msgs, name(battler) .. "'s\ndisabled no more!") end end if battler.confusedTurns then battler.confusedTurns = battler.confusedTurns - 1 if battler.confusedTurns <= 0 then battler.confusedTurns = nil - table.insert(msgs, battler.name .. "\nsnapped out of\nconfusion!") + table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!") else - table.insert(msgs, battler.name .. "\nis confused!") + table.insert(msgs, name(battler) .. "\nis confused!") -- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256) if rng(0, 255) < 128 then return false, msgs, true -- hurt itself end end end - -- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256) - if mon.status == "PAR" and rng(0, 255) < 63 then - table.insert(msgs, battler.name .. "'s\nfully paralyzed!") - return false, msgs + if handler then + local canMove, selfHit = runStatus() + if not canMove or selfHit then return canMove, msgs, selfHit end end return true, msgs end -- End-of-turn residual damage; opponent is needed for Leech Seed. -- Returns messages. -function Status.residual(battler, opponent) +function Status.residual(battler, opponent, battle) local msgs = {} local mon = battler.mon -- the Haze move-forfeit only covers the turn Haze was used; if this -- mon had already moved, drop the flag before it leaks into next turn battler.skipMove = nil if mon.hp <= 0 then return msgs end - if mon.status == "PSN" or mon.status == "BRN" then - local base = math.max(1, math.floor(mon.stats.hp / 16)) - local dmg = base - if battler.toxicCounter then - dmg = base * battler.toxicCounter - battler.toxicCounter = battler.toxicCounter + 1 + local record = Status.recordFor(battleStatuses(battle), mon.status) + if record and record.residual then + for _, m in ipairs(record.residual(battler, opponent, battle) or {}) do + msgs[#msgs + 1] = m end - mon.hp = math.max(0, mon.hp - dmg) - local what = mon.status == "PSN" and "poison" or "the burn" - table.insert(msgs, ("%s's\nhurt by %s!"):format(battler.name, what)) end if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then -- the shared Toxic counter multiplies (and advances on) the seed @@ -92,7 +231,7 @@ function Status.residual(battler, opponent) dmg = math.min(dmg, mon.hp) mon.hp = mon.hp - dmg opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg) - table.insert(msgs, ("LEECH SEED saps\n%s!"):format(battler.name)) + table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler))) end return msgs end diff --git a/src/battle/StatusRegistry.lua b/src/battle/StatusRegistry.lua new file mode 100644 index 00000000..2c9f8892 --- /dev/null +++ b/src/battle/StatusRegistry.lua @@ -0,0 +1,57 @@ +-- Status infliction against the merged statuses registry: the shared +-- immunity rules stay here, the per-status ones live on the records +-- (canInflict) and so does the landing text (onInflict), so a mod status +-- inflicts through the same path as the vanilla five. + +local Runtime = require("src.mods.Runtime") +local Status = require("src.battle.Status") + +local StatusRegistry = {} + +-- pokered's / text macros (home/text.asm +-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name +local function displayName(b) + return b.isPlayer and b.name or ("Enemy " .. b.name) +end + +-- opts: toxic (start the Toxic counter), moveType (for the type gates), +-- secondary (side-effect of a damaging move), source (inflicting move id). +-- Returns messages; empty means the status did not land. +function StatusRegistry.inflict(battle, target, status, opts) + opts = opts or {} + if target.mon.status then return {} end + -- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute) + -- and every secondary status, but NOT primary Sleep or Thunder Wave, + -- their handlers never check the substitute in Gen 1. + if target.substituteHP and (opts.secondary or status == "PSN") then + return {} + end + -- FreezeBurnParalyzeEffect: a secondary status never lands when the + -- move's type matches either of the target's types (Body Slam can't + -- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice) + if opts.secondary and status ~= "PSN" then + for _, t in ipairs(target.curTypes or {}) do + if opts.moveType == t then return {} end + end + end + local statuses = battle and battle.data and battle.data.statuses + local record = Status.recordFor(statuses, status) + if record and record.canInflict and not record.canInflict(target, opts) then + return {} + end + target.mon.status = status + local msgs + local display = displayName(target) + if record and record.onInflict then + msgs = record.onInflict(battle, target, opts, display) + else + msgs = { ("%s\nwas afflicted\nby %s!"):format(display, + record and record.label or tostring(status)) } + end + Runtime.emit("battle.status_inflicted", { + battle = battle, target = target, status = status, source = opts.source, + }) + return msgs +end + +return StatusRegistry diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index f8c77487..6cc6dc89 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -24,13 +24,25 @@ local TrainerAI = {} local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 } local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" } +-- The trainer's ai_classes record from the merged registry; the direct +-- require covers battles built without a loader. A trainer record's +-- aiClass field picks a record other than its own id. +function TrainerAI.classFor(battle) + local trainer = battle and battle.trainer + if not trainer then return nil end + local id = trainer.aiClass or trainer.id + local classes = battle.data and battle.data.ai_classes + if classes then return classes[id] end + return require("data.scripts.ai_classes")[id] +end + -- Item use / switching per trainer class (engine/battle/trainer_ai.asm --- via data/scripts/ai_classes.lua). Runs before move choice each enemy +-- via the ai_classes registry). Runs before move choice each enemy -- turn; returns an action { special = "aiItem"/"aiSwitch", ... } or nil. -- battle.aiUses is initialized per enemy Pokémon (wAICount). function TrainerAI.classAction(battle) if battle.kind ~= "trainer" or not battle.trainer then return nil end - local class = require("data.scripts.ai_classes")[battle.trainer.id] + local class = TrainerAI.classFor(battle) if not class then return nil end if (battle.aiUses or 0) <= 0 then return nil end local rng = battle.rng @@ -154,6 +166,50 @@ local function hasBetterMove(battler, judged, battle) return false end +-- The three vanilla passes as ai_classes layer records: vanilla is just the +-- first registrant, so a mod patches one instead of reimplementing trainer +-- AI. src/mods/Builtins.lua registers them; chooseMove dispatches through +-- whatever the registry holds and falls back here when a battle was built +-- without a loader. view.encourageTurn is wAILayer2Encouragement == 1. +TrainerAI.LAYERS = { + LAYER_1 = { kind = "layer", score = function(view, def, score) + -- `add $5`: heavily discourage a zero-power status move that would + -- fail because the player is already statused + if def and view.target.mon.status and def.power == 0 + and STATUS_EFFECTS[def.effect] then + return score + 5 + end + return score + end }, + LAYER_2 = { kind = "layer", score = function(view, def, score) + if def and view.encourageTurn and ENCOURAGE_EFFECTS[def.effect] then + return score - 1 -- `dec [hl]`: slightly encourage + end + return score + end }, + -- AIGetTypeEffectiveness only reads the FIRST matching TypeEffects row for + -- (move type vs either defender type) -- no dual-type product -- and runs + -- for non-damaging moves too. The table holds no value-10 rows, so + -- >10 / <10 reproduces the oracle's compare against $10. + LAYER_3 = { kind = "layer", score = function(view, def, score) + if not def then return score end + local row = TypeChart.rows(def.type, view.target.curTypes)[1] + if row and row > 10 then + return score - 1 -- `dec [hl]`: encourage a super-effective move + elseif row and row < 10 and hasBetterMove(view.user, def, view.battle) then + return score + 1 -- `inc [hl]`: discourage when a better move is known + end + return score + end }, +} + +-- vanilla registrations, kept beside the other Builtins delegations +function TrainerAI.registerInto(registry, _, owner) + for id, record in pairs(TrainerAI.LAYERS) do + registry:register(id, record, owner) + end +end + function TrainerAI.chooseMove(battler, rng, battle) rng = rng or love.math.random local usable = {} @@ -179,40 +235,36 @@ function TrainerAI.chooseMove(battler, rng, battle) return usable[rng(1, #usable)] end + -- aiMods entries may name registered ai_classes layer records; a number n + -- resolves through "LAYER_", which is how the vanilla three are keyed. + -- A battle built without a loader has no merged registry, so the built-in + -- records answer directly. + local classes = battle.data and battle.data.ai_classes + local layers, view = {}, nil + for _, mod in ipairs(mods) do + local id = type(mod) == "string" and mod or ("LAYER_" .. tostring(mod)) + local record = classes and classes[id] + if not (record and record.score) then record = TrainerAI.LAYERS[id] end + if record and record.score then + layers[#layers + 1] = record.score + view = view or { battle = battle, user = battler, target = battle.player, + data = battle.data, rng = rng, + encourageTurn = encourageTurn } + end + end + -- AIEnemyTrainerChooseMoves (engine/battle/trainer_ai.asm:3-257): every -- usable move starts at a base score of 10; the class's modification -- functions adjust it additively, then the MINIMUM-scored move is chosen -- with ties broken uniformly among the minima (core.asm:2971-3002 rolls a -- fresh byte among the value-1 slots). A non-minimal move is never -- selectable. - local target = battle.player local scores = {} for i, mv in ipairs(usable) do local def = battle.data.moves[mv.id] local s = 10 - for _, mod in ipairs(mods) do - if mod == 1 and def and target.mon.status - and def.power == 0 and STATUS_EFFECTS[def.effect] then - -- AIMoveChoiceModification1: `add $5` -- heavily discourage a - -- zero-power status move that would fail (player already statused) - s = s + 5 - elseif mod == 2 and def and encourageTurn - and ENCOURAGE_EFFECTS[def.effect] then - -- AIMoveChoiceModification2: `dec [hl]` -- slightly encourage - s = s - 1 - elseif mod == 3 and def then - -- AIMoveChoiceModification3 via AIGetTypeEffectiveness only reads - -- the FIRST matching TypeEffects row for (move type vs either - -- defender type) -- no dual-type product -- and runs for - -- non-damaging moves too. The table holds no value-10 rows, so - -- >10 / <10 reproduces the oracle's compare against $10. - local row = TypeChart.rows(def.type, target.curTypes)[1] - if row and row > 10 then - s = s - 1 -- `dec [hl]`: encourage a super-effective move - elseif row and row < 10 and hasBetterMove(battler, def, battle) then - s = s + 1 -- `inc [hl]`: discourage when a better move is known - end - end + for _, score in ipairs(layers) do + s = score(view, def, s) or s end scores[i] = s end diff --git a/src/battle/TurnOrder.lua b/src/battle/TurnOrder.lua index d1aebf39..499093e8 100644 --- a/src/battle/TurnOrder.lua +++ b/src/battle/TurnOrder.lua @@ -1,31 +1,46 @@ -- Turn order, from engine/battle/core.asm MainInBattleLoop: compare --- effective speed; ties are a coin flip. QUICK_ATTACK moves first and --- COUNTER last (Gen 1 has only these two priority moves, checked by id). +-- effective speed; ties are a coin flip. Move priority reads the move +-- record's priority field; the id table below covers pre-existing +-- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last). +local Damage = require("src.battle.Damage") local Stats = require("src.pokemon.Stats") +local Status = require("src.battle.Status") local TurnOrder = {} local function effectiveSpeed(battler) local spd = Stats.applyStage(battler.curStats.speed, battler.stages and battler.stages.speed or 0) - -- ApplyBadgeStatBoosts: the SOULBADGE (bit 4) boosts speed - if battler.badges and battler.badges.SOULBADGE then - spd = math.floor(spd * 9 / 8) + -- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from + -- the battler's merged badgeBoosts with the vanilla list as fallback + local badges = battler.badges + if badges then + for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do + if row.stat == "speed" and badges[row.badge] then + spd = math.floor(spd * (row.num or 9) / (row.den or 8)) + break + end + end end - -- paralysis quarters speed; hazeStatReset suppresses it because Haze - -- (haze.asm ResetStats) copied the unmodified speed over the quartered - -- battle stat, lifting the penalty until the next stat recompute. - if battler.mon.status == "PAR" and not battler.hazeStatReset then - spd = math.max(1, math.floor(spd / 4)) + -- paralysis quarters speed (the status record's statPenalty); + -- hazeStatReset suppresses it because Haze (haze.asm ResetStats) + -- copied the unmodified speed over the quartered battle stat, lifting + -- the penalty until the next stat recompute. + local record = Status.recordFor(battler.statuses, battler.mon.status) + local penalty = record and record.statPenalty + if penalty and penalty.stat == "speed" and not battler.hazeStatReset then + spd = math.max(1, math.floor(spd / penalty.div)) end return spd end -local function priority(moveId) - if moveId == "QUICK_ATTACK" then return 1 end - if moveId == "COUNTER" then return -1 end - return 0 +local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 } + +local function priority(move) + if not move then return 0 end + if move.priority then return move.priority end + return PRIORITY[move.id] or 0 end -- Returns true when battler a moves before battler b. invertTie flips @@ -34,7 +49,7 @@ end -- who moves first. function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie) rng = rng or love.math.random - local pa, pb = priority(aMove and aMove.id), priority(bMove and bMove.id) + local pa, pb = priority(aMove), priority(bMove) if pa ~= pb then return pa > pb end local sa, sb = effectiveSpeed(a), effectiveSpeed(b) if sa ~= sb then return sa > sb end diff --git a/src/battle/TypeChart.lua b/src/battle/TypeChart.lua index 5899df01..ee85e067 100644 --- a/src/battle/TypeChart.lua +++ b/src/battle/TypeChart.lua @@ -6,6 +6,7 @@ local TypeChart = {} local index -- [atk][def] -> x10 multiplier local matchups -- ROM-ordered TypeEffects rows +local types -- merged type records (physical/special category, display name) function TypeChart.load(data) index = {} @@ -14,6 +15,21 @@ function TypeChart.load(data) index[m.attacker] = index[m.attacker] or {} index[m.attacker][m.defender] = m.multiplier end + types = data.type_chart.types +end + +-- the merged type record's category; falls back to the vanilla records +-- so pure-module callers need no load +function TypeChart.category(typeId) + local record = types and types[typeId] or TypeChart.TYPES[typeId] + return record and record.category or nil +end + +-- display name for the move-select TYPE/ box (mod types render their +-- name instead of their raw id) +function TypeChart.displayName(typeId) + local record = types and types[typeId] or TypeChart.TYPES[typeId] + return record and record.name or typeId end -- The x10 multipliers of every TypeEffects row that applies, in ROM @@ -52,4 +68,39 @@ function TypeChart.effectiveness(moveType, defenderTypes) return mult end +-- Gen 1 splits physical from special by TYPE, not by move: the seven types +-- from FIRE up are special (engine/battle/effect_commands.asm compares the +-- type id against SPECIAL). The list Damage.isSpecial carries is the same +-- one, restated here as the type records the type_chart registry serves. +TypeChart.TYPES = { + NORMAL = { name = "NORMAL", category = "physical" }, + FIGHTING = { name = "FIGHTING", category = "physical" }, + FLYING = { name = "FLYING", category = "physical" }, + POISON = { name = "POISON", category = "physical" }, + GROUND = { name = "GROUND", category = "physical" }, + ROCK = { name = "ROCK", category = "physical" }, + BUG = { name = "BUG", category = "physical" }, + GHOST = { name = "GHOST", category = "physical" }, + FIRE = { name = "FIRE", category = "special" }, + WATER = { name = "WATER", category = "special" }, + GRASS = { name = "GRASS", category = "special" }, + ELECTRIC = { name = "ELECTRIC", category = "special" }, + PSYCHIC_TYPE = { name = "PSYCHIC", category = "special" }, + ICE = { name = "ICE", category = "special" }, + DRAGON = { name = "DRAGON", category = "special" }, +} + +-- The matchup rows come from the generated chart, so a dataset with a +-- different table registers a different world without touching this file. +function TypeChart.registerInto(registry, data, owner) + for id, record in pairs(TypeChart.TYPES) do + registry:register(id, record, owner) + end + local chart = data and data.type_chart + for _, row in ipairs(chart and chart.matchups or {}) do + registry:register(row.attacker .. ">" .. row.defender, + { multiplier = row.multiplier }, owner) + end +end + return TypeChart diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index b6ea12a2..b4644da3 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -1,4 +1,5 @@ local bit = require("bit") +local Assets = require("src.render.Assets") local ChipAudio = {} @@ -48,6 +49,22 @@ local function loadBanks(data) return banks end +-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to +-- the ROM banks, so the 0x4000-window byte reader and every call/loop +-- target work unchanged. The ROM's own cached bank table is never touched +-- because bank 0 differs per def, and a blob that carries its own waves and +-- drums renders even where programs.bin is unreadable. +local function engineBanks(data, chip) + if not chip then return loadBanks(data) end + local banks = {} + local ok, romBanks = pcall(loadBanks, data) + if ok then + for bank, bytes in pairs(romBanks) do banks[bank] = bytes end + end + banks[0] = chip.blob + return banks +end + local function romByte(banks, bank, address) local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank)) local value = bytes:byte(address - 0x4000 + 1) @@ -497,6 +514,8 @@ function Channel:sample() if event.wave then local wave = self.engine.waves[ math.min(event.waveInstrument + 1, #self.engine.waves)] + -- a def-local program may omit its wave table entirely + if not wave then return 0 end local index = math.min(32, math.floor(phase * 32) + 1) return wave[index] * event.waveLevel * 0.55 end @@ -511,6 +530,9 @@ local Engine = {} Engine.__index = Engine function Engine:noiseInstrument(number) + -- a def-local drum wins over the ROM engine's table for that id + local custom = self.customDrums and self.customDrums[number] + if custom then return custom end local cached = self.noiseInstruments[number] if cached then return cached end @@ -571,20 +593,55 @@ local function readWaves(banks, audio, engineNumber) return waves end +-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own +-- units) or as the -1..1 samples readWaves produces; the synth wants the +-- latter +local function normalizeWaves(source) + local waves = {} + for index, values in ipairs(source) do + local nibbles = false + for _, value in ipairs(values) do + if value > 1 or value < -1 then nibbles = true break end + end + local wave = {} + for position, value in ipairs(values) do + wave[position] = nibbles and (value - 7.5) / 7.5 or value + end + waves[index] = wave + end + return waves +end + function Engine.new(data, header, options) options = options or {} - local banks = loadBanks(data) + local audio = data.audio or {} + -- shape dispatch: a def-local chip program supplies its own channels and + -- may supply its own waves/drums, falling back to a ROM engine's tables + local chip = header.chip + local banks = engineBanks(data, chip) + local engineNumber = chip and (chip.engine or 1) or header.engine + local waves + if chip and chip.waves then + waves = normalizeWaves(chip.waves) + elseif chip then + local ok, romWaves = pcall(readWaves, banks, audio, engineNumber) + waves = ok and romWaves or {} + else + waves = readWaves(banks, audio, engineNumber) + end local engine = setmetatable({ banks = banks, tempo = 0x100, pan = 0xFF, - waves = readWaves(banks, data.audio, header.engine), - noiseHeaders = data.audio.noiseHeaders - and data.audio.noiseHeaders[tostring(header.engine)] or {}, + waves = waves, + noiseHeaders = audio.noiseHeaders + and audio.noiseHeaders[tostring(engineNumber)] or {}, + customDrums = chip and chip.drums or nil, noiseInstruments = {}, channels = {}, }, Engine) - for _, spec in ipairs(headerChannels(banks, header)) do + for _, spec in ipairs(chip and chip.channels + or headerChannels(banks, header)) do local frameTicks = options.frameTicks local hardware = (spec.number - 1) % 4 + 1 if hardware == 4 then @@ -593,7 +650,7 @@ function Engine.new(data, header, options) frameTicks = 0x80 + options.cryLength end engine.channels[#engine.channels + 1] = Channel.new(engine, spec, { - bank = header.bank, + bank = chip and 0 or header.bank, sfx = options.sfx, allowLoops = options.allowLoops, frequencyOffset = options.frequencyOffset, @@ -663,14 +720,14 @@ local function fillMusic() end function ChipAudio.playMusic(data, header, allowLoops) - ChipAudio.stopMusic() + -- build before tearing down: a def that fails to compile (bad addresses, + -- unreadable blob) must leave the outgoing song sounding + local engine = Engine.new(data, header, { allowLoops = allowLoops }) local ok, source = pcall( love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT) if not ok then return nil, source end - currentMusic = { - source = source, - engine = Engine.new(data, header, { allowLoops = allowLoops }), - } + ChipAudio.stopMusic() + currentMusic = { source = source, engine = engine } fillMusic() source:play() return source @@ -700,6 +757,17 @@ function ChipAudio.stopMusic() currentMusic = nil end +-- hot reload: the next play re-reads programs.bin (a mod may have swapped +-- the file out from under the single-slot bank cache) +function ChipAudio.invalidate() + ChipAudio.stopMusic() + cachedProgramFile, cachedBanks = nil, nil +end + +-- a stale song must not keep sounding past the flush that replaced its +-- program (20 §2 cache contract, chip music row) +Assets.register(ChipAudio.invalidate) + local function renderEffect(data, header, options) if not header then return nil end options = options or {} @@ -796,10 +864,13 @@ function ChipAudio.newSfx(data, name, pitch, tempo, header) }) end -function ChipAudio.newCry(data, species) - local cry = data.audio.cries[species] +-- `resolved` is a {header|chip, pitch, length} def the caller already worked +-- out -- a derived cry borrowing another species' header with its own +-- modifiers, which no registry lookup under `species` could find +function ChipAudio.newCry(data, species, resolved) + local cry = resolved or (data.audio.cries and data.audio.cries[species]) if not cry then return nil end - return renderEffect(data, cry.header, { + return renderEffect(data, cry.chip and cry or cry.header, { frequencyOffset = cry.pitch, cryLength = cry.length, }) diff --git a/src/core/Data.lua b/src/core/Data.lua index 0eb88e03..67fb0c50 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -14,10 +14,109 @@ local MODULES = { -- Optional for compatibility with developer and stale caches. local OPTIONAL = { "audio", "palettes", "icons" } +-- The rules the engine still carries as literals. The constants registry +-- deep-merges over these, so a value has to exist before a mod can patch +-- it; each one is the number the engine hard-codes today, so seeding them +-- changes nothing on a mod-free boot. +local CONSTANT_DEFAULTS = { + bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua) + partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua) + boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua) + moveMax = 4, + levelCap = 100, + coinCap = 9999, -- MAX_COINS (src/ui/SlotMachine.lua) + -- move-slot repair when a scrub empties a mon (src/core/SaveData.lua); + -- a total conversion without TACKLE patches this to its own floor + fallbackMove = "TACKLE", + hmMoves = { "CUT", "FLY", "SURF", "STRENGTH", "FLASH" }, -- IsMoveHM + -- gym order (data/scripts/victories.lua); list position is the badge + -- number the trainer card draws + badges = { + { id = "BOULDERBADGE" }, { id = "CASCADEBADGE" }, { id = "THUNDERBADGE" }, + { id = "RAINBOWBADGE" }, { id = "SOULBADGE" }, { id = "MARSHBADGE" }, + { id = "VOLCANOBADGE" }, { id = "EARTHBADGE" }, + }, +} + +-- field.boot is the total-conversion override point for the new game; the +-- values match what SaveData.newGame and the Oak speech used to inline. +local BOOT_DEFAULTS = { + startMap = "PALLET_TOWN", startX = 5, startY = 6, startFacing = "down", + playerName = "RED", rivalName = "BLUE", + startMoney = 3000, + screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" }, +} + +local function copy(value) + if type(value) ~= "table" then return value end + local out = {} + for k, v in pairs(value) do out[k] = copy(v) end + return out +end + +-- Fills only what the cache is missing, so an importer that learns to +-- stamp one of these keys silently takes over from the engine. +function Data:seedDefaults() + local constants = self.constants + for key, value in pairs(CONSTANT_DEFAULTS) do + if constants[key] == nil then constants[key] = copy(value) end + end + -- derived, not literal: a dataset with a different roster gets the right + -- upper bound without 151 being written down anywhere + if constants.dexSize == nil then + local highest = 0 + for _, def in pairs(self.pokemon) do + if def.dex and def.dex > highest then highest = def.dex end + end + constants.dexSize = highest + end + if constants.dexDigits == nil then + constants.dexDigits = math.max(3, #tostring(constants.dexSize)) + end + local boot = self.field.boot + if boot == nil then + boot = {} + self.field.boot = boot + end + for key, value in pairs(BOOT_DEFAULTS) do + if boot[key] == nil then boot[key] = copy(value) end + end + -- the naming screen presets the importer already extracts but nothing + -- ever read (field.presetNames) + if boot.namePresets == nil then + local presets = self.field.presetNames or {} + boot.namePresets = { + player = copy(presets.player) or { "RED", "ASH", "JACK" }, + rival = copy(presets.rival) or { "BLUE", "GARY", "JOHN" }, + } + end + -- the overworld's Kanto literals, same fill-if-absent contract; required + -- here rather than at the top so core keeps out of src/world at load time + require("src.world.FieldDefaults").seed(self) +end + +-- POKEPORT_DATA_DIR points a test runner at another dataset root (the +-- ROM-free fixture set, tests/fixture_data); unset -- every shipped build +-- -- the generated modules load exactly as before. loadfile skips the +-- require cache, so each overridden load hands back fresh tables. +local function loadModule(dir, name) + if dir then + local chunk, err = loadfile(dir .. "/" .. name .. ".lua") + if not chunk then return false, err end + return pcall(chunk) + end + return pcall(require, "data.generated." .. name) +end + function Data:load() + local dir = os.getenv("POKEPORT_DATA_DIR") for _, name in ipairs(MODULES) do - local ok, mod = pcall(require, "data.generated." .. name) + local ok, mod = loadModule(dir, name) if not ok then + if dir then + error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)") + :format(dir, name, mod)) + end error(("missing generated data module 'data/generated/%s.lua'.\n" .. "Import the ROM again or rebuild developer data.\n(%s)") :format(name, mod)) @@ -25,18 +124,58 @@ function Data:load() self[name] = mod end for _, name in ipairs(OPTIONAL) do - local ok, mod = pcall(require, "data.generated." .. name) + local ok, mod = loadModule(dir, name) self[name] = ok and mod or nil if not ok then Logger.warn("optional data module '%s' missing (feature disabled)", name) end end + -- before the mod loader runs: the deep registries fold over these + self:seedDefaults() + -- the top-level keys a pristine load leaves behind, so reloadGenerated can + -- strip whatever a mod merge added since; kept on self (assigned before the + -- scan so it counts itself) because tests load other tables through this + -- method, and a shared upvalue would let them clobber the singleton's set + local pristine = {} + self._pristineKeys = pristine + for key in pairs(self) do pristine[key] = true end Logger.info("generated data loaded (%d maps, %d species, %d moves)", (function() local n = 0 for _ in pairs(self.maps) do n = n + 1 end return n end)(), (function() local n = 0 for _ in pairs(self.pokemon) do n = n + 1 end return n end)(), (function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)()) end +-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the +-- mod merge created, then re-require the generated modules so base records +-- return to their on-disk values even where a mod edited them in place +function Data:reloadGenerated() + local pristine = self._pristineKeys + if pristine then + for key in pairs(self) do + if not pristine[key] then self[key] = nil end + end + end + for _, name in ipairs(MODULES) do + package.loaded["data.generated." .. name] = nil + end + for _, name in ipairs(OPTIONAL) do + package.loaded["data.generated." .. name] = nil + end + self:load() +end + +-- Resolve a dotted target path, creating empty tables on the way. Only the +-- mod merge calls this; a vanilla boot never does, so an unmodded Data +-- table is byte-identical to a pre-registry-v2 one. +function Data.ensure(data, path) + local node = data + for key in path:gmatch("[^%.]+") do + if node[key] == nil then node[key] = {} end + node = node[key] + end + return node +end + -- Resolve a TEXT_* constant on a map to a plain string (or nil if the text -- needs a hand-ported script; see data/scripts/). function Data:resolveText(mapLabel, textConst) diff --git a/src/core/Game.lua b/src/core/Game.lua index 2ddf38b1..c2aa40dd 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -10,9 +10,22 @@ local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local TouchInput = require("src.core.TouchInput") local ModLoader = require("src.mods.Loader") +local ModRuntime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") local Game = {} +-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev +-- module unloaded, so a player boot never touches a byte of dev code +local devMode = os.getenv("POKEPORT_DEV") == "1" + +-- the boot screen ids (field.boot.screens); a plain function so the +-- headless harness can borrow makeTitleState onto a stub game +local function bootScreens(game) + local boot = game.data and game.data.field and game.data.field.boot + return (boot and boot.screens) or {} +end + function Game:load() self.data = Data Data:load() @@ -35,11 +48,17 @@ function Game:load() Renderer:init() require("src.render.Font").load(Data) + -- menu cursor/border/geometry constants; field.theme restyles them + require("src.ui.Theme").load(Data) self.stack = StateStack StateStack:init() - self.save = SaveData.newGame() + self.save = SaveData.newGame(self:bootConfig()) + -- seed=true keeps what entry chunks wrote through mod.save before any + -- save existed; the skeleton fires save.created exactly once + self:adoptSave(self.save, true) + ModRuntime.emit("save.created", { save = self.save }) -- apply the persisted audio + display options before anything plays self:applyOptions(self.save.options) @@ -49,6 +68,10 @@ function Game:load() local OverworldState = require("src.world.OverworldController") self.overworld = OverworldState + -- every service is up but nothing is on the stack yet; this payload is + -- the sanctioned way for a mod to obtain the Game object + ModRuntime.emit("game.ready", { game = self }) + -- boot into the title screen (engine/movie/title.asm); NEW GAME runs -- the Oak speech + naming, CONTINUE restores the save. The headless -- autopilot skips straight into the overworld. @@ -58,37 +81,48 @@ function Game:load() else local titleState = self:makeTitleState() -- the copyright splash + Nidorino-vs-Gengar attract movie plays - -- before the title (engine/movie/splash.asm + intro.asm) - local IntroMovie = require("src.ui.IntroMovie") - StateStack:push(IntroMovie.new(self, function() + -- before the title (engine/movie/splash.asm + intro.asm); the ids come + -- from field.boot.screens so a total conversion owns the whole boot + Screens.push(self, bootScreens(self).splash or "IntroMovie", function() StateStack:push(titleState) - end)) + end) end Logger.info("game loaded") end +-- the merged field.boot: spawn, names, money and the naming presets a +-- total conversion overrides. Threaded into SaveData so persistence stays +-- free of a Data dependency. +function Game:bootConfig() + return self.data and self.data.field and self.data.field.boot +end + -- the title screen with its NEW GAME / CONTINUE wiring; used at boot -- and by the START-menu QUIT confirmation function Game:makeTitleState() - local TitleState = require("src.ui.TitleState") local OverworldState = require("src.world.OverworldController") - return TitleState.new(self, { + local factory = Screens.get(self, bootScreens(self).title or "TitleState") + return factory.new(self, { onNewGame = function() while self.stack:top() do self.stack:pop() end -- New Game keeps the standalone options.lua preferences - self.save = SaveData.newGame() + self.save = SaveData.newGame(self:bootConfig()) + -- no bucket carry-over: mod state from an abandoned session must + -- not leak into a fresh slot; mods seed via save.created instead + self:adoptSave(self.save) + ModRuntime.emit("save.created", { save = self.save }) self:applyOptions(self.save.options) self.stack:push(OverworldState, self.save.player.map, self.save.player.x, self.save.player.y, self.save.player.facing) - local OakSpeech = require("src.ui.OakSpeech") - self.stack:push(OakSpeech.new(self, function() end)) + Screens.push(self, bootScreens(self).newGame or "OakSpeech", + function() end) end, onContinue = function() - local loaded = SaveData.load() + local loaded, recovered = SaveData.load() if loaded then - self:restoreSave(loaded) + self:restoreSave(loaded, recovered) end end, }) @@ -127,6 +161,10 @@ function Game:update(dt) require("src.render.Tilt").update(dt) end +-- render.zones' identity default: unhooked, the zone list reaches the blit +-- exactly as the owning state computed it +local function sameZones(_, zones) return zones end + function Game:draw() -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic @@ -146,6 +184,11 @@ function Game:draw() break end end + -- 14's render.zones: weather/lighting overlays and custom colorization + -- recolor or add zones before the blit + if ModRuntime.wantsHook("render.zones") then + zones = ModRuntime.call("render.zones", sameZones, self, zones) + end if worldBelow and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end @@ -172,17 +215,31 @@ function Game:keypressed(key) self.stack:top():onKeyPressed(key) return end + if devMode and key == "f5" then + require("src.dev.HotReload").run(self) + return + end + if devMode and key == "`" then + self.stack:push(require("src.dev.Console").new(self)) + return + end if key == "f10" then - local ManagerState = require("src.mods.ManagerState") - self.stack:push(ManagerState.new(self)) + -- toggle: the manager no longer swallows the keyboard, so a second + -- press reaches this branch and closes it instead of stacking another + local top = self.stack:top() + if top and top.screenId == "ManagerState" then + self.stack:pop() + else + Screens.push(self, "ManagerState") + end return end if key == "f1" then self:writeSave() return elseif key == "f2" then - local loaded = SaveData.load() - if loaded then self:restoreSave(loaded) end + local loaded, recovered = SaveData.load() + if loaded then self:restoreSave(loaded, recovered) end return elseif key == "-" then self:zoomStep(-1) @@ -228,6 +285,12 @@ function Game:keyreleased(key) end function Game:gamepadpressed(joystick, button) + -- BindingsMenu's pad capture rides the same top-state routing as keys + local top = self.stack and self.stack:top() + if top and top.onGamepadPressed then + top:onGamepadPressed(button) + return + end Input:gamepadpressed(joystick, button) end @@ -251,12 +314,35 @@ function Game:touchreleased(id, x, y) TouchInput:touchreleased(id, x, y) end +-- Point the loader's mod.save backing at this save's modData so per-mod +-- state persists with the slot. seedBuckets is boot-only: it keeps what +-- entry chunks wrote before any save existed, while NEW GAME and +-- CONTINUE replace the backing outright. +function Game:adoptSave(save, seedBuckets) + save.modData = save.modData or {} + local loader = self.mods + if not loader then return end + if seedBuckets then + for id, bucket in pairs(loader.modSave or {}) do + if save.modData[id] == nil then save.modData[id] = bucket end + end + end + loader.modSave = save.modData +end + -- Capture the live world state into the save table and persist it. -- Options are flushed to options.lua as part of SaveData.save. function Game:writeSave() if self.overworld and self.overworld.captureSave then self.overworld:captureSave(self.save) end + -- stamp here so the save.writing payload carries the exact meta the + -- file gets; mods snapshot runtime state into their namespace now + self.save.meta = SaveData.buildMeta( + self.modStatus and self.modStatus.loaded, self.save.meta) + if ModRuntime.wants("save.writing") then + ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta }) + end SaveData.save(self.save) end @@ -279,11 +365,25 @@ function Game:applyOptions(opts) require("src.render.GBCFX").applyOptions(opts) end -function Game:restoreSave(loaded) +function Game:restoreSave(loaded, recovered) + if ModRuntime.wants("save.loading") then + ModRuntime.emit("save.loading", { raw = loaded }) + end + -- mod chains replay before validation so a mod repairs its own data + -- instead of watching it get quarantined; core steps already ran in + -- SaveData.load and skip on the format guard + local activeMods = self.modStatus and self.modStatus.loaded + SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods) + local modsDiff = SaveData.modsDiff(loaded, activeMods) + local report = SaveData.validate(loaded, self.data) + report.recovered = recovered + report.modsDiff = modsDiff self.save = loaded + self:adoptSave(loaded) -- SaveData.load already attached the standalone options.lua table self:applyOptions(loaded.options) - -- saves from before OT/ID stamping: backfill with the player's + -- saves from before OT/ID stamping: backfill with the player's (after + -- the scrub, so every mon the stamp loop sees is known) local stamp = require("src.battle.BattleState").stampOT for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end for _, box in ipairs(loaded.boxes or {}) do @@ -293,6 +393,24 @@ function Game:restoreSave(loaded) while self.stack:top() do self.stack:pop() end self.stack:push(self.overworld, loaded.player.map, loaded.player.x, loaded.player.y, loaded.player.facing) + self.saveReport = report + if not SaveData.emptyReport(report) then + -- the report screen is a Screens id so mods (or the ui milestone) own + -- its looks; until one exists the log keeps a quarantine from being + -- silent + local ok = pcall(Screens.push, self, "QuarantineReport", report) + if not ok then + Logger.warn("load report: %d mons quarantined, %d items removed, %d maps remapped%s", + #report.lostMons, #report.lostItems, #report.remappedMaps, + recovered and (", recovered from " .. recovered) or "") + local notice = SaveData.modsDiffNotice(modsDiff, loaded.meta) + if notice then Logger.warn("%s", notice) end + end + end + if ModRuntime.wants("save.loaded") then + ModRuntime.emit("save.loaded", + { save = loaded, meta = loaded.meta, modsDiff = modsDiff }) + end end return Game diff --git a/src/core/Music.lua b/src/core/Music.lua index ab220031..8a55269f 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -1,11 +1,14 @@ -- Music playback supports compact ROM channel programs synthesized live by --- ChipAudio and legacy pre-rendered WAV definitions. Songs with split WAVs --- chain def.file into def.loopFile in Music.update(). +-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The +-- branch is chosen per song definition, never by a global import flag, so a +-- file-backed song and a chip song coexist in one dataset. Songs with split +-- files chain def.file into def.loopFile in Music.update(). -- Map themes switch on map change; battles override with the battle -- theme and restore afterwards; riding the bike overrides outdoor map --- themes with Music_BikeRiding until dismount. +-- themes with the bike song until dismount. local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") local Music = {} @@ -37,8 +40,8 @@ local function applyFilter(src) end local state = { - enabled = true, current = nil, -- song label + chip = false, -- the playing song is a synthesized channel program source = nil, -- currently playing source loopSource = nil, -- pre-loaded loop body waiting for the intro to end mapSong = nil, -- song to restore after a battle @@ -48,6 +51,7 @@ local state = { fanfare = nil, -- fanfare SFX source; the song pauses while it plays fanfareResume = false, -- start/resume state.source when the fanfare ends fade = nil, -- active volume-ramp fade-out (see Music.fadeOut) + failed = {}, -- labels whose def could not be started; logged once } -- Is a fanfare SFX (Sound.lua's FANFARES) still sounding? @@ -64,7 +68,7 @@ end -- channels on the Game Boy, so the current song halts and resumes when -- the jingle ends (see update()). function Music.duckForFanfare(src) - if not state.enabled or not src then return end + if not src then return end state.fanfare = src if state.source then local ok, playing = pcall(state.source.isPlaying, state.source) @@ -78,6 +82,8 @@ end -- Overworld themes where the bike can be ridden (outdoor maps plus the -- caves/dungeons where gen-1 allows cycling). Indoor themes such as -- Pokecenter/Gym/SilphCo never get replaced by the bike theme. +-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback +-- for caches built before the importer wrote the table. local OUTDOOR = { Music_PalletTown = true, Music_Cities1 = true, @@ -97,10 +103,52 @@ local OUTDOOR = { Music_Dungeon3 = true, } +-- Scene themes the engine asks for by role rather than by label, so a total +-- conversion can rename every song. data.audio.special supersedes this. +local SPECIAL = { + heal = "Music_PkmnHealed", + title = "Music_TitleScreen", + credits = "Music_Credits", + hallOfFame = "Music_HallOfFame", + introBattle = "Music_IntroBattle", + oakRoute = "Music_Routes2", + bike = "Music_BikeRiding", + surf = "Music_Surfing", +} + +-- the label a scene role resolves to; call sites keep their own presence +-- guard on the resolved label +function Music.special(data, key) + local special = data and data.audio and data.audio.special + local label = special and special[key] + if label ~= nil then return label end + return SPECIAL[key] +end + +local function outdoorSongs(data) + return data and data.audio and data.audio.outdoorSongs or OUTDOOR +end + local function songDef(data, song) return data and data.audio and data.audio.songs and data.audio.songs[song] end +-- which mod put this label in the registry, for attributed failure logs +local function songOwner(data, song) + local owners = data and data.audio and data.audio._owners + local songs = owners and owners.songs + return songs and songs[song] or "base" +end + +-- one log line, plus an entry in the loader's error feed when a mod owns the +-- def, so the manager's errors screen can flag that mod +local function reportBadDef(data, song, err) + local who = songOwner(data, song) + Logger.warn("audio: bad song def %q (mod %s): %s", song, who, tostring(err)) + Runtime.reportError(who, + ("audio: bad song def %q: %s"):format(song, tostring(err))) +end + local function stopSource(src) if src then pcall(src.stop, src) end end @@ -108,50 +156,73 @@ end local function newSource(file) local ok, src = pcall(love.audio.newSource, file, "stream") if ok and src then return src end - Logger.warn("music: cannot load %s", tostring(file)) - return nil + return nil, ok and "no source" or tostring(src) end -function Music.play(data, song, loop) - if not state.enabled or not song or song == state.current then return end - if not love.audio then -- headless test stub - state.enabled = false +-- Build the new song's sources; the caller only tears the old song down +-- once this succeeded, so a broken def costs nothing but a log line. +-- Returns src, loopSrc, isChip -- or nil plus the reason. +local function startSong(data, def, wantLoop) + if def.chip or (def.address and def.bank) then + local ok, src = pcall( + require("src.core.ChipAudio").playMusic, data, def, wantLoop) + if ok and src then return src, nil, true end + return nil, nil, nil, ok and "no source" or tostring(src) + elseif def.file then + local src, err = newSource(def.file) + if not src then return nil, nil, nil, err end + -- a missing loop body degrades to the intro file alone + local loopSrc = def.loopFile and newSource(def.loopFile) or nil + return src, loopSrc, false + end + return nil, nil, nil, "no chip program and no file" +end + +-- the single choke point every song choice passes through, so one hook +-- covers map themes, battle themes, jingles and scene music +local function selectSong(song, ctx) + if not Runtime.wantsHook("music.select") then return song end + return Runtime.call("music.select", function(chosen) return chosen end, song, { + reason = ctx and ctx.reason or "direct", + mapId = ctx and ctx.mapId, + mapSong = state.mapSong, + onBike = state.onBike, + surfing = state.surfing, + kind = ctx and ctx.kind, + battleKind = ctx and ctx.kind, + trainerId = ctx and ctx.trainerId, + }) +end + +function Music.play(data, song, loop, ctx) + if not song then return end + if not love.audio then return end -- headless test stub + song = selectSong(song, ctx) + -- a hook may silence the cue outright, or swap in a label the dedupe + -- below has to compare against + if not song or song == state.current then return end + local def = songDef(data, song) + if not def or state.failed[song] then return end + local wantLoop = loop ~= false + local src, loopSrc, isChip, err = startSong(data, def, wantLoop) + if not src then + state.failed[song] = true + reportBadDef(data, song, err) return end - local def = songDef(data, song) - local runtime = data and data.audio and data.audio.runtime - if not def or (not runtime and not def.file) then return end stopSource(state.source) stopSource(state.loopSource) - if runtime then require("src.core.ChipAudio").stopMusic() end - state.source, state.loopSource, state.fade = nil, nil, nil - local wantLoop = loop ~= false - local src - if runtime then - local ok, generated = pcall( - require("src.core.ChipAudio").playMusic, data, def, wantLoop) - if ok then src = generated end - else - src = newSource(def.file) - end - if not src then - state.enabled = false - state.current = nil - return - end - if not runtime and def.loopFile then - -- intro file plays once, then update() chains to the loop body + -- a chip song holds the streaming source; ChipAudio.playMusic already + -- swapped it when the new song is chip-backed too + if state.chip and not isChip then require("src.core.ChipAudio").stopMusic() end + state.fade = nil + if loopSrc then + -- intro plays once, then update() chains to the loop body -- (for one-shot jingles the body plays once and doesn't repeat) pcall(src.setLooping, src, false) - local loopSrc = newSource(def.loopFile) - if loopSrc then - pcall(loopSrc.setLooping, loopSrc, wantLoop) - applyVolume(loopSrc) - applyFilter(loopSrc) - state.loopSource = loopSrc - else - pcall(src.setLooping, src, wantLoop) -- degrade: intro file only - end + pcall(loopSrc.setLooping, loopSrc, wantLoop) + applyVolume(loopSrc) + applyFilter(loopSrc) else pcall(src.setLooping, src, wantLoop) end @@ -164,15 +235,34 @@ function Music.play(data, song, loop) else pcall(src.play, src) end - state.source = src + local previous = state.current + state.source, state.loopSource, state.chip = src, loopSrc, isChip state.current = song + if Runtime.wants("music.started") then + Runtime.emit("music.started", { + song = song, previous = previous, chip = isChip, + reason = ctx and ctx.reason or "direct", + }) + end end function Music.stop() + local previous = state.current stopSource(state.source) stopSource(state.loopSource) require("src.core.ChipAudio").stopMusic() state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil + state.chip = false + if previous and Runtime.wants("music.stopped") then + Runtime.emit("music.stopped", { song = previous }) + end +end + +-- hot reload: forget the failed defs and the playing label so the next cue +-- re-resolves against the freshly merged registries +function Music.reload() + state.failed = {} + Music.stop() end -- Ramp the current song's volume to silence, then stop it, mirroring the @@ -183,7 +273,6 @@ end -- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames -- to silence). Ticked once per frame from Music.update(). function Music.fadeOut(control) - if not state.enabled then return end if not state.source then Music.stop() return end control = math.max(1, control or 10) state.fade = { @@ -196,13 +285,14 @@ end -- the song a map should currently play, honoring the bike/surf overrides local function effectiveMapSong(data, song) - if state.onBike and song and OUTDOOR[song] - and songDef(data, "Music_BikeRiding") then - return "Music_BikeRiding" + if not song or not outdoorSongs(data)[song] then return song end + if state.onBike then + local bike = Music.special(data, "bike") + if bike and songDef(data, bike) then return bike end end - if state.surfing and song and OUTDOOR[song] - and songDef(data, "Music_Surfing") then - return "Music_Surfing" + if state.surfing then + local surf = Music.special(data, "surf") + if surf and songDef(data, surf) then return surf end end return song end @@ -216,20 +306,23 @@ function Music.playMap(data, mapId, onBike, surfing) state.onBike = not not onBike state.surfing = not not surfing local play = effectiveMapSong(data, song) - if play then Music.play(data, play) end + if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end end -- toggle the surf override mid-map (starting/ending a surf) function Music.setSurfing(data, surfing) state.surfing = not not surfing local play = effectiveMapSong(data, state.mapSong) - if play then Music.play(data, play) end + if play then Music.play(data, play, nil, { reason = "map" }) end end -- battle themes; kind = "wild"|"trainer"|"gym"|"final" -function Music.playBattle(data, kind) +function Music.playBattle(data, kind, trainerId) local b = data.audio and data.audio.battle - if b then Music.play(data, b[kind] or b.wild) end + if b then + Music.play(data, b[kind] or b.wild, nil, + { reason = "battle", kind = kind, trainerId = trainerId }) + end end -- victory theme (Music_DefeatedWildMon/Trainer/GymLeader): starts the @@ -237,12 +330,12 @@ end -- (each Defeated* song ends in `sound_loop 0, .mainloop`); the battle's -- finish() restores the map theme, like the overworld reload's -- PlayDefaultMusicFadeOutCurrent. Returns true if the theme started. -function Music.playVictory(data, kind) +function Music.playVictory(data, kind, trainerId) local b = data.audio and data.audio.battle local jingle = b and b[kind .. "Win"] - local def = jingle and songDef(data, jingle) - if def and (def.file or (data.audio and data.audio.runtime)) then - Music.play(data, jingle) + if jingle and songDef(data, jingle) then + Music.play(data, jingle, nil, + { reason = "victory", kind = kind, trainerId = trainerId }) return true end return false @@ -251,11 +344,8 @@ end -- one-shot jingle (PkmnHealed, Jigglypuff's song): the map theme -- resumes when it ends, via update() function Music.playOnce(data, song) - local def = songDef(data, song) - if not (def and (def.file or (data.audio and data.audio.runtime))) then - return false - end - Music.play(data, song, false) + if not songDef(data, song) then return false end + Music.play(data, song, false, { reason = "once" }) state.pendingRestore = true return true end @@ -274,7 +364,7 @@ function Music.restoreMap(data) state.current = nil state.pendingRestore = nil local play = effectiveMapSong(data, state.mapSong) - if play then Music.play(data, play) end + if play then Music.play(data, play, nil, { reason = "map" }) end end -- 0-7 music volume (0 mutes), applied to the playing song and the @@ -308,10 +398,7 @@ end -- call once per frame: chains a finished intro into its loop body and -- restores the map theme after a one-shot jingle function Music.update(data) - if data and data.audio and data.audio.runtime then - require("src.core.ChipAudio").update() - end - if not state.enabled then return end + if state.chip then require("src.core.ChipAudio").update() 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. @@ -344,7 +431,7 @@ function Music.update(data) end state.fanfareResume = false end - if data and data.audio and data.audio.runtime and not state.fanfare then + if state.chip and not state.fanfare then require("src.core.ChipAudio").ensureMusicPlaying() end if state.loopSource and sourceStopped(state.source) then diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index a341347c..ff3f6958 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -2,14 +2,30 @@ -- Options (audio, display, battle preferences) live in a separate -- options.lua so they survive New Game and aren't tied to a save slot. -- Both are plain Lua tables serialized as Lua source (deterministic --- key order). +-- key order) and read back through SaveSerializer's data-only parser, +-- so a save can never execute code. +-- +-- The load pipeline is read -> parse -> migrate -> validate/quarantine +-- -> restore; Game:restoreSave drives the last two phases with the +-- merged Data threaded in, because this module must not reach into +-- Data itself. local Logger = require("src.core.Logger") +local Version = require("src.core.Version") +local SaveSerializer = require("src.core.SaveSerializer") +local Runtime = require("src.mods.Runtime") +local Semver = require("src.mods.Semver") +local Boxes = require("src.pokemon.Boxes") +local Bag = require("src.inventory.Bag") local SaveData = {} local FILENAME = "save.lua" local OPTIONS_FILENAME = "options.lua" +-- one rolling backup plus the staged-write witness; load promotes either +-- when the main file is missing or fails to parse +local BACKUP_FILENAME = FILENAME .. ".bak" +local TMP_FILENAME = FILENAME .. ".tmp" -- Port + original Options menu defaults. Missing keys on load are filled -- from this table so old options.lua files stay compatible. @@ -47,153 +63,584 @@ function SaveData.mergeOptions(loaded) return opts end -local function serialize(v, indent) - indent = indent or 0 - local pad = string.rep(" ", indent) - local t = type(v) - if t == "number" or t == "boolean" then - return tostring(v) - elseif t == "string" then - return string.format("%q", v) - elseif t == "table" then - local keys = {} - for k in pairs(v) do table.insert(keys, k) end - table.sort(keys, function(a, b) - local ta, tb = type(a), type(b) - if ta ~= tb then return ta < tb end - return a < b - end) - if next(v) == nil then return "{}" end - local parts = {} - for _, k in ipairs(keys) do - local key - if type(k) == "string" and k:match("^[%a_][%w_]*$") then - key = k - else - key = "[" .. serialize(k) .. "]" - end - table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1)) - end - return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}" - end - error("cannot serialize " .. t) -end - function SaveData.encode(data) - return "return " .. serialize(data) .. "\n" + return SaveSerializer.encode(data) end function SaveData.decode(str) - local loader = loadstring or load - local chunk, err = loader(str, "@save.lua") - if not chunk then return nil, err end - local ok, data = pcall(chunk) - if not ok then return nil, data end - if type(data) ~= "table" then return nil, "save root must be a table" end - return data + return SaveSerializer.decode(str) end -function SaveData.saveOptions(opts) +local function readTable(fs, name) + if not fs.getInfo(name) then return nil, "no file: " .. name end + local body = fs.read(name) + if type(body) ~= "string" then return nil, "unreadable: " .. name end + return SaveSerializer.decode(body) +end + +-- the stub filesystem some headless harnesses inject has no remove; a +-- lingering tmp/bak there is harmless +local function remove(fs, name) + if fs.remove then fs.remove(name) end +end + +-- ------- options + +-- Both take an optional fs (write/getInfo/read) defaulting to +-- love.filesystem, so the mod loader's injected filesystem can carry the +-- options round-trip headless (no love global). +function SaveData.saveOptions(opts, fs) + fs = fs or love.filesystem opts = SaveData.mergeOptions(opts) - local ok, err = love.filesystem.write(OPTIONS_FILENAME, SaveData.encode(opts)) + -- modOptions is per-mod nested state: fold the on-disk sub-tree + -- underneath (newest value winning per key) so one caller's partial + -- write cannot clobber another mod's persisted keys. Every other + -- option stays on the shallow path. + local onDisk = readTable(fs, OPTIONS_FILENAME) + if onDisk and type(onDisk.modOptions) == "table" then + local merged = {} + for modId, bucket in pairs(onDisk.modOptions) do + merged[modId] = bucket + end + for modId, bucket in pairs(opts.modOptions or {}) do + if type(bucket) == "table" and type(merged[modId]) == "table" then + for k, v in pairs(bucket) do merged[modId][k] = v end + else + merged[modId] = bucket + end + end + opts.modOptions = merged + end + local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts)) if not ok then Logger.error("options save failed: %s", tostring(err)) end return ok and opts or nil end -function SaveData.loadOptions() - if not love.filesystem.getInfo(OPTIONS_FILENAME) then - return SaveData.defaultOptions() - end - local chunk, err = love.filesystem.load(OPTIONS_FILENAME) - if not chunk then - Logger.error("options load failed: %s", tostring(err)) - return SaveData.defaultOptions() - end - local ok, data = pcall(chunk) - if not ok or type(data) ~= "table" then - Logger.error("options load failed: %s", tostring(data)) +function SaveData.loadOptions(fs) + fs = fs or love.filesystem + local data, err = readTable(fs, OPTIONS_FILENAME) + if not data then + if fs.getInfo(OPTIONS_FILENAME) then + Logger.error("options load failed: %s", tostring(err)) + end return SaveData.defaultOptions() end return SaveData.mergeOptions(data) end --- Game progress only; options are written separately via saveOptions. --- If `data.options` is present it is also flushed to options.lua so an --- F1 / in-game save keeps the live settings in sync, then stripped from --- the game file. -function SaveData.save(data) - if data.options then - SaveData.saveOptions(data.options) - end - local gameOnly = {} - for k, v in pairs(data) do - if k ~= "options" then gameOnly[k] = v end - end - local ok, err = love.filesystem.write(FILENAME, SaveData.encode(gameOnly)) - if ok then - Logger.info("saved game") +-- ------- meta + +-- the version/engine/mod-set stamp every v2 save carries; mods is the +-- loaded list sorted by id and is the ground truth for the load-time +-- mod-set diff. A nil mods list keeps the previous stamp's set so a +-- headless writer (the save editor) never wipes it. +function SaveData.buildMeta(mods, previous) + local list + if mods ~= nil then + list = {} + for _, mod in ipairs(mods) do + list[#list + 1] = { id = mod.id, version = mod.version, api = mod.api } + end + table.sort(list, function(a, b) return a.id < b.id end) else - Logger.error("save failed: %s", tostring(err)) + list = (type(previous) == "table" and previous.mods) or {} end - return ok + return { + format = Version.saveFormat, + engine = Version.engine, + savedAt = os.time(), + mods = list, + } end -function SaveData.load() - if not love.filesystem.getInfo(FILENAME) then - return nil +-- {added, removed, changed} between the set that wrote the save +-- (meta.mods) and the active loaded set; all three empty on a vanilla +-- load under vanilla +function SaveData.modsDiff(save, activeMods) + local stored = {} + for _, entry in ipairs((save.meta and save.meta.mods) or {}) do + if type(entry) == "table" and entry.id then + stored[entry.id] = entry.version or "" + end end - local chunk, err = love.filesystem.load(FILENAME) - if not chunk then - Logger.error("load failed: %s", tostring(err)) - return nil + local diff = { added = {}, removed = {}, changed = {} } + for _, mod in ipairs(activeMods or {}) do + local was = stored[mod.id] + if was == nil then + diff.added[#diff.added + 1] = mod.id + elseif was ~= mod.version then + diff.changed[#diff.changed + 1] = { id = mod.id, from = was, to = mod.version } + end + stored[mod.id] = nil end - local ok, data = pcall(chunk) - if not ok then - Logger.error("load failed: %s", tostring(data)) - return nil + for id in pairs(stored) do diff.removed[#diff.removed + 1] = id end + table.sort(diff.added) + table.sort(diff.removed) + table.sort(diff.changed, function(a, b) return a.id < b.id end) + return diff +end + +-- one-line load notice for a non-empty diff ("This save was made with +-- 2 mods; 1 is no longer active"); nil when empty so a vanilla load +-- stays silent +function SaveData.modsDiffNotice(diff, meta) + if type(diff) ~= "table" then return nil end + local removed = #(diff.removed or {}) + local changed = #(diff.changed or {}) + local added = #(diff.added or {}) + if removed == 0 and changed == 0 and added == 0 then return nil end + local wrote = #((type(meta) == "table" and meta.mods) or {}) + local parts = {} + if removed > 0 then + parts[#parts + 1] = removed .. (removed == 1 and " is" or " are") .. " no longer active" end - -- saves from before the trainer ID existed: backfill once on load - -- (like the OT backfill for old saves) - if data.player and not data.player.id then - data.player.id = math.random(0, 65535) + if changed > 0 then + parts[#parts + 1] = changed .. " changed version" end - -- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object - -- was already hidden (Snorlax beaten) but the flag was never added, - -- and it can never be set again since the hidden object is - -- unreachable -- backfill it from the toggle so it isn't stuck forever - if data.objectToggles and data.flags then + if added > 0 then + parts[#parts + 1] = added .. " newly active" + end + return ("This save was made with %d mod%s; %s"):format( + wrote, wrote == 1 and "" or "s", table.concat(parts, ", ")) +end + +-- ------- migrations + +-- Ordered engine steps keyed on meta.format, each reproducing the inline +-- migration it replaced; a save already at the current format skips them +-- all. Mod chains (recorded by Loader from mod.migrations:add) replay +-- against the version stored in meta.mods, in semver order, before the +-- validation pass -- so a mod repairs its own data instead of watching +-- it get quarantined. +local coreMigrations = {} + +function SaveData.addCoreMigration(fromFormat, fn) + coreMigrations[#coreMigrations + 1] = + { from = fromFormat, seq = #coreMigrations + 1, fn = fn } +end + +local function storedVersion(save, modId) + for _, entry in ipairs((save.meta and save.meta.mods) or {}) do + if type(entry) == "table" and entry.id == modId then + return entry.version + end + end + return nil +end + +local function semverLt(a, b) + local order = Semver.compare(a, b) + return order ~= nil and order < 0 +end + +function SaveData.runMigrations(save, modChains, activeMods) + table.sort(coreMigrations, function(a, b) + if a.from ~= b.from then return a.from < b.from end + return a.seq < b.seq + end) + -- every step whose from-format the save has not passed yet runs, in + -- (from, registration) order; a save at the current format runs none + local fmt = (save.meta and save.meta.format) or 1 + for _, m in ipairs(coreMigrations) do + if m.from >= fmt then m.fn(save) end + end + -- a save that predates meta records an empty mod set: an old vanilla + -- save becomes a v2 vanilla save + save.meta = save.meta or { mods = {} } + save.meta.format = Version.saveFormat + for _, active in ipairs(activeMods or {}) do + local modSave = save.modData and save.modData[active.id] + local recorded = modChains and modChains[active.id] + if modSave and recorded then + local chain = {} + for _, m in ipairs(recorded) do chain[#chain + 1] = m end + table.sort(chain, function(a, b) return semverLt(a.since, b.since) end) + local stored = storedVersion(save, active.id) or "0.0.0" + for _, m in ipairs(chain) do + if semverLt(stored, m.since) and not semverLt(active.version, m.since) then + -- a throwing migration is skipped, not fatal: it would otherwise + -- re-raise on every load and lock the player out of the save + local ok, err = pcall(m.apply, modSave, save) + if not ok then + Logger.error("[%s] migration %s: %s -- skipped", + active.id, tostring(m.since), tostring(err)) + break + end + end + end + end + end + return save +end + +-- saves from before the trainer ID existed: backfill once on load +-- (like the OT backfill for old saves) +SaveData.addCoreMigration(1, function(save) + if save.player and not save.player.id then + save.player.id = math.random(0, 65535) + end +end) + +-- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object +-- was already hidden (Snorlax beaten) but the flag was never added, +-- and it can never be set again since the hidden object is +-- unreachable -- backfill it from the toggle so it isn't stuck forever +SaveData.addCoreMigration(1, function(save) + if save.objectToggles and save.flags then local snorlaxRoutes = { { map = "ROUTE_12", obj = "ROUTE12_SNORLAX", flag = "EVENT_BEAT_ROUTE12_SNORLAX" }, { map = "ROUTE_16", obj = "ROUTE16_SNORLAX", flag = "EVENT_BEAT_ROUTE16_SNORLAX" }, } for _, r in ipairs(snorlaxRoutes) do - local toggles = data.objectToggles[r.map] - if toggles and toggles[r.obj] == false and not data.flags[r.flag] then - data.flags[r.flag] = true + local toggles = save.objectToggles[r.map] + if toggles and toggles[r.obj] == false and not save.flags[r.flag] then + save.flags[r.flag] = true end end end - -- Migrate options that still live inside an old save.lua into the - -- standalone options file (once), then always prefer options.lua. - if type(data.options) == "table" and not love.filesystem.getInfo(OPTIONS_FILENAME) then +end) + +-- Migrate options that still live inside an old save.lua into the +-- standalone options file (once); load always re-attaches options.lua +-- afterwards either way +SaveData.addCoreMigration(1, function(save) + if type(save.options) == "table" + and not love.filesystem.getInfo(OPTIONS_FILENAME) then + SaveData.saveOptions(save.options) + end +end) + +-- settle the box shape (single `box` list -> 12 boxes) before the +-- validation pass walks it; Boxes keeps the lazy ensure for play paths +SaveData.addCoreMigration(1, function(save) + Boxes.ensure(save) +end) + +-- ------- write + +-- Game progress only; options are written separately via saveOptions. +-- If `data.options` is present it is also flushed to options.lua so an +-- F1 / in-game save keeps the live settings in sync, then stripped from +-- the game file. mods (when given) refreshes the meta stamp; the write +-- itself rolls the last good save into .bak and stages the new bytes as +-- a .tmp witness before the swap, so a crash mid-write is recoverable. +function SaveData.save(data, mods) + if data.options then SaveData.saveOptions(data.options) end - data.options = SaveData.loadOptions() - Logger.info("loaded save") - return data + if mods ~= nil or data.meta == nil then + data.meta = SaveData.buildMeta(mods, data.meta) + end + local gameOnly = {} + for k, v in pairs(data) do + if k ~= "options" then gameOnly[k] = v end + end + local encoded = SaveSerializer.encode(gameOnly) + local fs = love.filesystem + if fs.getInfo(FILENAME) then + local prev = fs.read(FILENAME) + if prev then fs.write(BACKUP_FILENAME, prev) end + end + local ok, err = fs.write(TMP_FILENAME, encoded) + if not ok then + Logger.error("save failed: %s", tostring(err)) + return false + end + -- love.filesystem has no atomic rename: remove + rewrite, with the + -- .tmp copy as the recovery witness in between + remove(fs, FILENAME) + ok, err = fs.write(FILENAME, encoded) + if not ok then + Logger.error("save failed: %s", tostring(err)) + return false + end + remove(fs, TMP_FILENAME) + Logger.info("saved game") + return true end -function SaveData.newGame() - return { +-- ------- read + +-- returns the parsed save plus "tmp"/"bak" when the main file was gone +-- or corrupt and a staged/backup copy was promoted; Game surfaces the +-- recovery on the load report +function SaveData.load() + local fs = love.filesystem + local data, err = readTable(fs, FILENAME) + local recovered + if not data then + local tmp = readTable(fs, TMP_FILENAME) + if tmp then + data, recovered = tmp, "tmp" + else + local bak = readTable(fs, BACKUP_FILENAME) + if bak then data, recovered = bak, "bak" end + end + if data then + Logger.warn("save.lua %s; recovered from %s copy", + fs.getInfo(FILENAME) and "corrupt" or "missing", recovered) + fs.write(FILENAME, SaveSerializer.encode(data)) + end + end + if not data then + if fs.getInfo(FILENAME) then + Logger.error("load failed: %s", tostring(err)) + end + return nil + end + SaveData.runMigrations(data) + data.options = SaveData.loadOptions() + Logger.info("loaded save") + return data, recovered +end + +-- ------- validation and quarantine + +local function known(tbl, id) + return id ~= nil and type(tbl) == "table" and tbl[id] ~= nil +end + +-- only out-of-range values move; a vanilla save passes through untouched +local function clamp(n, lo, hi, fallback) + if type(n) ~= "number" then return fallback end + if n < lo then return lo end + if n > hi then return hi end + return n +end + +local function ensureOrphaned(save) + if not save.orphaned then + save.orphaned = { mons = {}, items = {} } + end + save.orphaned.mons = save.orphaned.mons or {} + save.orphaned.items = save.orphaned.items or {} + return save.orphaned +end + +-- quarantined ids whose content reappeared (mod re-enabled) go home +-- again: mons through the PC deposit, items through the bag with the PC +-- as overflow +local function reclaim(save, data, report) + local orphaned = save.orphaned + if not orphaned then return end + for i = #(orphaned.mons or {}), 1, -1 do + local mon = orphaned.mons[i] + if type(mon) == "table" and known(data.pokemon, mon.species) then + table.remove(orphaned.mons, i) + local box = Boxes.deposit(save, mon) + if box then + report.restoredMons[#report.restoredMons + 1] = + { species = mon.species, box = box } + else + -- every box full: stays quarantined rather than vanishing + table.insert(orphaned.mons, i, mon) + end + end + end + for i = #(orphaned.items or {}), 1, -1 do + local entry = orphaned.items[i] + if type(entry) == "table" and known(data.items, entry.id) then + table.remove(orphaned.items, i) + if entry.from == "pcItems" or type(save.inventory) ~= "table" + or not Bag.add(save, entry.id, entry.count or 1) then + save.pcItems = save.pcItems or {} + save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1) + end + report.restoredItems[#report.restoredItems + 1] = + { id = entry.id, count = entry.count or 1 } + end + end +end + +-- mirrors Protocol.unpackMon's clamp discipline for the fields play +-- indexes; the level floor widens to 1 because a freshly caught level-1 +-- mon can legitimately sit in a save +local function scrubKnownMon(mon, data) + if type(mon.dvs) == "table" then + for stat, v in pairs(mon.dvs) do mon.dvs[stat] = clamp(v, 0, 15, 0) end + end + if type(mon.statExp) == "table" then + for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end + end + mon.level = clamp(mon.level, 1, 100, 1) + local moves = mon.moves + if type(moves) ~= "table" then return end + local hadMoves = #moves > 0 + for j = #moves, 1, -1 do + local slot = moves[j] + local id = type(slot) == "table" and slot.id or slot + if not known(data.moves, id) then table.remove(moves, j) end + end + while #moves > 4 do table.remove(moves) end + if hadMoves and #moves == 0 then + -- data-driven repair so a total conversion without TACKLE still heals + local fallback = (data.constants and data.constants.fallbackMove) or "TACKLE" + local def = data.moves and data.moves[fallback] + if def then + moves[1] = { id = fallback, pp = def.pp } + end + end +end + +local function scrubMonList(list, where, save, data, report) + if type(list) ~= "table" then return end + for i = #list, 1, -1 do + local mon = list[i] + if type(mon) ~= "table" or not known(data.pokemon, mon.species) then + table.remove(list, i) + ensureOrphaned(save) + save.orphaned.mons[#save.orphaned.mons + 1] = mon + report.lostMons[#report.lostMons + 1] = + { species = type(mon) == "table" and mon.species or nil, from = where } + else + scrubKnownMon(mon, data) + end + end +end + +local function scrubItemMap(map, where, save, data, report) + if type(map) ~= "table" then return end + for id, count in pairs(map) do + if not known(data.items, id) then + map[id] = nil + ensureOrphaned(save) + save.orphaned.items[#save.orphaned.items + 1] = + { id = id, count = count, from = where } + report.lostItems[#report.lostItems + 1] = + { id = id, count = count, from = where } + end + end +end + +local function scrubMaps(save, data, report) + local boot = (data.field and data.field.boot) or {} + local spawn = { map = boot.startMap or "PALLET_TOWN", + x = boot.startX or 5, y = boot.startY or 6 } + -- heal point first, so the player fallback below always lands somewhere + -- valid; boot's heal cell (threaded from field.boot) is the last resort + if save.lastHeal and not known(data.maps, save.lastHeal.map) then + local heal = boot.lastHeal or spawn + report.remappedMaps[#report.remappedMaps + 1] = + { id = save.lastHeal.map, to = heal.map, field = "lastHeal" } + save.lastHeal = { map = heal.map, x = heal.x, y = heal.y } + end + if save.player and not known(data.maps, save.player.map) then + local heal = save.lastHeal or spawn + report.remappedMaps[#report.remappedMaps + 1] = + { id = save.player.map, to = heal.map, field = "player" } + save.player.map, save.player.x, save.player.y = heal.map, heal.x, heal.y + end + if save.lastOutdoor and not known(data.maps, save.lastOutdoor.id) then + report.remappedMaps[#report.remappedMaps + 1] = + { id = save.lastOutdoor.id, field = "lastOutdoor" } + save.lastOutdoor = nil + end + if save.lastHeal and type(save.lastHeal.outdoor) == "table" + and not known(data.maps, save.lastHeal.outdoor.id) then + save.lastHeal.outdoor = nil + end +end + +-- Walks every content id the save references against the merged data and +-- quarantines unknowns instead of letting them nil-index later: mons move +-- to save.orphaned (the LOST box), items are removed with a report row, +-- locations fall back to the heal point. Reclaims quarantined content +-- whose id reappeared first. On a mod-free save every membership test +-- passes and the save comes back untouched. +function SaveData.validate(save, data) + local report = { lostMons = {}, lostItems = {}, remappedMaps = {}, + restoredMons = {}, restoredItems = {} } + reclaim(save, data, report) + scrubMonList(save.party, "party", save, data, report) + for b, box in ipairs(save.boxes or {}) do + scrubMonList(box, "box " .. b, save, data, report) + end + local daycare = save.daycare + if type(daycare) == "table" and type(daycare.mon) == "table" then + if not known(data.pokemon, daycare.mon.species) then + ensureOrphaned(save) + save.orphaned.mons[#save.orphaned.mons + 1] = daycare.mon + report.lostMons[#report.lostMons + 1] = + { species = daycare.mon.species, from = "daycare" } + daycare.mon = nil + else + scrubKnownMon(daycare.mon, data) + end + end + scrubItemMap(save.inventory, "inventory", save, data, report) + scrubItemMap(save.pcItems, "pcItems", save, data, report) + if type(save.bagOrder) == "table" then + for i = #save.bagOrder, 1, -1 do + if not known(data.items, save.bagOrder[i]) then + table.remove(save.bagOrder, i) + end + end + end + scrubMaps(save, data, report) + local dex = save.pokedex + if type(dex) == "table" then + for _, key in ipairs({ "seen", "owned" }) do + if type(dex[key]) == "table" then + for id in pairs(dex[key]) do + if not known(data.pokemon, id) then dex[key][id] = nil end + end + end + end + end + -- hall of fame rosters keep their shape: an unknown species blanks in + -- place so the team stays the size it won at, with the rest of the mon + -- (level etc.) intact for display + for _, entry in ipairs(save.hallOfFame or {}) do + if type(entry) == "table" then + for i = 1, #entry do + local mon = entry[i] + if type(mon) == "table" and mon.species ~= nil + and not known(data.pokemon, mon.species) then + mon.species = nil + end + end + end + end + -- an empty quarantine leaves no residue, so a vanilla save re-encodes + -- byte-identically + local orphaned = save.orphaned + if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 then + save.orphaned = nil + end + return report +end + +function SaveData.emptyReport(report) + -- a bare validate report (the save editor's probe) carries no modsDiff; + -- restoreSave attaches one so a version bump alone still surfaces + local diff = report.modsDiff + return #report.lostMons == 0 and #report.lostItems == 0 + and #report.remappedMaps == 0 and #report.restoredMons == 0 + and #report.restoredItems == 0 and not report.recovered + and (not diff or (#diff.added == 0 and #diff.removed == 0 and #diff.changed == 0)) +end + +-- ------- new game + +-- boot is Data.field.boot, threaded in by Game: this module must not reach +-- into Data itself. Every read falls back to the Red literal it replaced, +-- so an absent or partial config still produces the vanilla new game. +function SaveData.newGame(boot) + boot = type(boot) == "table" and boot or {} + local map = boot.startMap or "PALLET_TOWN" + local x, y = boot.startX or 5, boot.startY or 6 + local heal = boot.lastHeal or {} + local save = { + meta = { format = Version.saveFormat, mods = {} }, player = { - map = "PALLET_TOWN", - x = 5, - y = 6, - facing = "down", - name = "RED", - rival = "BLUE", + map = map, + x = x, + y = y, + facing = boot.startFacing or "down", + name = boot.playerName or "RED", + rival = boot.rivalName or "BLUE", -- 16-bit trainer ID rolled at new game (wPlayerID, filled from -- hRandomAdd in OakSpeech) id = math.random(0, 65535), @@ -202,16 +649,22 @@ function SaveData.newGame() inventory = {}, party = {}, box = {}, - money = 3000, + money = boot.startMoney or 3000, defeatedTrainers = {}, pokedex = { seen = {}, owned = {} }, - -- where blackouts and ESCAPE ROPE return to (updated by nurses) - lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 }, + -- where blackouts and ESCAPE ROPE return to (updated by nurses); + -- copied, never aliased, so a save never writes back into Data + lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y }, repelSteps = 0, + -- per-mod persistence (mod.save) lives under here, keyed by mod id + modData = {}, -- Live options from options.lua (or defaults); New Game keeps the -- player's audio/display/battle preferences. options = SaveData.loadOptions(), } + -- a total conversion reshapes the skeleton (spawn, party, money) + -- before anything reads it; unhooked this returns save unchanged + return Runtime.call("save.new_game", function(s) return s end, save) end return SaveData diff --git a/src/core/SaveSerializer.lua b/src/core/SaveSerializer.lua new file mode 100644 index 00000000..77ba482d --- /dev/null +++ b/src/core/SaveSerializer.lua @@ -0,0 +1,219 @@ +-- Save-file serialization: the deterministic Lua-source writer (moved +-- verbatim from SaveData so output stays byte-identical) and a +-- restricted-grammar reader that replaces load() on save bytes. The +-- writer is the grammar's specification -- literals, %q strings and keyed +-- tables only -- so a hand-tampered or malicious save fails to parse +-- instead of executing. + +local SaveSerializer = {} + +-- ------- writer + +local function serialize(v, indent) + indent = indent or 0 + local pad = string.rep(" ", indent) + local t = type(v) + if t == "number" or t == "boolean" then + return tostring(v) + elseif t == "string" then + return string.format("%q", v) + elseif t == "table" then + local keys = {} + for k in pairs(v) do table.insert(keys, k) end + table.sort(keys, function(a, b) + local ta, tb = type(a), type(b) + if ta ~= tb then return ta < tb end + return a < b + end) + if next(v) == nil then return "{}" end + local parts = {} + for _, k in ipairs(keys) do + local key + if type(k) == "string" and k:match("^[%a_][%w_]*$") then + key = k + else + key = "[" .. serialize(k) .. "]" + end + table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1)) + end + return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}" + end + error("cannot serialize " .. t) +end + +function SaveSerializer.encode(data) + return "return " .. serialize(data) .. "\n" +end + +-- ------- reader + +-- letter escapes %q has emitted across the Lua 5.x family; LuaJIT writes +-- control characters as \ddd decimal escapes, handled separately below +local ESCAPES = { + ['"'] = '"', ["\\"] = "\\", ["n"] = "\n", ["r"] = "\r", ["t"] = "\t", + ["a"] = "\a", ["b"] = "\b", ["f"] = "\f", ["v"] = "\v", + ["\n"] = "\n", ["\r"] = "\n", +} + +-- recursion cap: a crafted file nesting thousands of braces must fail +-- closed, not blow the interpreter stack +local MAX_DEPTH = 128 + +local function fail(state, why) + error(("parse error at byte %d: %s"):format(state.pos, why), 0) +end + +local function skip(state) + local _, last = state.src:find("^[ \t\r\n]*", state.pos) + state.pos = last + 1 +end + +local function peek(state) + return state.src:sub(state.pos, state.pos) +end + +local function readString(state) + local src = state.src + local out = {} + local i = state.pos + 1 + while true do + local c = src:sub(i, i) + if c == "" then + state.pos = i + fail(state, "unterminated string") + elseif c == '"' then + state.pos = i + 1 + return table.concat(out) + elseif c == "\\" then + local nxt = src:sub(i + 1, i + 1) + if nxt:match("%d") then + local digits = src:match("^%d%d?%d?", i + 1) + local code = tonumber(digits) + if code > 255 then + state.pos = i + fail(state, "escape out of range") + end + out[#out + 1] = string.char(code) + i = i + 1 + #digits + elseif ESCAPES[nxt] then + out[#out + 1] = ESCAPES[nxt] + i = i + 2 + else + state.pos = i + fail(state, "bad string escape") + end + else + out[#out + 1] = c + i = i + 1 + end + end +end + +-- a number runs to the next delimiter; tonumber is the judge of what the +-- writer's tostring could have produced ("0.1", "-2", "1e+300") +local function readNumber(state) + local token = state.src:match("^[^,%]}%s]+", state.pos) + local value = token and tonumber(token) + if value == nil then fail(state, "malformed number") end + state.pos = state.pos + #token + return value +end + +local function readIdent(state) + local ident = state.src:match("^[%a_][%w_]*", state.pos) + if not ident then fail(state, "expected name") end + state.pos = state.pos + #ident + return ident +end + +local readValue + +local function readTable(state) + state.depth = state.depth + 1 + if state.depth > MAX_DEPTH then fail(state, "table nesting too deep") end + state.pos = state.pos + 1 + local out = {} + skip(state) + if peek(state) == "}" then + state.pos = state.pos + 1 + state.depth = state.depth - 1 + return out + end + while true do + skip(state) + local key + local c = peek(state) + if c == "[" then + state.pos = state.pos + 1 + key = readValue(state) + skip(state) + if peek(state) ~= "]" then fail(state, "expected ]") end + state.pos = state.pos + 1 + elseif c:match("[%a_]") then + key = readIdent(state) + else + fail(state, "expected key") + end + skip(state) + if peek(state) ~= "=" then fail(state, "expected =") end + state.pos = state.pos + 1 + out[key] = readValue(state) + skip(state) + local sep = peek(state) + if sep == "," then + state.pos = state.pos + 1 + skip(state) + if peek(state) == "}" then + state.pos = state.pos + 1 + break + end + elseif sep == "}" then + state.pos = state.pos + 1 + break + else + fail(state, "expected , or }") + end + end + state.depth = state.depth - 1 + return out +end + +readValue = function(state) + skip(state) + local c = peek(state) + if c == '"' then + return readString(state) + elseif c == "{" then + return readTable(state) + elseif c:match("[%a_]") then + -- the only bare words in the grammar are the boolean literals + local word = readIdent(state) + if word == "true" then return true end + if word == "false" then return false end + state.pos = state.pos - #word + fail(state, "unexpected name '" .. word .. "'") + elseif c:match("[%-%d%.]") then + return readNumber(state) + end + fail(state, c == "" and "unexpected end of input" or "unexpected character") +end + +function SaveSerializer.decode(str) + if type(str) ~= "string" then return nil, "save must be a string" end + local state = { src = str, pos = 1, depth = 0 } + local ok, result = pcall(function() + skip(state) + local word = state.src:match("^[%a_][%w_]*", state.pos) + if word ~= "return" then fail(state, "expected return") end + state.pos = state.pos + #word + local value = readValue(state) + skip(state) + if state.pos <= #state.src then fail(state, "trailing content") end + return value + end) + if not ok then return nil, result end + if type(result) ~= "table" then return nil, "save root must be a table" end + return result +end + +return SaveSerializer diff --git a/src/core/Sound.lua b/src/core/Sound.lua index a5d6c7e8..328fa8a6 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -1,11 +1,17 @@ --- Sound effects and cries synthesized from compact ROM channel programs or --- loaded from legacy static audio definitions. Sources are cached; headless +-- Sound effects and cries synthesized from compact ROM channel programs, +-- from def-local chip programs (ChipAsm), or loaded from file definitions -- +-- the branch is chosen per definition, not by a global import flag. Sources +-- are cached; a definition that fails to load caches as `false` so it is +-- logged once and skipped, never disabling the rest of the audio. Headless -- use is a safe no-op. +local Assets = require("src.render.Assets") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") + local Sound = {} local cache = {} -local enabled = true -- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu), -- scaling the 0.8 base every source gets local BASE_VOLUME = 0.8 @@ -19,6 +25,9 @@ local volumeScale = 1 -- (engine/items/item_effects.asm). Music.lua pauses the current song -- while one of these plays and resumes it afterwards. Ordinary short -- SFX (menu beeps, hits, cries) stay overlaid. +-- data.audio.fanfares supersedes this; the copy stays as the fallback for +-- caches built before the importer wrote the table, and a def may claim the +-- behavior for itself with fanfare = true. local FANFARES = { Level_Up = true, Caught_Mon = true, @@ -30,20 +39,56 @@ local FANFARES = { Pokeflute = true, } -local function playPath(data, key, path, pitch, tempo) - if not enabled or not love.audio or not path then return nil end +-- which mod put this key in the registry, for attributed failure logs +local function owner(data, kind, key) + local owners = data and data.audio and data.audio._owners + local map = owners and owners[kind] + return map and map[key] or "base" +end + +-- one log line, plus an entry in the loader's error feed when a mod owns the +-- def, so the manager's errors screen can flag that mod +local function reportBadDef(kind, key, who, err) + Logger.warn("audio: bad %s def %q (mod %s): %s", kind, key, who, tostring(err)) + Runtime.reportError(who, + ("audio: bad %s def %q: %s"):format(kind, key, tostring(err))) +end + +local function isChipDef(def) + return type(def) == "table" and (def.chip ~= nil or def.address ~= nil) +end + +-- a file def carries an optional playback rate; a bare string is shorthand +-- for { file = } +local function newFileSource(def) + local file = type(def) == "table" and def.file or def + if type(file) ~= "string" then return nil, "no chip program and no file" end + local ok, s = pcall(love.audio.newSource, file, "static") + if not ok or not s then return nil, ok and "no source" or tostring(s) end + if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end + return s +end + +local function newSfxSource(data, key, def, pitch, tempo) + if isChipDef(def) then + local ok, s = pcall(require("src.core.ChipAudio").newSfx, + data, key:match("^([^@]+)") or key, pitch, tempo, def) + if not ok then return nil, tostring(s) end + if not s then return nil, "no source" end + return s + end + return newFileSource(def) +end + +local function playPath(data, key, def, pitch, tempo) + if not love.audio or not def then return nil end local src = cache[key] + if src == false then return nil end -- known bad, already logged if not src then - local ok, s - if data.audio and data.audio.runtime and type(path) == "table" then - ok, s = pcall( - require("src.core.ChipAudio").newSfx, - data, key:match("^([^@]+)") or key, pitch, tempo, path) - else - ok, s = pcall(love.audio.newSource, path, "static") - end - if not ok or not s then - enabled = false + local s, err = newSfxSource(data, key, def, pitch, tempo) + if not s then + cache[key] = false + reportBadDef("sfx", key, owner(data, "sfx", key), err) return nil end s:setVolume(BASE_VOLUME * volumeScale) @@ -55,12 +100,26 @@ local function playPath(data, key, path, pitch, tempo) return src end +local function ducks(data, name, def) + if type(def) == "table" and def.fanfare then return true end + local fanfares = data.audio and data.audio.fanfares or FANFARES + return fanfares[name] and true or false +end + +local function played(kind, name, species) + if not Runtime.wants("sound.played") then return end + Runtime.emit("sound.played", { kind = kind, name = name, species = species }) +end + function Sound.play(data, name) local sfx = data.audio and data.audio.sfx - local src = playPath(data, name, sfx and sfx[name]) - if src and FANFARES[name] then + local def = sfx and sfx[name] + local src = playPath(data, name, def) + if not src then return end + if ducks(data, name, def) then require("src.core.Music").duckForFanfare(src) end + played("sfx", name) end -- Play a move's sound with its MoveSoundTable pitch/tempo modifiers @@ -78,42 +137,87 @@ function Sound.playMove(data, anim) if not sfx then return end local name = anim.sound local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80 - if data.audio.runtime and sfx[name] then - playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo), - sfx[name], pitch, tempo) + -- a chip program synthesizes the modified variant on demand; a file def + -- can only reach for a pre-rendered one + if isChipDef(sfx[name]) then + if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo), + sfx[name], pitch, tempo) then + played("move", name) + end return end if pitch ~= 0 or tempo ~= 0x80 then local key = ("%s@%02x%02x"):format(name, pitch, tempo) if sfx[key] then - playPath(data, key, sfx[key]) + if playPath(data, key, sfx[key]) then played("move", name) end return end end - playPath(data, name, sfx[name]) + if playPath(data, name, sfx[name]) then played("move", name) end end -function Sound.playCry(data, species) +-- A derived cry ({ base = "RHYDON", pitch, length }) borrows another +-- species' program and applies its own modifiers, so a new species needs no +-- assets at all. Chains are followed; the modifiers nearest the caller win. +local function resolveCry(data, def, depth) + if type(def) ~= "table" or not def.base then return def end + if depth > 8 then return nil, "cry base chain too deep" end local cries = data.audio and data.audio.cries - -- returns the source (nil headless) so callers that block on the cry - -- like the original's PlayCry -> WaitForSoundToFinish can poll it - local definition = cries and cries[species] - if data.audio and data.audio.runtime and definition then - local key = "cry:" .. tostring(species) - local src = cache[key] - if not src then - local ok, generated = pcall( - require("src.core.ChipAudio").newCry, data, species) - if not ok or not generated then return nil end - generated:setVolume(BASE_VOLUME * volumeScale) - cache[key] = generated - src = generated - end - src:stop() - src:play() - return src + local baseDef = cries and cries[def.base] + if not baseDef then + return nil, "unknown base cry " .. tostring(def.base) end - return playPath(data, "cry:" .. tostring(species), definition) + local resolved, err = resolveCry(data, baseDef, depth + 1) + if not resolved then return nil, err end + if type(resolved) ~= "table" or not (resolved.header or resolved.chip) then + return nil, "base cry " .. tostring(def.base) .. " is not a chip program" + end + return { + header = resolved.header, chip = resolved.chip, + pitch = def.pitch or resolved.pitch, + length = def.length or resolved.length, + } +end + +local function newCrySource(data, species, def) + local resolved, err = resolveCry(data, def, 0) + if not resolved then return nil, err end + if type(resolved) == "table" and (resolved.header or resolved.chip) then + local ok, s = pcall( + require("src.core.ChipAudio").newCry, data, species, resolved) + if not ok then return nil, tostring(s) end + if not s then return nil, "no source" end + return s + end + return newFileSource(resolved) +end + +-- returns the source (nil headless) so callers that block on the cry +-- like the original's PlayCry -> WaitForSoundToFinish can poll it +function Sound.playCry(data, species) + if not love.audio then return nil end + local cries = data.audio and data.audio.cries + local def = cries and cries[species] + if not def then return nil end + local key = "cry:" .. tostring(species) + local src = cache[key] + if src == false then return nil end + if not src then + local s, err = newCrySource(data, species, def) + if not s then + cache[key] = false + reportBadDef("cry", tostring(species), + owner(data, "cries", species), err) + return nil + end + s:setVolume(BASE_VOLUME * volumeScale) + cache[key] = s + src = s + end + src:stop() + src:play() + played("cry", species, species) + return src end -- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks @@ -159,25 +263,28 @@ local looping = {} function Sound.startLoop(data, name) if looping[name] then return end + if not love.audio then return end local sfx = data.audio and data.audio.sfx - local path = sfx and sfx[name] - local runtimeAlarm = data.audio and data.audio.runtime - and name == "Low_Health_Alarm" - if not enabled or not love.audio or (not path and not runtimeAlarm) then - return - end + local def = sfx and sfx[name] + local alarm = not def and name == "Low_Health_Alarm" + if not def and not alarm then return end local src = loopCache[name] + if src == false then return end if not src then - local ok, s - if data.audio.runtime and name == "Low_Health_Alarm" then - ok, s = pcall(require("src.core.ChipAudio").newLowHealthAlarm) - elseif data.audio.runtime and type(path) == "table" then - ok, s = pcall( - require("src.core.ChipAudio").newSfx, data, name) + local s, err + if alarm then + -- the synthesized siren is the default, not the rule: a registered + -- Low_Health_Alarm def of any shape replaces it + local ok, generated = pcall(require("src.core.ChipAudio").newLowHealthAlarm) + if ok then s = generated else err = tostring(generated) end else - ok, s = pcall(love.audio.newSource, path, "static") + s, err = newSfxSource(data, name, def) + end + if not s then + loopCache[name] = false + reportBadDef("sfx", name, owner(data, "sfx", name), err or "no source") + return end - if not ok then return end s:setLooping(true) s:setVolume(BASE_VOLUME * volumeScale) loopCache[name] = s @@ -206,13 +313,40 @@ end function Sound.setVolumeLevel(level) volumeScale = math.max(0, math.min(7, level or 7)) / 7 for _, src in pairs(cache) do - pcall(src.setVolume, src, BASE_VOLUME * volumeScale) + if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end end for _, src in pairs(loopCache) do - pcall(src.setVolume, src, BASE_VOLUME * volumeScale) + if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end end end +-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo +-- variants included) or all of them, so the next play re-resolves the def +function Sound.invalidate(name) + local function evict(store, key) + local src = store[key] + if src then pcall(src.stop, src) end + store[key] = nil + end + for _, store in ipairs({ cache, loopCache }) do + for key in pairs(store) do + if not name or key == name or key:sub(1, #name + 1) == name .. "@" then + evict(store, key) + end + end + end + for key, src in pairs(looping) do + if not name or key == name then + pcall(src.stop, src) + looping[key] = nil + end + end +end + +-- the flush fan-out calls with no key, dropping everything, so an edited +-- def is re-resolved on the next play (20 §2 cache contract, audio row) +Assets.register(Sound.invalidate) + -- re-apply persisted audio options (Game calls this on boot and after -- loading a save) function Sound.applyOptions(opts) diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index 321f213a..898fd3dc 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -2,20 +2,31 @@ -- (so a text box can overlay the overworld, a battle replaces it, etc). -- States are tables with optional enter/exit/update/draw/isOpaque. +local Runtime = require("src.mods.Runtime") + local StateStack = {} function StateStack:init() self.states = {} end +-- screen.pushed/popped fire after enter/exit so listeners observe the +-- settled state; the wants guard keeps the no-listener path allocation-free + function StateStack:push(state, ...) table.insert(self.states, state) if state.enter then state:enter(...) end + if Runtime.wants("screen.pushed") then + Runtime.emit("screen.pushed", { state = state }) + end end function StateStack:pop() local state = table.remove(self.states) if state and state.exit then state:exit() end + if state and Runtime.wants("screen.popped") then + Runtime.emit("screen.popped", { state = state }) + end return state end diff --git a/src/core/Version.lua b/src/core/Version.lua new file mode 100644 index 00000000..08e14f6b --- /dev/null +++ b/src/core/Version.lua @@ -0,0 +1,20 @@ +-- Single source of every compatibility-relevant number: engine release, +-- mod API major, link protocol, save format and ROM cache generation. Zero +-- requires so it loads during love.conf and under plain Lua for tools and +-- tests. + +local Version = { + engine = "1.0.0", -- game/engine release (semver triple) + modApi = 2, -- mod API major (manifest `api`) + linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) + saveFormat = 2, -- save.meta.format + cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) +} + +-- "Pokemon Red (Gen 1 Recompilation Project) v1.0.0" +function Version.title(base) + return (base or "Pokemon Red (Gen 1 Recompilation Project)") + .. " v" .. Version.engine +end + +return Version diff --git a/src/dev/Console.lua b/src/dev/Console.lua new file mode 100644 index 00000000..d4f92106 --- /dev/null +++ b/src/dev/Console.lua @@ -0,0 +1,398 @@ +-- Dev console overlay (POKEPORT_DEV=1, backtick): a Lua REPL with the live +-- game, data and mod list in scope, built-in verbs (warp / give / flag / +-- party / mods / reload) and an event/hook tracer driven off the Runtime +-- buses. It rides the state stack, so while it is open it owns the +-- keyboard (Game:keypressed routes to onKeyPressed) and the world below +-- does not update. Never required on a player boot. + +local Font = require("src.render.Font") + +local Console = {} +Console.__index = Console + +local ROWS = 15 -- scrollback rows drawn above the input line +local COLS = 19 -- 160px canvas minus the border +local HISTORY_MAX = 64 +local SCROLLBACK_MAX = 200 + +-- keypressed names -> characters; the console types from key events because +-- love.textinput never reaches Game. Shift reads the live keyboard. +local KEY_CHARS = { + space = " ", ["1"] = "1!", ["2"] = "2@", ["3"] = "3#", ["4"] = "4$", + ["5"] = "5%", ["6"] = "6^", ["7"] = "7&", ["8"] = "8*", ["9"] = "9(", + ["0"] = "0)", ["-"] = "-_", ["="] = "=+", ["["] = "[{", ["]"] = "]}", + ["\\"] = "\\|", [";"] = ";:", ["'"] = "'\"", [","] = ",<", ["."] = ".>", + ["/"] = "/?", +} + +local function shiftDown() + return love and love.keyboard and love.keyboard.isDown + and (love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift")) +end + +-- one-line pretty printer with a depth fuse, for expression results +local function pp(value, depth) + depth = depth or 0 + local kind = type(value) + if kind == "string" then return string.format("%q", value) end + if kind ~= "table" then return tostring(value) end + if depth >= 2 then return "{...}" end + local parts, n = {}, 0 + for k, v in pairs(value) do + n = n + 1 + if n > 8 then parts[#parts + 1] = "..." break end + parts[#parts + 1] = tostring(k) .. "=" .. pp(v, depth + 1) + end + return "{" .. table.concat(parts, ", ") .. "}" +end + +function Console.new(game) + local self = setmetatable({ + game = game, + buffer = "", + lines = {}, + history = {}, + historyIndex = 0, + scroll = 0, + }, Console) + self.env = setmetatable({ + game = game, + data = game.data, + mods = game.mods, + pp = pp, + }, { __index = _G }) + self:print("dev console -- `help` for verbs, ` to close") + return self +end + +function Console:print(text) + for line in (tostring(text) .. "\n"):gmatch("([^\n]*)\n") do + -- wrap to the canvas width so long payload dumps stay readable + repeat + self.lines[#self.lines + 1] = line:sub(1, COLS) + line = line:sub(COLS + 1) + until line == "" + end + while #self.lines > SCROLLBACK_MAX do table.remove(self.lines, 1) end + self.scroll = 0 +end + +-- ------- tracer + +-- glob -> anchored lua pattern ("battle.*" matches battle.turn etc.) +local function globPattern(glob) + local escaped = glob:gsub("[%^%$%(%)%%%.%[%]%+%-%?]", "%%%0") + return "^" .. escaped:gsub("%*", ".*") .. "$" +end + +-- The buses have no name catalog to subscribe against, so the tracer shims +-- the live instances: an instance field shadows the class method and +-- removing it restores the original. Runtime.wants is widened too, or +-- guarded call sites would skip payload construction for unwatched names. +function Console:startTrace(glob) + self:stopTrace() + local Runtime = require("src.mods.Runtime") + local loader = self.game.mods + local events = loader and loader.events + local hooks = loader and loader.hooks + if not (events and hooks) then + self:print("trace: no loader buses") + return + end + local pattern = globPattern(glob) + local function matches(name) + return type(name) == "string" and name:match(pattern) ~= nil + end + local console = self + local trace = { + glob = glob, events = events, hooks = hooks, + emit = events.emit, call = hooks.call, + wants = Runtime.wants, wantsHook = Runtime.wantsHook, + } + events.emit = function(bus, name, payload) + if matches(name) then + console:print("[event] " .. name .. " " .. pp(payload)) + end + return trace.emit(bus, name, payload) + end + hooks.call = function(bus, name, vanilla, ...) + if not matches(name) then return trace.call(bus, name, vanilla, ...) end + console:print("[hook] " .. name .. " in " .. pp({ ... })) + local result = { trace.call(bus, name, vanilla, ...) } + console:print("[hook] " .. name .. " out " .. pp(result)) + local unpack_ = table.unpack or unpack + return unpack_(result) + end + Runtime.wants = function(name) + return matches(name) or trace.wants(name) + end + Runtime.wantsHook = function(name) + return matches(name) or trace.wantsHook(name) + end + self.trace = trace + self:print(("tracing %s"):format(glob)) +end + +function Console:stopTrace() + local trace = self.trace + if not trace then return end + local Runtime = require("src.mods.Runtime") + -- clearing the instance fields re-exposes the class methods + trace.events.emit = nil + trace.hooks.call = nil + Runtime.wants = trace.wants + Runtime.wantsHook = trace.wantsHook + self.trace = nil +end + +-- ------- verbs + +local VERBS = {} + +function VERBS.help(self) + self:print("warp MAP [x y]") + self:print("give ID [n|level]") + self:print("flag NAME [on|off]") + self:print("party mods reload") + self:print("trace PAT | trace off") + self:print("anything else = lua") +end + +function VERBS.mods(self) + local status = self.game.modStatus + or (self.game.mods and self.game.mods:status()) + if not status then + self:print("no loader") + return + end + for _, mod in ipairs(status.available) do + self:print(("%s %s %s"):format(mod.id, mod.version or "?", mod.state)) + end + self:print(("%d errors"):format(#status.errors)) +end + +function VERBS.reload(self) + self:stopTrace() + local _, summary = require("src.dev.HotReload").run(self.game) + -- the reload swapped the buses and the env's loader reference with them + self.env.mods = self.game.mods + self.env.data = self.game.data + self:print(summary) +end + +function VERBS.warp(self, rest) + local mapId, x, y = rest:match("^(%S+)%s*(%d*)%s*(%d*)") + local game = self.game + if not mapId or not (game.data.maps and game.data.maps[mapId]) then + self:print("unknown map: " .. tostring(mapId)) + return + end + x, y = tonumber(x) or 5, tonumber(y) or 5 + -- the driver kit's teleport rebuild: everything (this console included) + -- pops and a fresh overworld enters at the target + while game.stack:top() do game.stack:pop() end + game.stack:push(require("src.world.OverworldController"), mapId, x, y, "down") +end + +function VERBS.give(self, rest) + local id, count = rest:match("^(%S+)%s*(%d*)") + local game = self.game + local save = game.save + if not id or id == "" then + self:print("give what?") + return + end + if game.data.pokemon and game.data.pokemon[id] then + local level = tonumber(count) or 5 + local mon = require("src.pokemon.Pokemon").new(game.data, id, level) + if require("src.pokemon.Party").add(save.party, mon) then + self:print(("%s L%d joined the party"):format(id, level)) + elseif require("src.pokemon.Boxes").deposit(save, mon) then + self:print(("%s L%d sent to the PC"):format(id, level)) + else + self:print("party and boxes full") + end + elseif game.data.items and game.data.items[id] then + local n = tonumber(count) or 1 + if require("src.inventory.Bag").add(save, id, n) then + self:print(("%s x%d added"):format(id, n)) + else + self:print("bag full") + end + else + self:print("unknown id: " .. id) + end +end + +function VERBS.flag(self, rest) + local name, value = rest:match("^(%S+)%s*(%S*)") + local flags = self.game.save and self.game.save.flags + if not name or not flags then + self:print("flag what?") + return + end + if value == "on" then + flags[name] = true + elseif value == "off" then + flags[name] = nil + end + self:print(("%s = %s"):format(name, tostring(flags[name] or false))) +end + +function VERBS.party(self) + local party = self.game.save and self.game.save.party or {} + if #party == 0 then + self:print("(empty)") + return + end + for i, mon in ipairs(party) do + self:print(("%d %s L%d %d/%d"):format(i, tostring(mon.species), + mon.level or 0, mon.hp or 0, (mon.stats and mon.stats.hp) or 0)) + end +end + +function VERBS.trace(self, rest) + local glob = rest:match("^(%S+)") + if not glob or glob == "off" then + self:stopTrace() + if glob then self:print("trace off") end + return + end + self:startTrace(glob) +end + +-- ------- repl + +function Console:exec(line) + self:print("> " .. line) + if line:match("^%s*$") then return end + self.history[#self.history + 1] = line + while #self.history > HISTORY_MAX do table.remove(self.history, 1) end + self.historyIndex = #self.history + 1 + local verb, rest = line:match("^(%S+)%s*(.*)$") + local handler = VERBS[verb] + if handler then + local ok, err = pcall(handler, self, rest or "") + if not ok then self:print("error: " .. tostring(err)) end + return + end + -- expression first so `1+1` prints 2; statements fall through + local chunk, err = loadstring("return " .. line, "=console") + if not chunk then + chunk, err = loadstring(line, "=console") + end + if not chunk then + self:print("error: " .. tostring(err)) + return + end + setfenv(chunk, self.env) + local results = { pcall(chunk) } + if not results[1] then + self:print("error: " .. tostring(results[2])) + return + end + if #results == 1 then return end + for i = 2, #results do + self:print(pp(results[i])) + end +end + +-- tab completion against the env (and one dotted level into it) +function Console:complete() + local prefix, partial = self.buffer:match("^(.-)([%w_%.]*)$") + local holder, field = partial:match("^([%w_]+)%.([%w_]*)$") + local scope, stem + if holder then + local ok, value = pcall(function() return self.env[holder] end) + if not ok or type(value) ~= "table" then return end + scope, stem = value, field + prefix = prefix .. holder .. "." + else + scope, stem = self.env, partial + end + local matches = {} + local seen = scope + while type(seen) == "table" do + for key in pairs(seen) do + if type(key) == "string" and key:sub(1, #stem) == stem then + matches[#matches + 1] = key + end + end + local meta = getmetatable(seen) + seen = meta and meta.__index + if type(seen) ~= "table" then break end + end + table.sort(matches) + if #matches == 1 then + self.buffer = prefix .. matches[1] + elseif #matches > 1 then + self:print(table.concat(matches, " ", 1, math.min(#matches, 12))) + end +end + +-- ------- input & drawing + +function Console:onKeyPressed(key) + if key == "`" then + self:stopTrace() + self.game.stack:pop() + elseif key == "return" or key == "kpenter" then + local line = self.buffer + self.buffer = "" + self:exec(line) + elseif key == "backspace" then + self.buffer = self.buffer:sub(1, -2) + elseif key == "tab" then + self:complete() + elseif key == "up" then + if self.historyIndex > 1 then + self.historyIndex = self.historyIndex - 1 + self.buffer = self.history[self.historyIndex] or "" + end + elseif key == "down" then + if self.historyIndex <= #self.history then + self.historyIndex = self.historyIndex + 1 + self.buffer = self.history[self.historyIndex] or "" + end + elseif key == "pageup" then + self.scroll = math.min(self.scroll + ROWS, + math.max(0, #self.lines - ROWS)) + elseif key == "pagedown" then + self.scroll = math.max(0, self.scroll - ROWS) + else + local chars = KEY_CHARS[key] + if chars then + local index = shiftDown() and 2 or 1 + self.buffer = self.buffer .. chars:sub(index, index) + elseif key:match("^%a$") then + self.buffer = self.buffer .. (shiftDown() and key:upper() or key) + elseif key:match("^kp%d$") then + self.buffer = self.buffer .. key:sub(3) + end + end +end + +function Console:update() end + +function Console:exit() + self:stopTrace() +end + +function Console:draw() + love.graphics.setColor(1, 1, 1, 0.92) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + local first = math.max(1, #self.lines - ROWS + 1 - self.scroll) + local row = 0 + for i = first, math.min(#self.lines, first + ROWS - 1) do + Font.draw(self.lines[i], 4, 2 + row * 9) + row = row + 1 + end + local input = "> " .. self.buffer + -- keep the tail visible while typing past the canvas edge + if #input > COLS then input = input:sub(#input - COLS + 1) end + Font.draw(input, 4, 134) + love.graphics.setColor(1, 1, 1, 1) +end + +return Console diff --git a/src/dev/HotReload.lua b/src/dev/HotReload.lua new file mode 100644 index 00000000..0a36309f --- /dev/null +++ b/src/dev/HotReload.lua @@ -0,0 +1,60 @@ +-- Dev-mode hot reload (POKEPORT_DEV=1, F5): restore pristine base data, +-- re-run the mod loader against the current mod files, re-merge, and flush +-- every cache registered on the Assets bus. Teardown is wholesale: the old +-- loader -- registries, event/hook subscriptions, exports -- is dropped and +-- a fresh one built, so nothing has to be un-registered piecemeal and the +-- boot-time freeze never needs an unfreeze. Only required from the dev +-- hotkey path and the console, never on a player boot. + +local HotReload = {} + +-- the overworld holds a built Map object; rebuild it in place so the +-- reloaded records are what the world reads from the next step on +local function reloadMap(game) + local ow = game.overworld + if not (ow and ow.map and ow.setMap and ow.player and game.stack) then return end + for _, state in ipairs(game.stack.states or {}) do + if state == ow then + ow:setMap(ow.map.id, ow.player.cellX, ow.player.cellY, + ow.player.facing, { via = "boot" }) + return + end + end +end + +-- opts.fs / opts.dev thread through to Loader.new for headless tests; the +-- in-game F5 path passes nothing and picks up love.filesystem +function HotReload.run(game, opts) + local Loader = require("src.mods.Loader") + local Assets = require("src.render.Assets") + local Runtime = require("src.mods.Runtime") + local Logger = require("src.core.Logger") + local data = game.data + if data and data.reloadGenerated then data:reloadGenerated() end + local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil) + loader.game = game + -- mod.save keeps pointing at the live slot across the reload + if game.save and game.save.modData then loader.modSave = game.save.modData end + local ok, err = pcall(loader.load, loader, data) + if not ok then + loader.errors[#loader.errors + 1] = tostring(err) + Logger.error("hot reload: %s", tostring(err)) + end + game.mods = loader + game.modStatus = loader:status() + -- the one invalidation entry point: every downstream cache registered + -- against Assets empties here (maps, tiles, sprites, pics, font, screens) + Assets.flush() + -- Theme binds Data outside the cache contract, so re-fold it by hand + local themeOk, Theme = pcall(require, "src.ui.Theme") + if themeOk and Theme.load then pcall(Theme.load, data) end + -- entry chunks just re-ran and re-subscribed; hand them the Game again + Runtime.emit("game.ready", { game = game }) + reloadMap(game) + local summary = ("reloaded %d mods (%d errors)") + :format(#loader.loaded, #loader.errors) + Logger.info("%s", summary) + return loader, summary +end + +return HotReload diff --git a/src/inventory/Badges.lua b/src/inventory/Badges.lua new file mode 100644 index 00000000..4fc4a525 --- /dev/null +++ b/src/inventory/Badges.lua @@ -0,0 +1,38 @@ +-- The badge set as data (constants.badges): an ordered list of +-- { id, name?, icon?, item? } records where list position is the badge +-- number. Every screen that used to carry its own copy of the gym order +-- reads it from here; the literal survives only as the fallback for caches +-- imported before the constant existed. + +local Badges = {} + +-- gym order (data/scripts/victories.lua) +local VANILLA = { + { id = "BOULDERBADGE" }, { id = "CASCADEBADGE" }, { id = "THUNDERBADGE" }, + { id = "RAINBOWBADGE" }, { id = "SOULBADGE" }, { id = "MARSHBADGE" }, + { id = "VOLCANOBADGE" }, { id = "EARTHBADGE" }, +} + +function Badges.list(data) + local list = data and data.constants and data.constants.badges + if type(list) == "table" and #list > 0 then return list end + return VANILLA +end + +-- badges are stored under an inventory key, which is the badge id unless +-- the record names a different item +function Badges.itemFor(entry) + return entry.item or entry.id +end + +function Badges.count(data, save) + local inventory = save and save.inventory + if not inventory then return 0 end + local n = 0 + for _, entry in ipairs(Badges.list(data)) do + if inventory[Badges.itemFor(entry)] then n = n + 1 end + end + return n +end + +return Badges diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua new file mode 100644 index 00000000..d9bed50c --- /dev/null +++ b/src/link/Fingerprint.lua @@ -0,0 +1,284 @@ +-- Deterministic digest of the link surface: the slice of merged data whose +-- value decides whether two lockstep simulations stay identical and whether a +-- traded mon is rebuilt the same way on both machines (D8). Peers whose +-- digests agree may battle; peers whose digests differ negotiate a trade +-- subset instead of desyncing three turns in. +-- +-- Everything is serialized through an explicit sorted key order. pairs() +-- order differs between two runs of the same build, so a digest that +-- inherited it would reject identical peers at random -- that is the whole +-- reason this file exists instead of a hash over tostring(data). +-- +-- Deliberately excluded: sprite paths and `source` (install-specific +-- generated paths that differ between two otherwise identical machines), +-- names, dex entries, learnsets and TM/HM lists (they change no battle math +-- and no trade rebuild). + +local Runtime = require("src.mods.Runtime") + +local Fingerprint = {} + +-- ------- FNV-1a, two lanes + +-- Two 32-bit lanes with different offset bases, concatenated into a 64-bit +-- hex digest. Pure arithmetic: the 32-bit product is split so every +-- intermediate stays inside a double's exact integer range, and the low-byte +-- xor runs off a nibble table -- LuaJIT has bit ops, plain 5.1 does not, and +-- tools load this file outside the game. +local PRIME = 16777619 +local LANE_A, LANE_B = 2166136261, 2654435769 + +local XOR4 = {} +for a = 0, 15 do + XOR4[a] = {} + for b = 0, 15 do + local x, y, r = a, b, 0 + for place = 0, 3 do + if x % 2 ~= y % 2 then r = r + 2 ^ place end + x, y = math.floor(x / 2), math.floor(y / 2) + end + XOR4[a][b] = r + end +end + +local function xor8(a, b) + return XOR4[math.floor(a / 16)][math.floor(b / 16)] * 16 + XOR4[a % 16][b % 16] +end + +local function step(h, byte) + local lo = h % 65536 + local hi = (h - lo) / 65536 + lo = lo - lo % 256 + xor8(lo % 256, byte) + return (lo * PRIME + (hi * PRIME % 65536) * 65536) % 4294967296 +end + +local function digest(text) + local a, b = LANE_A, LANE_B + for i = 1, #text do + local byte = text:byte(i) + a = step(a, byte) + b = step(b, byte) + end + return ("%08x%08x"):format(a, b) +end + +Fingerprint.digest = digest + +-- ------- canonical serialization + +-- %.17g is exact for every integer stat involved and is the same format the +-- wire encoder uses, so a value that survives JSON hashes the same +local function number(v) + return ("%.17g"):format(v) +end + +local writeValue + +-- tables are written array part first (order is meaning there: type chart +-- rows, evolution lists), then named keys in sorted order +writeValue = function(out, v) + local t = type(v) + if t == "number" then + out[#out + 1] = "#" .. number(v) + elseif t == "string" then + out[#out + 1] = "$" .. v + elseif t == "boolean" then + out[#out + 1] = v and "T" or "F" + elseif t == "table" then + out[#out + 1] = "(" + local n = #v + for i = 1, n do writeValue(out, v[i]) end + local keys = {} + for k in pairs(v) do + if not (type(k) == "number" and k >= 1 and k <= n and k % 1 == 0) then + keys[#keys + 1] = k + end + end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + for _, k in ipairs(keys) do + out[#out + 1] = "." .. tostring(k) + writeValue(out, v[k]) + end + out[#out + 1] = ")" + else + -- a handler's bytes are not portably hashable; mods bump the record's + -- rev instead, and the mod version is the backstop when they forget + out[#out + 1] = "?" + end +end + +-- an absent field is skipped identically on both sides, so a record that +-- never had the key and one whose mod removed it agree +local function writeFields(out, record, fields) + for _, field in ipairs(fields) do + local v = record[field] + if v ~= nil then + out[#out + 1] = "." .. field + writeValue(out, v) + end + end +end + +local function sortedIds(map) + local ids = {} + for id in pairs(map or {}) do ids[#ids + 1] = id end + table.sort(ids) + return ids +end + +-- ------- the link surface + +local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp", + "growthRate", "evolutions" } +local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category", + "priority", "highCrit", "fixedDamage", "multiHit", + "counterable", "semiInvulnerable" } +-- catchBonus/shakeBonus are this engine's names for the plan's catchModifier +local STATUS_FIELDS = { "rev", "catchBonus", "shakeBonus", "statPenalty", + "cureOnSwitch", "beforeMovePriority" } +local EFFECT_FIELDS = { "rev", "kind", "accuracyChecked" } +local CONSTANT_FIELDS = { "partyMax", "moveMax", "levelCap", "dexSize", + "badgeBoosts" } + +local RECORD_FIELDS = { pokemon = SPECIES_FIELDS, moves = MOVE_FIELDS, + statuses = STATUS_FIELDS, move_effects = EFFECT_FIELDS } + +Fingerprint.FIELDS = RECORD_FIELDS + +local function writeSection(out, data, kind) + local map = data[kind] + if map == nil then return end + local fields = RECORD_FIELDS[kind] + out[#out + 1] = "[" .. kind .. "]" + for _, id in ipairs(sortedIds(map)) do + local record = map[id] + if type(record) == "table" then + out[#out + 1] = "@" .. id + writeFields(out, record, fields) + end + end +end + +-- the chart rows are an ordered array whose order the merge rebuilds from +-- registration history, so they hash in place; the type records ride along +-- because `category` decides the physical/special split +local function writeTypeChart(out, data) + local chart = data.type_chart + if not chart then return end + out[#out + 1] = "[type_chart]" + for _, row in ipairs(chart.matchups or {}) do + out[#out + 1] = ("@%s>%s"):format(tostring(row.attacker), tostring(row.defender)) + writeValue(out, row.multiplier) + end + for _, id in ipairs(sortedIds(chart.types)) do + local record = chart.types[id] + if type(record) == "table" then + out[#out + 1] = "@" .. id + writeFields(out, record, { "category", "index" }) + end + end +end + +local function writeConstants(out, data) + if not data.constants then return end + out[#out + 1] = "[constants]" + writeFields(out, data.constants, CONSTANT_FIELDS) +end + +-- a mod that wants an extra mon field to force agreement declares it here; +-- only the author revision is hashable, the pack/unpack pair is not +local function writeLinkFields(out, data) + local fields = data.link_fields + if not fields then return end + out[#out + 1] = "[link_fields]" + for _, id in ipairs(sortedIds(fields)) do + local record = fields[id] + if type(record) == "table" then + out[#out + 1] = "@" .. id + writeFields(out, record, { "rev" }) + end + end +end + +-- id@version of every enabled mod that touches the link surface: the +-- backstop for a logic-only change whose author forgot to bump a rev +local function modKey(mods) + local parts = {} + for _, mod in ipairs(mods or {}) do + if mod.affectsLink ~= false then + parts[#parts + 1] = ("%s@%s"):format(tostring(mod.id), + tostring(mod.version or "?")) + end + end + table.sort(parts) + return table.concat(parts, ",") +end + +Fingerprint.modKey = modKey + +-- ------- public API + +-- memoized per merged-data identity: the digest is only ever asked for on +-- entry to link play, and vanilla single-player must not pay for it at all +local cache = setmetatable({}, { __mode = "k" }) + +local function surface(data, mods) + local out = {} + writeSection(out, data, "pokemon") + writeSection(out, data, "moves") + writeTypeChart(out, data) + writeSection(out, data, "statuses") + writeSection(out, data, "move_effects") + writeConstants(out, data) + writeLinkFields(out, data) + out[#out + 1] = "[mods]" .. modKey(mods) + return table.concat(out) +end + +Fingerprint.surface = surface + +-- mods: { { id, version, affectsLink } } -- the hello's mod array +function Fingerprint.compute(data, mods) + if not data then return digest("") end + local key = modKey(mods) + local hit = cache[data] + if hit and hit.key == key then return hit.value end + local value = Runtime.call("link.fingerprint", function(d, m) + return digest(surface(d, m)) + end, data, mods) + cache[data] = { key = key, value = value } + return value +end + +-- per-record digests over the same allowlist, so two peers can agree on +-- exactly which species and moves they rebuild identically +local recordCache = setmetatable({}, { __mode = "k" }) + +function Fingerprint.records(data, kind) + local fields = RECORD_FIELDS[kind] + assert(fields, "no record allowlist for " .. tostring(kind)) + local perData = recordCache[data] + if not perData then + perData = {} + recordCache[data] = perData + end + if perData[kind] then return perData[kind] end + local map = data[kind] or {} + local out = {} + for id, record in pairs(map) do + if type(record) == "table" then + local buf = { "@" .. id } + writeFields(buf, record, fields) + out[id] = digest(table.concat(buf)) + end + end + perData[kind] = out + return out +end + +function Fingerprint.forget(data) + cache[data] = nil + recordCache[data] = nil +end + +return Fingerprint diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua new file mode 100644 index 00000000..2cdad249 --- /dev/null +++ b/src/link/Handshake.lua @@ -0,0 +1,225 @@ +-- Handshake v2 (D8): the `hello` both peers exchange on pairing and the +-- compatibility verdict drawn from the two of them. +-- +-- v1 builds sent `{type="hello", name, mode}` and nothing else. Every field +-- here is additive, and a peer that omits `protocol` is by construction a +-- pre-mod build running unmodified content -- so a missing `protocol` reads +-- as "peer is vanilla" and the v1 code path is taken verbatim. That keeps +-- old installs byte-compatible instead of locking them out. + +local Fingerprint = require("src.link.Fingerprint") +local Schemas = require("src.mods.Schemas") +local Version = require("src.core.Version") + +local Handshake = {} + +Handshake.PROTOCOL = Version.linkProtocol or 2 + +-- writing into any of these changes what a lockstep turn or a rebuilt trade +-- mon looks like, which is what a v1 peer cannot know about us +local LINK_SURFACE = { + pokemon = true, moves = true, type_chart = true, statuses = true, + move_effects = true, balls = true, rulesets = true, constants = true, + link_fields = true, +} + +Handshake.LINK_SURFACE = LINK_SURFACE + +local function loader(game) + return game and game.mods or nil +end + +-- every enabled mod, sorted so both peers see one order. The whole set +-- rides the wire because the incompatibility screen diffs these arrays to +-- name what is missing; only the affects-link ones fold into the digest. +function Handshake.mods(game) + local mods = {} + local mod = loader(game) + if not mod or not mod.status then return mods end + local ok, status = pcall(mod.status, mod) + if not ok or not status then return mods end + for _, manifest in ipairs(status.loaded or {}) do + mods[#mods + 1] = { id = manifest.id, version = manifest.version, + affectsLink = manifest.affects_link ~= false } + end + table.sort(mods, function(a, b) return tostring(a.id) < tostring(b.id) end) + return mods +end + +-- cheap answer to "can I link with a peer that assumes vanilla?": true as +-- soon as one enabled mod either declares affects_link or has written a +-- record into a link-surface registry +function Handshake.linkModified(game) + local mod = loader(game) + if not mod then return false end + for _, entry in ipairs(Handshake.mods(game)) do + if entry.affectsLink then return true end + end + for name, registry in pairs(mod.content or {}) do + if LINK_SURFACE[name] then + for _, list in pairs(registry.ops or {}) do + for _, entry in ipairs(list) do + if entry.owner and entry.owner ~= Schemas.ENGINE then return true end + end + end + end + end + return false +end + +-- mode is nil on the guest: it pairs and announces itself before the host +-- has picked, and compatibility is decided from the two hellos, not the mode +function Handshake.hello(game, mode) + local mods = Handshake.mods(game) + return { + type = "hello", + protocol = Handshake.PROTOCOL, + name = game and game.save and game.save.player and game.save.player.name, + mode = mode, + engineVersion = Version.engine, + apiVersion = Version.modApi, + fingerprint = Fingerprint.compute(game and game.data, mods), + linkModified = Handshake.linkModified(game), + mods = mods, + } +end + +local function major(semver) + return tonumber(tostring(semver or ""):match("^(%d+)")) or 0 +end + +-- full identical link surfaces: nothing to negotiate, lockstep is safe +-- vanilla_peer an old build, and we are unmodified, so it is right about us +-- subset both v2 but the surfaces differ: negotiated trade, no battle +-- refused an old build we would silently corrupt, or a different engine +function Handshake.checkCompat(localHello, remoteHello) + localHello = localHello or {} + if not remoteHello or not remoteHello.protocol then + if localHello.linkModified then + return "refused", "peer_v1_modified" + end + return "vanilla_peer", nil + end + if major(remoteHello.engineVersion) ~= major(localHello.engineVersion) then + return "refused", "engine_mismatch" + end + if remoteHello.fingerprint == localHello.fingerprint then + return "full", nil + end + return "subset", "fingerprint_mismatch" +end + +-- only two v2 peers that agreed on a verdict may reject a mon outright; a v1 +-- peer keeps the old substitute-a-move behaviour it was built against +function Handshake.strict(verdict) + return verdict == "full" or verdict == "subset" +end + +function Handshake.battleAllowed(verdict) + return verdict == "full" or verdict == "vanilla_peer" or verdict == nil +end + +function Handshake.tradeAllowed(verdict) + return verdict ~= "refused" +end + +-- ------- incompatibility report + +local function index(mods) + local byId = {} + for _, mod in ipairs(mods or {}) do byId[tostring(mod.id)] = mod end + return byId +end + +-- the two mod arrays diffed, so the screen can name the difference instead +-- of the old silent mid-battle draw +function Handshake.modDiff(localHello, remoteHello) + local mine = index(localHello and localHello.mods) + local theirs = index(remoteHello and remoteHello.mods) + local onlyMine, onlyTheirs, differing = {}, {}, {} + for id, mod in pairs(mine) do + local peer = theirs[id] + if not peer then + onlyMine[#onlyMine + 1] = mod + elseif tostring(peer.version) ~= tostring(mod.version) then + differing[#differing + 1] = { id = id, mine = mod.version, + theirs = peer.version } + end + end + for id, mod in pairs(theirs) do + if not mine[id] then onlyTheirs[#onlyTheirs + 1] = mod end + end + local byId = function(a, b) return tostring(a.id) < tostring(b.id) end + table.sort(onlyMine, byId) + table.sort(onlyTheirs, byId) + table.sort(differing, byId) + return { onlyMine = onlyMine, onlyTheirs = onlyTheirs, differing = differing } +end + +local WIDTH = 19 -- characters that fit one 160px line at 8px per glyph + +local function wrap(lines, text) + while #text > WIDTH do + local cut = text:sub(1, WIDTH + 1):match("^.*()%s") + if not cut or cut <= 1 then cut = WIDTH + 1 end + lines[#lines + 1] = text:sub(1, cut - 1) + text = text:sub(cut + 1) + end + if #text > 0 then lines[#lines + 1] = text end +end + +local function listMods(lines, heading, mods) + if #mods == 0 then return end + wrap(lines, heading) + for i, mod in ipairs(mods) do + if i > 3 then + wrap(lines, ("and %d more."):format(#mods - 3)) + return + end + wrap(lines, (" %s %s"):format(tostring(mod.id):upper():sub(1, 12), + tostring(mod.version or "?"))) + end +end + +-- lines for the incompatibility screen: what differs, then what still works +function Handshake.describe(localHello, remoteHello, verdict, mode) + local lines = {} + local peer = (remoteHello and remoteHello.name) or "THEY" + if verdict == "refused" then + if not (remoteHello and remoteHello.protocol) then + wrap(lines, "The other game is") + wrap(lines, "an older version") + wrap(lines, "with no mods.") + wrap(lines, "Your mods can't") + wrap(lines, "link with it.") + else + wrap(lines, "The two games are") + wrap(lines, "different engine") + wrap(lines, "versions.") + end + return lines + end + wrap(lines, "Your games differ.") + local diff = Handshake.modDiff(localHello, remoteHello) + listMods(lines, peer .. " has:", diff.onlyTheirs) + listMods(lines, "You have:", diff.onlyMine) + for i, row in ipairs(diff.differing) do + if i > 2 then break end + wrap(lines, ("%s %s vs %s"):format(tostring(row.id):upper():sub(1, 8), + tostring(row.mine), tostring(row.theirs))) + end + if #diff.onlyMine == 0 and #diff.onlyTheirs == 0 and #diff.differing == 0 then + wrap(lines, "The game data is") + wrap(lines, "not the same.") + end + if mode == "battle" then + wrap(lines, "Link battle needs") + wrap(lines, "the same mods.") + else + wrap(lines, "Trading is limited") + wrap(lines, "to shared POKéMON.") + end + return lines +end + +return Handshake diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 7aad236e..2826c2f8 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -14,8 +14,11 @@ -- boosts don't apply on either side (divergence: Gen 1 famously kept -- them in link battles). +local Fingerprint = require("src.link.Fingerprint") +local Handshake = require("src.link.Handshake") local Logger = require("src.core.Logger") local Protocol = require("src.link.Protocol") +local Runtime = require("src.mods.Runtime") local TurnOrder = require("src.battle.TurnOrder") local LinkBattle = {} @@ -47,7 +50,9 @@ local function mkBattler(data, mon, isPlayer) } end --- canonical (host-side-first) state signature for desync detection +-- canonical (host-side-first) state hash, unchanged since v1: it stays on +-- the wire as `value` so a pre-mod peer still compares something it agrees +-- with, while the components below carry the real coverage local function stateHash(self, role) local function sig(b) return ("%s:%d:%s"):format(b.mon.species, b.mon.hp, tostring(b.mon.status)) @@ -57,29 +62,141 @@ local function stateHash(self, role) return sig(hostSide) .. "|" .. sig(guestSide) end +-- The signature is split into components so a mismatch can name what +-- diverged: species:hp:status alone missed stat stages, PP, toxic counters +-- and bench damage until they happened to move an active's HP, and the +-- match then ended in a draw that explained nothing. +local STAGES = { "attack", "defense", "special", "speed", "accuracy", "evasion" } +local VOLATILE = { + "bideDamage", "bideTurns", "boundTurns", "chargeReady", "charging", + "confusedTurns", "disabledSlot", "disabledTurns", "flinched", "focusEnergy", + "invulnerable", "leechSeeded", "lightScreen", "mist", "mustRecharge", + "rageMove", "reflect", "skipMove", "sleepTurns", "substituteHP", + "thrashMove", "thrashTurns", "toxicCounter", "trapDamage", "trapMove", + "trappingTurns", +} + +-- move instances ride some volatile slots; only their id is comparable +local function scalar(v) + if type(v) == "table" then return tostring(v.id or "?") end + if type(v) == "boolean" then return v and "T" or "F" end + return tostring(v) +end + +local function stageStr(b) + local out = {} + for i, stat in ipairs(STAGES) do + out[i] = tostring((b.stages or {})[stat] or 0) + end + return table.concat(out, ",") +end + +local function ppStr(mon) + local out = {} + for i, mv in ipairs(mon.moves or {}) do + out[i] = ("%s=%s"):format(tostring(mv.id), tostring(mv.pp or 0)) + end + return table.concat(out, ",") +end + +local function volStr(b) + local out = {} + for _, key in ipairs(VOLATILE) do + if b[key] ~= nil then + out[#out + 1] = key .. "=" .. scalar(b[key]) + end + end + return table.concat(out, ",") +end + +local function activeStr(b) + return ("%s:%d:%s:%s:%s"):format(b.mon.species, b.mon.hp, + tostring(b.mon.status), stageStr(b), + ppStr(b.mon)) +end + +local function benchStr(party) + local out = {} + for i, mon in ipairs(party or {}) do + out[i] = ("%s:%d:%s"):format(tostring(mon.species), mon.hp or 0, + tostring(mon.status)) + end + return table.concat(out, "|") +end + +-- canonical (host-side-first) per-component signature for desync detection +local function stateSig(self, role, myParty, theirParty) + local host = role == "host" and self.player or self.enemy + local guest = role == "host" and self.enemy or self.player + local hostParty = role == "host" and myParty or theirParty + local guestParty = role == "host" and theirParty or myParty + return { + actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)), + volatile = Fingerprint.digest(volStr(host) .. "|" .. volStr(guest)), + bench = Fingerprint.digest(benchStr(hostParty) .. "|" .. benchStr(guestParty)), + } +end + +local PARTS = { "actives", "volatile", "bench" } + -- opts: { myParty = packed, theirParty = packed, theirName, role = --- "host"/"guest", seed } +-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when +-- the handshake says the two link surfaces don't match: a lockstep +-- simulation of two different rulebooks can only end in a bogus draw. function LinkBattle.new(game, net, opts) local BattleState = require("src.battle.BattleState") local role = opts.role local theirName = opts.theirName or "FOE" + if not Handshake.battleAllowed(opts.verdict) then + return nil, "Link battle needs\nthe same mods on\nboth games." + end + -- both parties pass through the same pack->unpack clamp on both -- machines, so the copies are identical everywhere + local unpackOpts = { strict = opts.strict or false } local myParty, theirParty = {}, {} for _, p in ipairs(opts.myParty or {}) do - local mon = Protocol.unpackMon(game.data, p) - if mon then table.insert(myParty, mon) end + local mon = Protocol.unpackMon(game.data, p, unpackOpts) + if mon then + table.insert(myParty, mon) + elseif unpackOpts.strict then + return nil, ("Your %s can't\nbattle on the\nother game."):format( + tostring(p.species)) + end end for _, p in ipairs(opts.theirParty or {}) do - local mon = Protocol.unpackMon(game.data, p) - if mon then table.insert(theirParty, mon) end + local mon, why = Protocol.unpackMon(game.data, p, unpackOpts) + if mon then + table.insert(theirParty, mon) + elseif unpackOpts.strict then + return nil, ("Their %s isn't\nin this game.\n(%s)"):format( + tostring(p.species), tostring(why)) + end end if #myParty == 0 or #theirParty == 0 then Logger.warn("link: empty party on one side") end - -- build on a wild battle and reshape it into the lockstep link battle + -- a mod validates its own extra namespace here, the same site the trade + -- path gets in TradeSession:apply, before anything simulates with it. + -- Both parties go through it on both machines and in the canonical + -- host-first order: a validator that strips a field from one side only, + -- or in a different order, leaves the two simulations holding different + -- mons and desyncs on the first turn the difference matters. + local function announceReceived(party) + for _, mon in ipairs(party) do + Runtime.emit("pokemon.received", + { mon = mon, from = "link", peerName = theirName }) + end + end + announceReceived(role == "host" and myParty or theirParty) + announceReceived(role == "host" and theirParty or myParty) + + -- build on a wild battle and reshape it into the lockstep link battle. + -- The RATTATA scaffold is unreachable on the negotiated path: an empty or + -- unrebuildable party is refused above, so it only ever covers a caller + -- that skipped the handshake. local self = BattleState.newWild(game, theirParty[1] and theirParty[1].species or "RATTATA", 5) self.kind = "link" @@ -99,6 +216,9 @@ function LinkBattle.new(game, net, opts) self.introText = ("%s wants\nto battle!"):format(theirName) self.remoteHashes = {} self.localHashes = {} + self.remoteParts = {} + self.localParts = {} + self.checkedTurns = {} local send = function(msg) net:send(msg) end @@ -128,17 +248,40 @@ function LinkBattle.new(game, net, opts) return nil end + -- with the handshake guaranteeing both games share a link surface, a + -- mismatch here is RNG non-determinism -- almost always a mod rolling + -- love.math.random inside battle logic instead of the injected s.rng + local function reportDesync(s, turn, component, localH, remoteH) + Logger.warn("link: desync turn %s component=%s (%s vs %s)", + tostring(turn), component, tostring(localH), tostring(remoteH)) + Runtime.emit("link.desync", { turn = turn, component = component, + localHash = localH, remoteHash = remoteH }) + endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?") + :format(component)) + end + + -- a verified turn stays recorded: consuming it here left a finished + -- battle holding 0-1 entries, so the whole-battle sweep the link suite + -- runs over localHashes had nothing left to compare local function checkHashes(s) for turn, localH in pairs(s.localHashes) do local remoteH = s.remoteHashes[turn] - if remoteH and remoteH ~= localH then - Logger.warn("link: desync on turn %d (%s vs %s)", turn, localH, remoteH) - endAsDraw(s, "Link error!\nThe battle ends\nin a draw.") - return - end - if remoteH then - s.localHashes[turn] = nil - s.remoteHashes[turn] = nil + if remoteH and not s.checkedTurns[turn] then + s.checkedTurns[turn] = true + local mine, theirs = s.localParts[turn], s.remoteParts[turn] + if mine and theirs then + for _, component in ipairs(PARTS) do + if mine[component] ~= theirs[component] then + reportDesync(s, turn, component, mine[component], theirs[component]) + return + end + end + end + -- a v1 peer sends the combined value only + if remoteH ~= localH then + reportDesync(s, turn, "state", localH, remoteH) + return + end end end end @@ -178,12 +321,25 @@ function LinkBattle.new(game, net, opts) s:act(function() local theirAction = decodeTheirAction(s, theirMsg) + Runtime.emit("battle.turn_started", { + battle = s, turn = s.turnCount, + playerAction = myAction, enemyAction = theirAction, + }) if myAction and theirAction then -- the tie-break roll is shared: the guest inverts it so both - -- machines agree on who goes first - local first = TurnOrder.firstMover(s.player, orderMove(myAction), - s.enemy, orderMove(theirAction), - s.rng, role == "guest") + -- machines agree on who goes first. A modded ordering rule has + -- to run here too, or the two peers order the turn differently. + local first + local myMove, theirMove = orderMove(myAction), orderMove(theirAction) + if Runtime.wantsHook("battle.turn_order") then + first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c) + return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie) + end, s.player, myMove, s.enemy, theirMove, + { rng = s.rng, invertTie = role == "guest" }) + else + first = TurnOrder.firstMover(s.player, myMove, s.enemy, theirMove, + s.rng, role == "guest") + end local order if first then order = { { s.player, s.enemy, myAction }, @@ -203,9 +359,11 @@ function LinkBattle.new(game, net, opts) s:act(function() s:endOfTurn() end) s:act(function() if s.linkEnded then return end + local parts = stateSig(s, role, myParty, theirParty) local h = stateHash(s, role) s.localHashes[s.turnCount] = h - send({ type = "hash", turn = s.turnCount, value = h }) + s.localParts[s.turnCount] = parts + send({ type = "hash", turn = s.turnCount, value = h, parts = parts }) checkHashes(s) end) end) @@ -260,11 +418,10 @@ function LinkBattle.new(game, net, opts) -- the party menu must offer the clamped link copies self.openParty = function(s) - local PartyMenu = require("src.ui.PartyMenu") s.phase = "messages" s.afterQueue = "menu" s:ui(function() - return PartyMenu.new(game, { + return s:buildScreen("PartyMenu", { battle = s, party = myParty, onSwitch = function(mon) @@ -333,6 +490,7 @@ function LinkBattle.new(game, net, opts) tryResolve(s) elseif msg.type == "hash" then s.remoteHashes[msg.turn or 0] = msg.value + s.remoteParts[msg.turn or 0] = msg.parts checkHashes(s) elseif msg.type == "bye" then -- only a draw if our own simulation hasn't already decided diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index faf2b84e..091578fd 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -3,8 +3,11 @@ -- lua-enet (bundled with LÖVE), no relay server. local Font = require("src.render.Font") +local Handshake = require("src.link.Handshake") local Net = require("src.link.Net") local Protocol = require("src.link.Protocol") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") local TextBox = require("src.render.TextBox") local LinkState = {} @@ -13,6 +16,10 @@ LinkState.isOpaque = true local CURSOR = 0xED +-- how long the host waits for a v2 hello before deciding the peer predates +-- the handshake (a pre-mod guest sends nothing until it hears the mode) +local HELLO_GRACE = 2 + -- the joiner edits an IPv4 address as 12 digits (three per octet), -- prefilled with our own LAN IP so usually only the tail needs changing local function ipDigits(ip) @@ -40,7 +47,8 @@ function LinkState.new(game) return self end -function LinkState:exitWith(message) +function LinkState:exitWith(message, reason) + Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) if self.net then self.net:close() end self.game.stack:pop() if message then @@ -48,6 +56,61 @@ function LinkState:exitWith(message) end end +-- ------------------------------------------------------------------- +-- handshake v2 (D8): both peers announce engine version, api version and +-- a fingerprint of their link surface, and the verdict comes from the two +-- hellos rather than from whoever picked the mode. The guest announces +-- itself the moment it pairs; the host's hello still carries the mode, so +-- a pre-mod build reads it exactly as it always did. +-- ------------------------------------------------------------------- + +-- take the peer's hello out of the inbox without eating anything that +-- shares the batch with it +function LinkState:pollHello() + local msgs = self.net:poll() + local keep, got = {}, false + for _, msg in ipairs(msgs) do + if msg.type == "hello" and not self.peerHello then + self.peerHello = msg + self.peerName = msg.name + got = true + else + keep[#keep + 1] = msg + end + end + for i = #keep, 1, -1 do + table.insert(self.net.inbox, 1, keep[i]) + end + return got, #keep > 0 +end + +function LinkState:sendHello(mode) + self.myHello = Handshake.hello(self.game, mode) + self.net:send(self.myHello) +end + +function LinkState:decideCompat(mode, isHost) + self.isHost = isHost + self.pendingMode = mode + self.myHello = self.myHello or Handshake.hello(self.game, isHost and mode or nil) + local peer = self.peerHello + self.verdict = Handshake.checkCompat(self.myHello, peer) + Runtime.emit("link.connected", { + role = isHost and "host" or "guest", + remote = { name = peer and peer.name or self.peerName, mode = mode, + mods = peer and peer.mods, fingerprint = peer and peer.fingerprint }, + }) + if self.verdict == "full" or self.verdict == "vanilla_peer" then + self:startMode(mode, isHost) + return + end + -- naming the difference up front is the whole point: the old behaviour + -- was a silent draw three turns into a battle that could never work + self.noticeLines = Handshake.describe(self.myHello, peer, self.verdict, mode) + self.noticeExits = self.verdict == "refused" or mode ~= "trade" + self.stage = "notice" +end + -- ------------------------------------------------------------------- -- update -- ------------------------------------------------------------------- @@ -64,7 +127,7 @@ function LinkState:update(dt) -- so a final message travelling with the disconnect still counts) if self.net.closed and #self.net.inbox == 0 and self.stage ~= "menu" and self.stage ~= "addrEntry" - and self.stage ~= "battleRunning" then + and self.stage ~= "notice" and self.stage ~= "battleRunning" then self:exitWith("The link was\nbroken.") return end @@ -124,35 +187,62 @@ function LinkState:update(dt) if input:wasPressed("b") then self:exitWith(nil) return end if self.net.paired then self.stage = "waitMode" + self:sendHello(nil) -- the host owns the mode; this is just who we are end elseif self.stage == "modeSelect" then -- host picks + self:pollHello() if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("a") then local mode = self.index == 1 and "trade" or "battle" - self.net:send({ type = "hello", name = self.game.save.player.name, mode = mode }) - self:startMode(mode, true) + self:sendHello(mode) + if self.peerHello then + self:decideCompat(mode, true) + else + self.pendingMode = mode + self.helloWait = 0 + self.stage = "waitHello" + end elseif input:wasPressed("b") then self:exitWith(nil) end + elseif self.stage == "waitHello" then -- host waits for the peer's hello + if input:wasPressed("b") then self:exitWith(nil) return end + local got, other = self:pollHello() + self.helloWait = self.helloWait + (dt or 0) + -- a pre-mod peer never sends one: it just gets on with the mode, so + -- its first message -- or the grace period -- is the answer + if got or other or self.helloWait > HELLO_GRACE then + self:decideCompat(self.pendingMode, true) + end + elseif self.stage == "waitMode" then -- guest waits for host's pick if input:wasPressed("b") then self:exitWith(nil) return end local msgs = self.net:poll() for i, msg in ipairs(msgs) do if msg.type == "hello" then + self.peerHello = msg self.peerName = msg.name - self:startMode(msg.mode, false) -- the host's next messages (party, ...) can share this batch; -- put them back so the new stage's poll sees them for j = #msgs, i + 1, -1 do table.insert(self.net.inbox, 1, msgs[j]) end + self:decideCompat(msg.mode, false) break end end + elseif self.stage == "notice" then + if input:wasPressed("b") or (self.noticeExits and input:wasPressed("a")) then + self.net:send({ type = "bye" }) + self:exitWith(nil, "error") + elseif input:wasPressed("a") then + self:startMode(self.pendingMode, self.isHost) + end + elseif self.stage == "trade" then self:updateTrade(input) @@ -167,12 +257,21 @@ function LinkState:update(dt) theirParty = msg.mons, theirName = self.peerName or "FOE", seed = self.isHost and self.linkSeed or msg.seed, + verdict = self.verdict, + strict = Handshake.strict(self.verdict), } + local battle, why if self.isHost then - self.game.stack:push(LinkBattle.newHost(self.game, self.net, opts)) + battle, why = LinkBattle.newHost(self.game, self.net, opts) else - self.game.stack:push(LinkBattle.newGuest(self.game, self.net, opts)) + battle, why = LinkBattle.newGuest(self.game, self.net, opts) end + if not battle then + self.net:send({ type = "bye" }) + self:exitWith(why or "Link battle\ncan't start.", "error") + return + end + self.game.stack:push(battle) self.stage = "battleRunning" for j = #msgs, i + 1, -1 do table.insert(self.net.inbox, 1, msgs[j]) @@ -192,8 +291,15 @@ function LinkState:startMode(mode, isHost) self.isHost = isHost if mode == "trade" then self.stage = "trade" - self.trade = Protocol.TradeSession.new(self.game.data, self.game.save.party) - self.net:send({ type = "party", mons = Protocol.packParty(self.game.save.party) }) + -- a subset session settles which mons both games rebuild identically + -- before either party goes out, so a pick can't land on a mon the + -- other side would reconstruct differently + self.trade = Protocol.TradeSession.new(self.game.data, self.game.save.party, { + subset = self.verdict == "subset", + strict = Handshake.strict(self.verdict), + peerName = self.peerName, + }) + self.net:send(self.trade:opening()) self.index = 1 else self.stage = "battleWait" @@ -213,24 +319,26 @@ end function LinkState:updateTrade(input) for _, msg in ipairs(self.net:poll()) do - self.trade:handle(msg) + local reply = self.trade:handle(msg) + if reply then self.net:send(reply) end end local t = self.trade if t.stage == "cancelled" then - self:exitWith("The trade was\ncancelled.") + self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error) + or "The trade was\ncancelled.") return end if t.stage == "done" then local sent = t.party[t.myPick] local received, evoTo = t:apply(self.game) local name = received.nickname or self.game.data.pokemon[received.species].name + Runtime.emit("link.ended", { reason = "done" }) self.net:close() self.game.stack:pop() local game = self.game require("src.core.Sound").play(game.data, "Trade_Machine") - local TradeAnim = require("src.ui.TradeAnim") - game.stack:push(TradeAnim.new(game, { + Screens.push(game, "TradeAnim", { sent = sent, received = received, onDone = function() game.stack:push(TextBox.new(game, @@ -241,7 +349,7 @@ function LinkState:updateTrade(input) end end)) end, - })) + }) return end @@ -257,7 +365,9 @@ function LinkState:updateTrade(input) self.net:send({ type = "bye" }) self:exitWith("The trade was\ncancelled.") elseif t.stage == "picking" and input:wasPressed("a") then - self.net:send(t:pick(self.index)) + if t:canPick(self.index) then + self.net:send(t:pick(self.index)) + end elseif t.stage == "confirming" and self.confirmed == nil then if input:wasPressed("a") then self.confirmed = true @@ -318,10 +428,23 @@ function LinkState:draw() Font.draw("BATTLE", 32, 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) - elseif self.stage == "waitMode" then + elseif self.stage == "waitMode" or self.stage == "waitHello" then drawTitle("CONNECTED!") - Font.draw("Waiting for the", 16, 56) - Font.draw("host to choose...", 16, 72) + if self.stage == "waitHello" then + Font.draw("Checking the", 16, 56) + Font.draw("other game...", 16, 72) + else + Font.draw("Waiting for the", 16, 56) + Font.draw("host to choose...", 16, 72) + end + + elseif self.stage == "notice" then + drawTitle("CHECK YOUR MODS") + for i, line in ipairs(self.noticeLines or {}) do + if i > 8 then break end -- what fits above the prompt row + Font.draw(line, 8, 24 + (i - 1) * 12) + end + Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128) elseif self.stage == "trade" then drawTitle("TRADE") @@ -329,7 +452,9 @@ function LinkState:draw() Font.draw("YOURS", 8, 20) for i, mon in ipairs(self.game.save.party) do local def = self.game.data.pokemon[mon.species] - Font.draw((mon.nickname or def.name):sub(1, 8), 16, 20 + i * 12) + local label = (mon.nickname or def.name):sub(1, 8) + if not t:canPick(i) then label = label .. "X" end + Font.draw(label, 16, 20 + i * 12) if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end end Font.draw("THEIRS", 84, 20) @@ -339,8 +464,11 @@ function LinkState:draw() if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end end local hint - if t.stage == "waitParty" then hint = "Exchanging data..." - elseif t.stage == "picking" then hint = "Pick one to trade" + if t.stage == "waitRecords" then hint = "Comparing games..." + elseif t.stage == "waitParty" then hint = "Exchanging data..." + elseif t.stage == "picking" then + hint = t:canPick(self.index) and "Pick one to trade" + or "X: not on theirs" elseif t.stage == "waitPick" then hint = "Waiting for them..." elseif t.stage == "confirming" then hint = self.confirmed and "Waiting..." or "A: trade B: cancel" diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index bdcaeada..b8dcc235 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -2,21 +2,52 @@ -- state machine (pure logic, headless-testable). -- -- Message types exchanged after pairing: --- {type="hello", name, mode} host announces trade|battle --- {type="party", mons=[...]} full party (both directions) --- {type="pick", index} trade: chosen party slot +-- {type="hello", ...} handshake v2 (src/link/Handshake.lua) +-- {type="records", pokemon=, moves=} subset trade: per-record hashes +-- {type="party", mons=[...]} party (both directions) +-- {type="pick", index} trade: chosen slot in the sent list -- {type="confirm", ok=bool} trade: final yes/no -- {type="action", ...} battle: guest -> host choice -- {type="event", ...} battle: host -> guest display event -- {type="bye"} +local Fingerprint = require("src.link.Fingerprint") +local Handshake = require("src.link.Handshake") +local Runtime = require("src.mods.Runtime") + local Protocol = {} --- serialize a mon instance for the wire (plain data only) +Protocol.hello = Handshake.hello +Protocol.checkCompat = Handshake.checkCompat + +-- the extra bag is JSON-safe by contract, the same restriction the save +-- serializer enforces; anything else is dropped rather than trusted +local function plainCopy(value, depth) + if type(value) ~= "table" then return nil end + if (depth or 0) > 8 then return nil end + local out = {} + for k, v in pairs(value) do + local kt, vt = type(k), type(v) + if kt == "string" or kt == "number" then + if vt == "string" or vt == "number" or vt == "boolean" then + out[k] = v + elseif vt == "table" then + out[k] = plainCopy(v, (depth or 0) + 1) + end + end + end + return out +end + +Protocol.plainCopy = plainCopy + +-- serialize a mon instance for the wire (plain data only). ppUps rides +-- along because the real cable transmitted it and its absence silently +-- capped a PP-Upped move at base PP on the receiving side. function Protocol.packMon(mon) local moves = {} for _, mv in ipairs(mon.moves) do - table.insert(moves, { id = mv.id, pp = mv.pp }) + table.insert(moves, { id = mv.id, pp = mv.pp, ppUps = mv.ppUps }) end return { species = mon.species, @@ -28,16 +59,23 @@ function Protocol.packMon(mon) dvs = mon.dvs, statExp = mon.statExp, moves = moves, + extra = plainCopy(mon.extra), } end -- rebuild a mon locally (recomputes stats from real species data so a --- tampered packet can't invent stats) -function Protocol.unpackMon(data, packed) +-- tampered packet can't invent stats). opts.strict is set once two v2 +-- peers have agreed on a verdict: a mon that cannot be rebuilt identically +-- is rejected by name instead of quietly mutated into something else. +function Protocol.unpackMon(data, packed, opts) local Stats = require("src.pokemon.Stats") local Growth = require("src.pokemon.Growth") + local strict = opts and opts.strict local def = data.pokemon[packed.species] - if not def then return nil end + if not def then + if strict then return nil, "unknown POKéMON" end + return nil + end local level = math.max(2, math.min(100, math.floor(packed.level or 5))) local dvs = {} for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do @@ -50,14 +88,20 @@ function Protocol.unpackMon(data, packed) local stats = Stats.calc(def, level, dvs, statExp) local moves = {} for _, mv in ipairs(packed.moves or {}) do - if data.moves[mv.id] and #moves < 4 then - table.insert(moves, { - id = mv.id, - pp = math.max(0, math.min(data.moves[mv.id].pp, math.floor(mv.pp or 0))), - }) + local mdef = data.moves[mv.id] + if mdef and #moves < 4 then + local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0))) + local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5) + local entry = { id = mv.id, + pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) } + if mv.ppUps ~= nil then entry.ppUps = ppUps end + table.insert(moves, entry) end end if #moves == 0 then + -- the v1 path keeps the substitute verbatim for peers built against it; + -- a negotiated v2 link says so out loud instead + if strict then return nil, "no shared moves" end moves = { { id = "TACKLE", pp = 35 } } end return { @@ -71,46 +115,170 @@ function Protocol.unpackMon(data, packed) status = packed.status, nickname = packed.nickname, moves = moves, + -- a namespace whose mod this install lacks survives untouched, so the + -- mon keeps it for the trip home + extra = plainCopy(packed.extra), } end -function Protocol.packParty(party) +function Protocol.packParty(party, indices) local mons = {} + if indices then + for _, i in ipairs(indices) do + table.insert(mons, Protocol.packMon(party[i])) + end + return mons + end for _, mon in ipairs(party) do table.insert(mons, Protocol.packMon(mon)) end return mons end +-- ------- subset negotiation + +-- the species and moves this party actually references, so the exchange +-- stays small (six mons) instead of shipping the whole catalog +function Protocol.recordsMessage(data, party) + local species = Fingerprint.records(data, "pokemon") + local moves = Fingerprint.records(data, "moves") + local outSpecies, outMoves = {}, {} + for _, mon in ipairs(party or {}) do + if species[mon.species] then outSpecies[mon.species] = species[mon.species] end + for _, mv in ipairs(mon.moves or {}) do + if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end + end + end + return { type = "records", pokemon = outSpecies, moves = outMoves } +end + +-- a mon may cross the wire only if both peers rebuild it identically: the +-- species and every move id has to exist on the other game with the same +-- record hash. Filtering is symmetric, so the two sides always agree on +-- which slots are in play and a pick can never land on a different mon. +function Protocol.eligibleParty(party, myRecords, theirRecords) + local eligible, reasons = {}, {} + theirRecords = theirRecords or {} + local theirSpecies = theirRecords.pokemon or {} + local theirMoves = theirRecords.moves or {} + local mySpecies = (myRecords or {}).pokemon or {} + local myMoves = (myRecords or {}).moves or {} + for i, mon in ipairs(party or {}) do + local reason + if not theirSpecies[mon.species] then + reason = "not on the other game" + elseif theirSpecies[mon.species] ~= mySpecies[mon.species] then + reason = "different data" + else + for _, mv in ipairs(mon.moves or {}) do + if not theirMoves[mv.id] then + reason = "unknown move" + break + elseif theirMoves[mv.id] ~= myMoves[mv.id] then + reason = "different move data" + break + end + end + end + eligible[i] = reason == nil + reasons[i] = reason + end + return eligible, reasons +end + -- ------------------------------------------------------------------- -- Trade session: symmetric state machine. Feed it messages; read --- .stage ("waitParty" -> "picking" -> "waitPick" -> "confirming" -> --- "done"/"cancelled"). When done, .result = {give=idx, getMon=mon}. +-- .stage ("waitRecords" -> "waitParty" -> "picking" -> "waitPick" -> +-- "confirming" -> "done"/"cancelled"). When done, .result = +-- {give=idx, getMon=mon}. A subset session negotiates the eligible +-- slots first, so both sides send and index the same filtered list. -- ------------------------------------------------------------------- local TradeSession = {} TradeSession.__index = TradeSession Protocol.TradeSession = TradeSession -function TradeSession.new(data, party) - return setmetatable({ +-- opts: { subset, strict, peerName } -- all absent on the v1 path +function TradeSession.new(data, party, opts) + opts = opts or {} + local self = setmetatable({ data = data, party = party, - stage = "waitParty", + subset = opts.subset or false, + strict = opts.strict or false, + peerName = opts.peerName, + stage = opts.subset and "waitRecords" or "waitParty", + sendIndices = nil, + eligible = nil, + reasons = {}, theirParty = nil, myPick = nil, theirPick = nil, myConfirm = nil, theirConfirm = nil, }, TradeSession) + if not self.subset then + local all = {} + for i = 1, #party do all[i] = i end + self.sendIndices = all + end + return self end +-- the first message on the wire: a subset trade has to agree on which mons +-- both games rebuild identically before either party can be sent +function TradeSession:opening() + if self.subset then + return Protocol.recordsMessage(self.data, self.party) + end + return self:partyMessage() +end + +function TradeSession:partyMessage() + return { type = "party", + mons = Protocol.packParty(self.party, self.sendIndices) } +end + +function TradeSession:_negotiate(theirRecords) + local mine = { pokemon = Fingerprint.records(self.data, "pokemon"), + moves = Fingerprint.records(self.data, "moves") } + self.eligible, self.reasons = + Protocol.eligibleParty(self.party, mine, theirRecords) + local indices = {} + for i = 1, #self.party do + if self.eligible[i] then indices[#indices + 1] = i end + end + self.sendIndices = indices +end + +-- the UI greys what the other game would rebuild differently +function TradeSession:canPick(index) + return self.eligible == nil or self.eligible[index] == true +end + +-- returns a message to put on the wire, or nil function TradeSession:handle(msg) - if msg.type == "party" then + if msg.type == "records" then + -- only once: re-filtering after our party went out would slide the + -- indices the peer is already holding + if self.stage ~= "waitRecords" then return nil end + self:_negotiate(msg) + self.stage = "waitParty" + return self:partyMessage() + elseif msg.type == "party" then self.theirParty = {} for _, packed in ipairs(msg.mons or {}) do - local mon = Protocol.unpackMon(self.data, packed) - if mon then table.insert(self.theirParty, mon) end + local mon, why = Protocol.unpackMon(self.data, packed, + { strict = self.strict }) + if mon then + table.insert(self.theirParty, mon) + elseif self.strict then + -- dropping a row would slide every later index by one and the + -- two sides would commit different mons; refuse the whole trade + self.stage = "cancelled" + self.error = why or "the other game sent an unknown POKéMON" + return nil + end end if self.stage == "waitParty" then self.stage = "picking" end elseif msg.type == "pick" then @@ -122,12 +290,22 @@ function TradeSession:handle(msg) elseif msg.type == "bye" then self.stage = "cancelled" end + return nil +end + +-- index is a real party slot; the wire carries its position in the list +-- this side actually sent, which is the only space both peers share +function TradeSession:wireIndex(index) + for pos, i in ipairs(self.sendIndices or {}) do + if i == index then return pos end + end + return index end function TradeSession:pick(index) self.myPick = index self:advance() - return { type = "pick", index = index } + return { type = "pick", index = self:wireIndex(index) } end function TradeSession:confirm(ok) @@ -157,19 +335,27 @@ end function TradeSession:apply(game) assert(self.stage == "done", "trade not complete") local received = self.theirParty[self.theirPick] + local sent = self.party[self.myPick] received.traded = true -- boosted exp (different OT) + -- a mod validates its own extra namespace here, before the mon is filed + Runtime.emit("pokemon.received", + { mon = received, from = "link", peerName = self.peerName }) self.party[self.myPick] = received if game and game.save.pokedex then game.save.pokedex.seen[received.species] = true game.save.pokedex.owned[received.species] = true end local def = self.data.pokemon[received.species] + local evolveTo for _, evo in ipairs(def.evolutions or {}) do if evo.method == "TRADE" then - return received, evo.species + evolveTo = evo.species + break end end - return received, nil + Runtime.emit("trade.completed", + { sent = sent, received = received, evolveTo = evolveTo }) + return received, evolveTo end return Protocol diff --git a/src/mods/AssetTransform.lua b/src/mods/AssetTransform.lua new file mode 100644 index 00000000..f73a98c6 --- /dev/null +++ b/src/mods/AssetTransform.lua @@ -0,0 +1,230 @@ +-- Asset transforms (D11): the manifest's assets_transforms file, run once +-- at install / first load to generate derived art from the player's *own* +-- imported cache. A mod ships the recipe, never the pixels, which is the +-- only sanctioned way to port art that overlaps vanilla Red +-- (17-total-conversions.md §legal posture). +-- +-- The chunk runs in a restricted context: a table of image utilities and +-- exactly two filesystem roots -- read assets/generated/**, write +-- save/mod-derived//** -- with no require, no love, no io, no os. +-- assets/generated is never written because re-import wipes it whole +-- (RomImporter), so anything a transform put there would vanish. +-- +-- A stamp of (cache marker + transform source hash) gates the run, so the +-- cost is paid once per install and re-paid only when the cache is +-- re-imported or the recipe changes. + +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") + +local unpack = table.unpack or unpack +local loadstring = loadstring or load + +local AssetTransform = {} + +local SOURCE_ROOT = "assets/generated/" +local DERIVED_ROOT = "save/mod-derived/" +local CACHE_MARKER = "rom-cache.complete" +local STAMP = ".stamp" + +AssetTransform.SOURCE_ROOT = SOURCE_ROOT +AssetTransform.DERIVED_ROOT = DERIVED_ROOT + +-- ------- path sandbox + +-- a relative path that cannot climb out of the root it is joined to +local function safeRelative(rel) + if type(rel) ~= "string" or rel == "" then return nil end + if rel:sub(1, 1) == "/" then return nil end + if rel:find("\\", 1, true) then return nil end + for segment in rel:gmatch("[^/]+") do + if segment == ".." or segment == "." then return nil end + end + return rel +end + +local function requireRelative(rel, what) + local safe = safeRelative(rel) + if not safe then + error(("%s must stay inside its root, got %q"):format(what, tostring(rel)), 0) + end + return safe +end + +-- ------- the restricted context + +-- shade classification matching the importer's 4 grays and the render +-- thresholds (PaletteFX 0.83 / 0.5 / 0.17), so recolor lands on the same +-- buckets every other consumer reads +local function shadeIndex(r) + if r > 0.83 then return 1 end + if r > 0.5 then return 2 end + if r > 0.17 then return 3 end + return 4 +end + +-- shade index -> new color, as 0-255 triples (a palettes record's shape). +-- Alpha rides through untouched so a matted battle pic stays matted. +function AssetTransform.recolor(imageData, shades) + assert(type(shades) == "table" and #shades == 4, + "recolor needs 4 colors, lightest first") + local out = love.image.newImageData(imageData:getDimensions()) + out:paste(imageData, 0, 0, 0, 0, imageData:getDimensions()) + out:mapPixel(function(_, _, r, g, b, a) + if a == 0 then return r, g, b, a end + local c = shades[shadeIndex(r)] + return c[1] / 255, c[2] / 255, c[3] / 255, a + end) + return out +end + +local function contextFor(modId, fs) + local ImageWriter = require("src.import.ImageWriter") + local derivedRoot = DERIVED_ROOT .. modId .. "/" + local written = 0 + local ctx = {} + + function ctx.source(rel) + return SOURCE_ROOT .. requireRelative(rel, "source path") + end + + function ctx.derived(rel) + return derivedRoot .. requireRelative(rel, "derived path") + end + + function ctx.exists(rel) + return fs.getInfo(ctx.source(rel)) ~= nil + end + + function ctx.readImage(rel) + return love.image.newImageData(ctx.source(rel)) + end + + function ctx.writeImage(imageData, rel) + local path = ctx.derived(rel) + local dir = path:match("^(.*)/[^/]+$") + -- an injected headless fs implies its directories from key prefixes + if dir and fs.createDirectory then fs.createDirectory(dir) end + local encoded = imageData:encode("png") + local ok, err = fs.write(path, encoded) + if not ok then error("could not write " .. path .. ": " .. tostring(err), 0) end + written = written + 1 + return path + end + + ctx.blank = ImageWriter.blank + ctx.blit = ImageWriter.blit + ctx.matte = ImageWriter.matteColor0 + ctx.recolor = AssetTransform.recolor + + return ctx, function() return written end +end + +-- Globals the recipe sees. Everything that could reach the filesystem, +-- the network or another engine module is absent, so the only way out of +-- the sandbox is the ctx table the transform is handed. +local function sandboxEnv() + return { + math = math, string = string, table = table, + ipairs = ipairs, pairs = pairs, next = next, select = select, + type = type, tostring = tostring, tonumber = tonumber, + assert = assert, error = error, pcall = pcall, unpack = unpack, + } +end + +-- The recipe is compiled from source we already read rather than through +-- fs.load, because that is the only way the environment is ours to set: +-- 5.1/LuaJIT swap it after the fact with setfenv, 5.2+ dropped setfenv and +-- take the env as load's 4th argument. Getting this wrong hands the chunk +-- the real globals -- require, love, io -- so it is never left to chance. +local function loadSandboxed(source, chunkname) + local env = sandboxEnv() + if setfenv then + local chunk, err = loadstring(source, chunkname) + if not chunk then return nil, err end + setfenv(chunk, env) + return chunk + end + return load(source, chunkname, "t", env) +end + +-- ------- stamp + +-- djb2 over the recipe source; only has to change when the file does +local function hash(text) + local h = 5381 + for i = 1, #text do + h = (h * 33 + text:byte(i)) % 4294967296 + end + return string.format("%08x", h) +end + +local function stampFor(fs, source) + local marker = fs.read(CACHE_MARKER) or "no-cache" + return marker .. "|" .. hash(source) +end + +-- ------- runner + +-- Run one mod's transform. Returns true when the derived assets are +-- current (whether this call built them or a previous one did); false +-- plus a reason when the recipe failed, which disables that mod's derived +-- art and nothing else. force skips the stamp (dev-mode hot reload). +function AssetTransform.runFor(mod, fs, force) + fs = fs or (love and love.filesystem) + local manifest = mod.manifest + local relative = manifest and manifest.assets_transforms + if not relative then return true end + local modId = manifest.id + local path = mod.path .. "/" .. relative + + local source = fs.read(path) + if not source then + return false, "assets_transforms unreadable: " .. relative + end + + local stampPath = DERIVED_ROOT .. modId .. "/" .. STAMP + local want = stampFor(fs, source) + if not force and fs.read(stampPath) == want then return true end + + local chunk, err = loadSandboxed(source, path) + if not chunk then return false, "assets_transforms: " .. tostring(err) end + + local ctx, count = contextFor(modId, fs) + local ok, result = pcall(chunk) + if ok and type(result) == "function" then + ok, result = pcall(result, ctx) + elseif ok and type(result) ~= "function" then + ok, result = false, "assets_transforms must return a function(ctx)" + end + if not ok then + local reason = "asset transform failed: " .. tostring(result) + Logger.error("[%s] %s", modId, reason) + Runtime.reportError(modId, reason) + return false, reason + end + + if fs.createDirectory then fs.createDirectory(DERIVED_ROOT .. modId) end + fs.write(stampPath, want) + Runtime.emit("assets.transformed", { modId = modId, count = count() }) + return true +end + +-- every loaded mod that declares a transform, in load order. A failing +-- recipe is reported against its mod and the rest still run. +function AssetTransform.run(loader, force) + local ran = 0 + for _, mod in ipairs(loader.loaded or {}) do + if mod.manifest.assets_transforms then + local ok, reason = AssetTransform.runFor(mod, loader.fs, force) + if ok then + ran = ran + 1 + elseif reason then + loader.errors[#loader.errors + 1] = mod.manifest.id .. ": " .. reason + end + end + end + return ran +end + +return AssetTransform diff --git a/src/mods/Builtins.lua b/src/mods/Builtins.lua new file mode 100644 index 00000000..00136d95 --- /dev/null +++ b/src/mods/Builtins.lua @@ -0,0 +1,119 @@ +-- The engine's own content, registered into the catalog under owner +-- "engine" before any mod runs. Overriding a vanilla record and overriding +-- a mod's record are then the same verb, each() always yields the whole +-- world, and a mod's cross-references resolve against real ids. +-- Every registrant hands over the table its module already reads, and +-- install deep-copies it on the way in: two loads must not share record +-- tables, or an edit through one dataset (hot reload, a test loading +-- twice) reaches the other and the module's own statics. Functions ride +-- the copy by reference, so handlers keep their identity and the merged +-- value stays equal to the vanilla one -- the mod-free merge is a no-op. +-- Modules are required lazily -- the loader must not drag the battle and +-- script stacks in with it at require time. +local Logger = require("src.core.Logger") +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") + +local Builtins = {} + +Builtins.OWNER = Schemas.ENGINE + +-- registry name -> the module that owns its vanilla records. Each exposes +-- registerInto(registry, data, owner). +local REGISTRANTS = { + { name = "type_chart", from = "src.battle.TypeChart" }, + { name = "statuses", from = "src.battle.Status" }, + { name = "move_effects", from = "src.battle.MoveEffects" }, + { name = "balls", from = "src.battle.Catching" }, + { name = "transitions", from = "src.render.BattleTransition" }, + { name = "growth_rates", from = "src.pokemon.Growth" }, + { name = "evolution_methods", from = "src.pokemon.Evolution" }, + { name = "commands", from = "src.script.Commands" }, + { name = "tokens", from = "src.render.TextBox" }, + -- plain data files with no owning module: registered from here + { name = "rulesets", modules = { "src.battle.rulesets.gen1_faithful", + "src.battle.rulesets.modern_clean" }, + install = function(registry, modules, owner) + for _, ruleset in ipairs(modules) do + registry:register(ruleset.name, ruleset, owner) + end + end }, + -- the per-trainer class records plus the three vanilla move-scoring + -- layers, which share the registry under LAYER_1..LAYER_3 + { name = "ai_classes", modules = { "data.scripts.ai_classes", + "src.battle.TrainerAI" }, + install = function(registry, modules, owner) + for id, record in pairs(modules[1]) do + registry:register(id, record, owner) + end + modules[2].registerInto(registry, nil, owner) + end }, +} + +-- the registries the engine seeds, in registration order; the parity tests +-- read this to tell an engine-owned namespace from a stray one +function Builtins.registries() + local names = {} + for i, entry in ipairs(REGISTRANTS) do names[i] = entry.name end + return names +end + +-- the top-level Data keys those registrations bring into existence: the +-- only namespaces a mod-free boot is allowed to add +function Builtins.namespaceRoots() + local roots = {} + for _, name in ipairs(Builtins.registries()) do + local target = Schemas.REGISTRIES[name] and Schemas.REGISTRIES[name].target + if target then roots[target:match("^[^%.]+")] = true end + end + return roots +end + +-- a module the build dropped disables its registry rather than the game: +-- the consumer still reads its own table, so vanilla keeps working +local function load(path) + local ok, module = pcall(require, path) + if ok then return module end + Logger.warn("builtin registrations skipped for %s (%s)", path, tostring(module)) + return nil +end + +-- the write verbs copy their payload before it lands; centralized here so +-- the isolation holds for every registrant instead of leaning on each +-- module to hand over fresh tables +local function isolate(registry) + return setmetatable({ + register = function(_, id, value, owner) + return registry:register(id, Merge.deepCopy(value), owner) + end, + override = function(_, id, value, owner) + return registry:override(id, Merge.deepCopy(value), owner) + end, + patch = function(_, id, partial, owner) + return registry:patch(id, Merge.deepCopy(partial), owner) + end, + }, { __index = registry }) +end + +function Builtins.install(content, data) + for _, entry in ipairs(REGISTRANTS) do + local registry = content[entry.name] and isolate(content[entry.name]) + if registry then + if entry.install then + local modules, complete = {}, true + for i, path in ipairs(entry.modules) do + modules[i] = load(path) + if modules[i] == nil then complete = false end + end + if complete then entry.install(registry, modules, Builtins.OWNER) end + else + local module = load(entry.from) + if module and module.registerInto then + module.registerInto(registry, data, Builtins.OWNER) + end + end + end + end +end + +return Builtins diff --git a/src/mods/Events.lua b/src/mods/Events.lua index a315d1eb..84f71cdd 100644 --- a/src/mods/Events.lua +++ b/src/mods/Events.lua @@ -1,35 +1,77 @@ +local Logger = require("src.core.Logger") + local Events = {} Events.__index = Events function Events.new() - return setmetatable({ listeners = {}, sealed = false }, Events) + return setmetatable({ listeners = {} }, Events) end -function Events:on(name, callback, priority) - assert(not self.sealed, "mod events are sealed") +-- owner is the subscribing mod id; failures are attributed to it +function Events:on(name, callback, priority, owner) assert(type(name) == "string" and name ~= "", "event name is required") assert(type(callback) == "function", "event callback must be a function") local list = self.listeners[name] or {} self.listeners[name] = list - local entry = { callback = callback, priority = priority or 0 } + local entry = { callback = callback, priority = priority or 0, owner = owner } list[#list + 1] = entry table.sort(list, function(a, b) return a.priority > b.priority end) return function() for i, candidate in ipairs(list) do if candidate == entry then table.remove(list, i) break end end + -- an emptied name drops its key, as removeOwner does, so Runtime.wants + -- stops telling hot call sites to build payloads for nobody; the + -- identity check keeps a stale second call off a later subscription + if #list == 0 and self.listeners[name] == list then + self.listeners[name] = nil + end end end +-- retires itself after the first fire; safe to unsubscribe from inside the +-- dispatch because emit walks a copy +function Events:once(name, callback, priority, owner) + assert(type(callback) == "function", "event callback must be a function") + local unsubscribe + unsubscribe = self:on(name, function(payload) + unsubscribe() + return callback(payload) + end, priority, owner) + return unsubscribe +end + +-- a throwing listener is logged and skipped so the emitting engine path +-- always completes; the error never propagates function Events:emit(name, payload) - local list = self.listeners[name] or {} - for _, entry in ipairs(list) do - entry.callback(payload) + local list = self.listeners[name] + if not list then return end + -- dispatch over a snapshot: a listener may retire itself or a sibling + -- mid-emit (once, or the closure on() returns), and table.remove on the + -- live list shifts the entries ipairs has not reached yet + local snapshot = {} + for i = 1, #list do snapshot[i] = list[i] end + for _, entry in ipairs(snapshot) do + local ok, err = pcall(entry.callback, payload) + if not ok then + Logger.error("[%s] event %s: %s", + tostring(entry.owner or "?"), name, tostring(err)) + end end end -function Events:seal() - self.sealed = true +-- drops every subscription a mod made; used by entry-chunk rollback +function Events:removeOwner(owner) + if owner == nil then return end + for name, list in pairs(self.listeners) do + for i = #list, 1, -1 do + if list[i].owner == owner then table.remove(list, i) end + end + if #list == 0 then self.listeners[name] = nil end + end end +-- deprecated no-op: subscription stays legal for the life of the process +function Events:seal() end + return Events diff --git a/src/mods/Hooks.lua b/src/mods/Hooks.lua index 0d4a6d0a..0df909b3 100644 --- a/src/mods/Hooks.lua +++ b/src/mods/Hooks.lua @@ -1,18 +1,27 @@ +local Logger = require("src.core.Logger") + local Hooks = {} Hooks.__index = Hooks local unpack = table.unpack or unpack +local function pack(...) return { n = select("#", ...), ... } end + +-- errors raised below the chain (the vanilla function itself) must not be +-- attributed to a mod link or retried; they ride out wrapped under this key +-- so every guard re-raises instead of skipping +local PASS = {} + function Hooks.new() - return setmetatable({ chains = {}, sealed = false }, Hooks) + return setmetatable({ chains = {} }, Hooks) end -function Hooks:wrap(name, callback, priority) - assert(not self.sealed, "mod hooks are sealed") +-- owner is the wrapping mod id; failures are attributed to it +function Hooks:wrap(name, callback, priority, owner) assert(type(name) == "string" and name ~= "", "hook name is required") assert(type(callback) == "function", "hook callback must be a function") local chain = self.chains[name] or {} self.chains[name] = chain - local entry = { callback = callback, priority = priority or 0 } + local entry = { callback = callback, priority = priority or 0, owner = owner } chain[#chain + 1] = entry table.sort(chain, function(a, b) return a.priority > b.priority end) return function() @@ -22,26 +31,75 @@ function Hooks:wrap(name, callback, priority) end end +-- each link runs under pcall: a throwing wrapper is logged and skipped and +-- the chain continues with the current arguments, so a broken mod degrades +-- to "not installed for this call" instead of breaking the pipeline. +-- vanilla must run at most once per call -- it has side effects -- so a link +-- that throws after its next() returned keeps the downstream results (its +-- post-processing is discarded) rather than re-walking the chain, and a link +-- that swallowed a vanilla error then threw propagates instead of retrying function Hooks:call(name, vanilla, ...) - local chain = self.chains[name] or {} - local args = { ... } - local function run(index, current) - if index > #chain then return current(unpack(args)) end - return chain[index].callback(function(...) - local nextArgs = { ... } - if #nextArgs == 0 then return run(index + 1, current) end - local old = args - args = nextArgs - local result = run(index + 1, current) - args = old - return result - end, unpack(args)) + local chain = self.chains[name] + if not chain or #chain == 0 then return vanilla(...) end + local args = pack(...) + local ranVanilla = false + local function run(index) + if index > #chain then + ranVanilla = true + local res = pack(pcall(vanilla, unpack(args, 1, args.n))) + if res[1] then return unpack(res, 2, res.n) end + error({ [PASS] = res[2] }, 0) + end + local entry = chain[index] + local downstream + local function nextFn(...) + if select("#", ...) == 0 then + downstream = pack(run(index + 1)) + else + local saved = args + args = pack(...) + downstream = pack(run(index + 1)) + args = saved + end + return unpack(downstream, 1, downstream.n) + end + local res = pack(pcall(entry.callback, nextFn, unpack(args, 1, args.n))) + if res[1] then return unpack(res, 2, res.n) end + local err = res[2] + if type(err) == "table" and err[PASS] ~= nil then error(err, 0) end + if downstream ~= nil then + Logger.warn("[%s] hook %s failed after next: %s -- downstream result kept", + tostring(entry.owner or "?"), name, tostring(err)) + return unpack(downstream, 1, downstream.n) + end + if ranVanilla then + Logger.warn("[%s] hook %s failed: %s -- vanilla already ran, not retried", + tostring(entry.owner or "?"), name, tostring(err)) + error({ [PASS] = err }, 0) + end + Logger.warn("[%s] hook %s failed: %s -- link skipped", + tostring(entry.owner or "?"), name, tostring(err)) + return run(index + 1) end - return run(1, vanilla) + local res = pack(pcall(run, 1)) + if res[1] then return unpack(res, 2, res.n) end + local err = res[2] + if type(err) == "table" and err[PASS] ~= nil then error(err[PASS], 0) end + error(err, 0) end -function Hooks:seal() - self.sealed = true +-- drops every wrap a mod made; used by entry-chunk rollback +function Hooks:removeOwner(owner) + if owner == nil then return end + for name, chain in pairs(self.chains) do + for i = #chain, 1, -1 do + if chain[i].owner == owner then table.remove(chain, i) end + end + if #chain == 0 then self.chains[name] = nil end + end end +-- deprecated no-op: wrapping stays legal for the life of the process +function Hooks:seal() end + return Hooks diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 238606ab..b6f9a7ea 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1,23 +1,38 @@ local Json = require("src.link.Json") local Logger = require("src.core.Logger") local SaveData = require("src.core.SaveData") +local Data = require("src.core.Data") +local Version = require("src.core.Version") +local Assets = require("src.render.Assets") +local ModUI = require("src.ui.ModUI") +local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") +local Merge = require("src.mods.Merge") local Registry = require("src.mods.Registry") +local Schemas = require("src.mods.Schemas") +local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") local Loader = {} Loader.__index = Loader -local REGISTRY_NAMES = { - "pokemon", "moves", "items", "maps", "tilesets", "encounters", - "trainers", "sprites", "music", "audio", "text", "scripts", "ui", -} - local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only -local function readManifest(root) - local raw, err = love.filesystem.read(root .. "/manifest.json") +-- walk a dotted target path without creating anything; the base view a +-- registry folds against must never perturb Data on a mod-free boot +local function resolvePath(root, path) + local node = root + for key in path:gmatch("[^%.]+") do + if type(node) ~= "table" then return nil end + node = node[key] + end + return node +end + +local function readManifest(fs, root) + local raw, err = fs.read(root .. "/manifest.json") if not raw then return nil, err end local data, decodeErr = Json.decode(raw) if not data then return nil, decodeErr end @@ -26,36 +41,106 @@ local function readManifest(root) return manifest end -local function topoSort(mods) - local ordered, visiting, visited = {}, {}, {} - local function visit(id) - if visited[id] then return end - if visiting[id] then error("circular mod dependency involving " .. id) end - local mod = mods[id] - if not mod then error("missing required mod dependency: " .. id) end - visiting[id] = true - for _, dependency in ipairs(mod.manifest.dependencies) do visit(dependency) end - visiting[id], visited[id] = nil, true - ordered[#ordered + 1] = mod - end +-- the ordering contract every phase walks in: priority ascending, ties by id +local function orderedIds(mods, filter) local ids = {} - for id in pairs(mods) do ids[#ids + 1] = id end + for id, mod in pairs(mods) do + if not filter or filter(mod) then ids[#ids + 1] = id end + end table.sort(ids, function(a, b) local pa, pb = mods[a].manifest.priority, mods[b].manifest.priority if pa == pb then return a < b end return pa < pb end) - for _, id in ipairs(ids) do visit(id) end - return ordered + return ids end -function Loader.new() +-- ------- dev-mode permissions tripwire +-- Attribution only: the shim delegates unconditionally and blocks nothing. +-- Installed once per process and only when the loader runs in dev mode, so a +-- player build has zero interposition. + +local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 } + +-- the src.* modules the mod surface points authors at: another mod's +-- exports carry a version string that wants range-checking before use, and +-- ChipAsm is the authoring path for chip music and sfx +local SUPPORTED_REQUIRES = { + ["src.mods.Semver"] = true, + ["src.audio.ChipAsm"] = true, +} + +local function scanRequire(name) + local modId = Runtime.currentMod + if not modId or type(name) ~= "string" then return end + local granted = devShim.permissions[modId] or {} + local function warnOnce(permission) + local key = modId .. "|" .. permission .. "|" .. name + if devShim.warned[key] then return end + devShim.warned[key] = true + Logger.warn("[%s] undeclared %s require: %s", modId, permission, name) + end + -- link modules are the one place a mod can reach the wire, so network is + -- the permission that governs them + if name:match("^src%.link%.") then + if not granted.network then warnOnce("network") end + elseif name:match("^src%.") and not SUPPORTED_REQUIRES[name] + and not granted.engine_internals then + warnOnce("engine_internals") + end +end + +-- the genuine require, captured before the shim can replace it +local rawRequire = require + +-- a module the loader pulls in late on the mod's behalf. The mod asked for +-- a facade, not for this module nor for whatever it drags in, so the whole +-- load runs at shim depth and neither level is attributed to the mod. +local function engineRequire(name) + devShim.depth = devShim.depth + 1 + local ok, module = pcall(rawRequire, name) + devShim.depth = devShim.depth - 1 + if not ok then return nil end + return module +end + +function Loader:_installDevShim() + for id, mod in pairs(self.mods) do + devShim.permissions[id] = mod.manifest.permissionSet + end + if devShim.installed then return end + devShim.installed = true + local delegate = require + _G.require = function(name, ...) + -- only the mod's own call is the mod's doing; whatever that module + -- requires in turn is the engine wiring itself up + if devShim.depth == 0 then scanRequire(name) end + devShim.depth = devShim.depth + 1 + local ok, result = pcall(delegate, name, ...) + devShim.depth = devShim.depth - 1 + if not ok then error(result, 0) end + return result + end +end + +-- opts.fs injects a filesystem (read/getInfo/load/getDirectoryItems, plus +-- write where enable-state should persist) so the loader runs headless under +-- plain Lua; the default is love.filesystem. opts.dev forces the dev-mode +-- tripwire on for tests that cannot set the environment. +function Loader.new(opts) + local dev = opts and opts.dev + if dev == nil then dev = os.getenv("POKEPORT_DEV") == "1" end local self = setmetatable({ mods = {}, loaded = {}, errors = {}, initialized = false, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, + exports = {}, migrations = {}, order = {}, + modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, + fs = (opts and opts.fs) or (love and love.filesystem), + dev = dev, }, Loader) - for _, name in ipairs(REGISTRY_NAMES) do - self.content[name] = Registry.new(name) + assert(self.fs, "Loader.new requires opts.fs when love is unavailable") + for name, spec in pairs(Schemas.REGISTRIES) do + self.content[name] = Registry.new(name, spec) end self.disabled = {} return self @@ -63,15 +148,17 @@ end function Loader:_loadState() self.disabled = {} - local options = SaveData.loadOptions() + local options = SaveData.loadOptions(self.fs) for id, enabled in pairs(options.mods or {}) do if enabled == false then self.disabled[id] = true end end + -- mod.options reads through this; M11 owns writing it back + self.modOptions = options.modOptions or {} -- Migrate the original prototype manager's separate state file into the -- normal persistent options file once. New Game never resets options. - if next(options.mods or {}) == nil and love.filesystem.getInfo - and love.filesystem.getInfo(MOD_STATE_FILE) then - local chunk = love.filesystem.load(MOD_STATE_FILE) + if next(options.mods or {}) == nil and self.fs.getInfo + and self.fs.getInfo(MOD_STATE_FILE) then + local chunk = self.fs.load(MOD_STATE_FILE) local ok, state = chunk and pcall(chunk) if ok and type(state) == "table" then for id, disabled in pairs(state) do @@ -80,18 +167,20 @@ function Loader:_loadState() self.disabled[id] = true end end - SaveData.saveOptions(options) + if self.fs.write then SaveData.saveOptions(options, self.fs) end end end end function Loader:_saveState() - local options = SaveData.loadOptions() + -- a read-only injected fs keeps enable toggles in-memory only + if not self.fs.write then return end + local options = SaveData.loadOptions(self.fs) options.mods = options.mods or {} for id in pairs(self.mods) do options.mods[id] = not self.disabled[id] end - SaveData.saveOptions(options) + SaveData.saveOptions(options, self.fs) end function Loader:setEnabled(id, enabled) @@ -103,18 +192,19 @@ function Loader:setEnabled(id, enabled) end function Loader:_discover() - if not love.filesystem.getDirectoryItems then return end + if not self.fs.getDirectoryItems then return end local roots = { "mods" } for _, root in ipairs(roots) do - if love.filesystem.getInfo(root) then - for _, name in ipairs(love.filesystem.getDirectoryItems(root)) do + if self.fs.getInfo(root) then + for _, name in ipairs(self.fs.getDirectoryItems(root)) do local path = root .. "/" .. name - local info = love.filesystem.getInfo(path) + local info = self.fs.getInfo(path) if info and info.type == "directory" then - local manifest, err = readManifest(path) + local manifest, err = readManifest(self.fs, path) if manifest then if self.mods[manifest.id] then - self.errors[#self.errors + 1] = manifest.id .. ": duplicate mod id" + self.errors[#self.errors + 1] = + ("%s: duplicate mod id (ignored %s)"):format(manifest.id, path) else self.mods[manifest.id] = { manifest = manifest, path = path } end @@ -127,111 +217,766 @@ function Loader:_discover() end end +-- ------- validate and resolve + +-- a failed mod keeps the user's enable flag (the manager still shows it as +-- enabled-but-broken) and is treated as absent by every later phase +function Loader:_fail(mod, state, reason) + if mod.failed then return end + mod.failed, mod.state, mod.failure = true, state, reason + self.errors[#self.errors + 1] = mod.manifest.id .. ": " .. reason + Logger.error("mod %s failed: %s", mod.manifest.id, reason) +end + +local function isActive(mod) + return mod.enabled and not mod.failed +end + +function Loader:_exists(path) + if not self.fs.getInfo then return true end + return self.fs.getInfo(path) ~= nil +end + +-- static per-manifest checks that need the filesystem or the engine version. +-- Enabled mods only: a mod the user switched off is not a boot problem +function Loader:_validate() + for _, id in ipairs(orderedIds(self.mods, isActive)) do + local mod = self.mods[id] + local manifest = mod.manifest + local reason + if not self:_exists(mod.path .. "/" .. manifest.entry) then + reason = "entry file missing: " .. manifest.entry + elseif manifest.options_schema + and not self:_exists(mod.path .. "/" .. manifest.options_schema) then + reason = "options_schema file missing: " .. manifest.options_schema + elseif manifest.assets_transforms + and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then + reason = "assets_transforms file missing: " .. manifest.assets_transforms + elseif manifest.game_version then + local ok, err = Semver.satisfies(Version.engine, manifest.game_version) + if not ok then + reason = ("needs game version %s, engine is %s") + :format(manifest.game_version, Version.engine) + if err then reason = reason .. " (" .. err .. ")" end + end + end + if reason then self:_fail(mod, "invalid", reason) end + end +end + +-- hard dependencies must exist, be enabled, have survived, and satisfy their +-- range; run to a fixpoint so failures propagate to dependents transitively +function Loader:_enforceDependencies() + local changed = true + while changed do + changed = false + for _, id in ipairs(orderedIds(self.mods, isActive)) do + local mod = self.mods[id] + for _, spec in ipairs(mod.manifest.dependencySpecs) do + local dep = self.mods[spec.id] + local reason + if not dep then + reason = "missing dependency: " .. spec.id + elseif not dep.enabled then + reason = ("dependency %s is disabled"):format(spec.id) + elseif dep.failed then + reason = ("dependency %s failed to load"):format(spec.id) + elseif spec.range + and not Semver.satisfies(dep.manifest.version, spec.range) then + reason = ("needs %s@%s, found %s") + :format(spec.id, spec.range, dep.manifest.version) + end + if reason then + self:_fail(mod, "blocked_dependency", reason) + changed = true + break + end + end + end + end +end + +-- Tarjan SCC over the hard-dependency graph: only a cycle's own members +-- fail, so an unrelated mod beside a cycle still loads +function Loader:_failCycles() + local mods = self.mods + local counter, stack, onStack, index, low = 0, {}, {}, {}, {} + local cycles = {} + local function connect(id) + counter = counter + 1 + index[id], low[id] = counter, counter + stack[#stack + 1] = id + onStack[id] = true + local selfEdge = false + for _, spec in ipairs(mods[id].manifest.dependencySpecs) do + local dep = mods[spec.id] + if spec.id == id then selfEdge = true end + if dep and isActive(dep) and spec.id ~= id then + if not index[spec.id] then + connect(spec.id) + if low[spec.id] < low[id] then low[id] = low[spec.id] end + elseif onStack[spec.id] and index[spec.id] < low[id] then + low[id] = index[spec.id] + end + end + end + if low[id] == index[id] then + local component = {} + repeat + local top = table.remove(stack) + onStack[top] = false + component[#component + 1] = top + until top == id + if #component > 1 or selfEdge then cycles[#cycles + 1] = component end + end + end + for _, id in ipairs(orderedIds(mods, isActive)) do + if not index[id] then connect(id) end + end + for _, component in ipairs(cycles) do + table.sort(component) + local trace = table.concat(component, " -> ") .. " -> " .. component[1] + for _, id in ipairs(component) do + self:_fail(mods[id], "blocked_dependency", "circular dependency: " .. trace) + end + end +end + +-- the declaring mod loses: it asserted the incompatibility, and judging every +-- claim against one snapshot makes a mutual pair fail together +function Loader:_enforceConflicts() + local doomed = {} + for _, id in ipairs(orderedIds(self.mods, isActive)) do + local mod = self.mods[id] + for _, spec in ipairs(mod.manifest.conflictSpecs) do + local other = self.mods[spec.id] + if other and isActive(other) + and (not spec.range + or Semver.satisfies(other.manifest.version, spec.range)) then + doomed[#doomed + 1] = { mod = mod, + reason = ("conflicts with %s %s"):format(spec.id, other.manifest.version) } + break + end + end + end + for _, entry in ipairs(doomed) do + self:_fail(entry.mod, "conflict", entry.reason) + end +end + +-- Kahn over the surviving graph with the ready set kept in (priority, id) +-- order, so dependencies come first and the rest matches the v1 contract +function Loader:_order() + local pending, indegree, dependents = {}, {}, {} + for _, id in ipairs(orderedIds(self.mods, isActive)) do + pending[id], indegree[id] = true, 0 + end + for id in pairs(pending) do + local manifest = self.mods[id].manifest + local function edge(depId) + if not pending[depId] or depId == id then return end + dependents[depId] = dependents[depId] or {} + dependents[depId][#dependents[depId] + 1] = id + indegree[id] = indegree[id] + 1 + end + for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end + -- optional dependencies order without requiring anything + for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end + end + local ordered = {} + local function nextId() + local best + for id in pairs(pending) do + if indegree[id] == 0 then + if not best then + best = id + else + local pa, pb = self.mods[id].manifest.priority, + self.mods[best].manifest.priority + if pa < pb or (pa == pb and id < best) then best = id end + end + end + end + if best then return best end + -- optional dependencies can close a loop the hard-dependency cycle check + -- deliberately ignores; break it at the lowest-ordered id rather than + -- silently dropping the mods + local leftovers = {} + for id in pairs(pending) do leftovers[#leftovers + 1] = id end + if #leftovers == 0 then return nil end + table.sort(leftovers) + Logger.warn("optional dependency loop broken at %s", leftovers[1]) + return leftovers[1] + end + while true do + local id = nextId() + if not id then break end + pending[id], indegree[id] = nil, nil + ordered[#ordered + 1] = self.mods[id] + for _, dependent in ipairs(dependents[id] or {}) do + if indegree[dependent] then indegree[dependent] = indegree[dependent] - 1 end + end + end + return ordered +end + +-- merge order is a property of the target paths, never of pairs(): a +-- whole-table registry ("audio") has to land before the granular ones nested +-- under it ("audio.sfx"), or its subtable swap discards every id they already +-- wrote into the object it replaces. A strict prefix always has fewer +-- segments, so shallowest-first buys that; the name breaks ties so the same +-- content always merges the same way. +function Loader:_mergeOrder() + local names, depth = {}, {} + for name, registry in pairs(self.content) do + names[#names + 1] = name + local segments = 0 + for _ in (registry.spec.target or ""):gmatch("[^%.]+") do + segments = segments + 1 + end + depth[name] = segments + end + table.sort(names, function(a, b) + if depth[a] ~= depth[b] then return depth[a] < depth[b] end + return a < b + end) + return names +end + +function Loader:_resolve() + self:_enforceDependencies() + self:_failCycles() + self:_enforceDependencies() + self:_enforceConflicts() + self:_enforceDependencies() + return self:_order() +end + +-- per-registry accessor bound to one mod: schema violations are load +-- errors for api 2 mods and attributed warnings for api 1 (compat), and a +-- deprecated name warns once per mod on first use +function Loader:_contentApi(mod, registry, deprecation) + local loader = self + local modId = mod.manifest.id + local apiLevel = mod.manifest.api or 1 + local warned = false + local function note() + if deprecation and not warned then + warned = true + Logger.warn("[%s] %s", modId, deprecation) + end + end + local function validate(mode, id, value) + local ok, err = Schemas.check(registry.spec, registry.name, id, value, mode) + if ok then return end + if apiLevel >= 2 then error(err, 0) end + Logger.warn("[%s] %s", modId, err) + end + return { + register = function(_, id, value) + note() + validate("register", id, value) + loader:_journal(registry.name) + return registry:register(id, value, modId) + end, + override = function(_, id, value) + note() + validate("override", id, value) + loader:_journal(registry.name) + return registry:override(id, value, modId) + end, + patch = function(_, id, partial) + note() + validate("patch", id, partial) + loader:_journal(registry.name) + return registry:patch(id, partial, modId) + end, + remove = function(_, id) + note() + loader:_journal(registry.name) + return registry:remove(id, modId) + end, + get = function(_, id) + note() + return registry:get(id) + end, + each = function() + note() + return registry:each() + end, + } +end + +-- mod.commands is sugar over the commands registry; the engine's own verbs +-- are registered there too, so replacing one has to say override +function Loader:_registerCommand(modId, verb, fn) + assert(type(verb) == "string" and verb ~= "", "command verb is required") + assert(type(fn) == "function", "command handler must be a function") + self:_journal("commands") + return self.content.commands:register(verb, fn, modId) +end + function Loader:_api(mod) local loader = self + local modId = mod.manifest.id local api = { - id = mod.manifest.id, + id = modId, version = mod.manifest.version, path = mod.path, + -- a deep copy: what a mod does to its own view never reaches the loader + manifest = Merge.deepCopy(mod.manifest), content = {}, - events = { on = function(_, name, callback, priority) - return loader.events:on(name, callback, priority) - end }, + exports = {}, + DELETE = Registry.DELETE, + events = { + on = function(_, name, callback, priority) + return loader.events:on(name, callback, priority, modId) + end, + once = function(_, name, callback, priority) + return loader.events:once(name, callback, priority, modId) + end, + -- mods broadcast under their own prefix only, so no mod can forge an + -- engine event; exports stay the call-style channel + emit = function(_, name, payload) + local prefix = "mod." .. modId .. "." + if type(name) ~= "string" or name:sub(1, #prefix) ~= prefix then + error(("[%s] mods may only emit %s* events"):format(modId, prefix), 0) + end + return loader.events:emit(name, payload) + end, + }, hooks = { wrap = function(_, name, callback, priority) - return loader.hooks:wrap(name, callback, priority) + return loader.hooks:wrap(name, callback, priority, modId) + end }, + -- the widget toolkit facade (12 4.5) is one shared surface, not + -- per-mod state; each widget inside it loads on first touch + ui = ModUI, + -- namespaced per mod; M11 backs these with save.modData / + -- options.modOptions, the shape mods compile against is already final + save = { + get = function(_, key, default) + local bucket = loader.modSave[modId] + local value = bucket and bucket[key] + if value == nil then return default end + return value + end, + set = function(_, key, value) + local bucket = loader.modSave[modId] + if not bucket then + bucket = {} + loader.modSave[modId] = bucket + end + bucket[key] = value + end, + }, + options = { + define = function(_, schema) + assert(type(schema) == "table", "options schema must be a table of rows") + for _, row in ipairs(schema) do + assert(type(row) == "table" and type(row.key) == "string" and row.key ~= "", + "each options row needs a string key") + end + loader.optionSchemas[modId] = schema + return schema + end, + get = function(_, key) + local stored = loader.modOptions[modId] + if stored ~= nil and stored[key] ~= nil then return stored[key] end + for _, row in ipairs(loader.optionSchemas[modId] or {}) do + if row.key == key then return row.default end + end + return nil + end, + }, + commands = { register = function(_, verb, fn) + return loader:_registerCommand(modId, verb, fn) + end }, + -- M11 runs these against save.meta; recording them is what M2 owes + migrations = { add = function(_, since, fn) + assert(type(since) == "string" and since ~= "", + "migrations need the version they upgrade from") + assert(type(fn) == "function", "migration must be a function") + local list = loader.migrations[modId] + if not list then + list = {} + loader.migrations[modId] = list + end + list[#list + 1] = { since = since, apply = fn } + return fn end }, log = { - info = function(_, fmt, ...) Logger.info("[%s] " .. fmt, mod.manifest.id, ...) end, - warn = function(_, fmt, ...) Logger.warn("[%s] " .. fmt, mod.manifest.id, ...) end, - error = function(_, fmt, ...) Logger.error("[%s] " .. fmt, mod.manifest.id, ...) end, + info = function(_, fmt, ...) Logger.info("[%s] " .. fmt, modId, ...) end, + warn = function(_, fmt, ...) Logger.warn("[%s] " .. fmt, modId, ...) end, + error = function(_, fmt, ...) Logger.error("[%s] " .. fmt, modId, ...) end, }, } - for _, name in ipairs(REGISTRY_NAMES) do - api.content[name] = { - register = function(_, id, value) - return loader.content[name]:register(id, value, mod.manifest.id) - end, - override = function(_, id, value) - return loader.content[name]:override(id, value, mod.manifest.id) - end, - get = function(_, id) - return loader.content[name]:get(id) - or (loader.baseData and loader.baseData[name] - and loader.baseData[name][id]) - end, - } + self.exports[modId] = api.exports + -- a handle, not the mod object: {id, version, exports} or nil when the + -- other mod is absent, disabled, failed, or has not run yet. Tolerates + -- mod:find(id) as well as the documented mod.find(id). + api.find = function(first, second) + local otherId = second == nil and first or second + local other = loader.mods[otherId] + if not other or not isActive(other) then return nil end + local exports = loader.exports[otherId] + if exports == nil then return nil end + return { id = otherId, version = other.manifest.version, exports = exports } end - api.assets = api.content + for name, registry in pairs(self.content) do + local deprecation = registry.spec.deprecated + and ("the %s registry is deprecated; use %s") + :format(name, registry.spec.deprecated.useInstead) + api.content[name] = self:_contentApi(mod, registry, deprecation) + end + for alias, canonical in pairs(Schemas.ALIASES) do + api.content[alias] = self:_contentApi(mod, self.content[canonical], + ("the %s registry is deprecated; use %s"):format(alias, canonical)) + end + -- assets keeps the v1 alias to the content accessors and adds the file + -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve + api.assets = setmetatable({ + path = function(_, relative) return mod.path .. "/" .. relative end, + image = function(_, relative) + local full = mod.path .. "/" .. relative + local cached = loader.imageCache[full] + if cached then return cached end + assert(love and love.graphics, + ("[%s] mod.assets:image needs a graphics context"):format(modId)) + local image = love.graphics.newImage(full) + loader.imageCache[full] = image + return image + end, + }, { __index = api.content }) function api:read(relative) local path = self.path .. "/" .. relative - return love.filesystem.read(path) + return loader.fs.read(path) end + -- mod.world materializes on first touch, like the image helper above: a + -- headless load must not drag the world stack in, and the Game the facade + -- acts on is still being wired when the entry chunk runs + local world + setmetatable(api, { __index = function(_, key) + if key ~= "world" then return nil end + if world then return world end + local game = loader:_game() + local module = game and engineRequire("src.world.WorldAPI") + if not module then return nil end + world = module.new(game, modId) + return world + end }) return api end +-- the live Game. An injected reference wins so a headless caller can hand +-- over a stub; otherwise the boot singleton, whose stack and overworld fill +-- in after this loader returns -- holding the table keeps the facade live. +function Loader:_game() + return self.game or engineRequire("src.core.Game") +end + function Loader:_loadMod(mod) local path = mod.path .. "/" .. mod.manifest.entry - local chunk, err = love.filesystem.load(path) + local chunk, err = self.fs.load(path) if not chunk then error(err or ("unable to load " .. path)) end local api = self:_api(mod) local result = chunk(api) if type(result) == "function" then result(api) end + -- a mod that replaced the table wholesale (mod.exports = {...}) still + -- publishes what its dependents will see + self.exports[mod.manifest.id] = api.exports +end + +-- remember which registries a mod touched so a failing entry chunk can be +-- undone with one owner-wide op purge per registry +function Loader:_journal(name) + local journal = self.journal + if journal then journal[name] = true end +end + +-- a failing mod leaves zero residue: its ops are dropped before the merge +-- loop ever runs, and every subscription, export, command, option schema and +-- migration it took goes with them. The journal only exists around an +-- entry chunk; a later failure (script validation) purges every registry. +function Loader:_rollback(modId) + for name in pairs(self.journal or self.content) do + self.content[name]:rollback(modId) + end + self.events:removeOwner(modId) + self.hooks:removeOwner(modId) + self.exports[modId] = nil + self.optionSchemas[modId] = nil + self.migrations[modId] = nil + self.modSave[modId] = nil +end + +-- a mod that explicitly swears it stays link-compatible while writing into a +-- link-relevant registry gets one attributed warning; the default for a +-- content profile is not a claim, so only a written affects_link is judged. +-- The fingerprint itself is derived from merged data either way (M12) +function Loader:_checkLinkClaims(mod) + if mod.manifest.raw.affects_link ~= false then return end + for name, registry in pairs(self.content) do + if Manifest.LINK_REGISTRIES[name] then + for _, list in pairs(registry.ops) do + for _, entry in ipairs(list) do + if entry.owner == mod.manifest.id then + Logger.warn("[%s] declares affects_link = false but writes to %s", + mod.manifest.id, name) + return + end + end + end + end + end +end + +-- ------- script validation (09 §4.9) + +-- Every row list reachable from a map_scripts contribution is checked +-- against the merged command set once all entry chunks have run, before the +-- merge writes the chains home. Findings fail an api 2 owner outright -- +-- the mod is purged like an entry-chunk error -- while api 1 and engine +-- owners keep the v1 runtime skip and get attributed warnings. +function Loader:_validateScripts() + local registry = self.content.map_scripts + if not registry or next(registry.ops) == nil then return end + local MapScripts = engineRequire("src.script.MapScripts") + if not MapScripts then return end + local commands = self.content.commands + local function lookup(verb) return commands:get(verb) ~= nil end + local failed = false + for mapId in pairs(registry.ops) do + local chain = registry:chain(mapId) + local owners = registry:chainOwners(mapId) + for i = 1, #chain do + local findings = MapScripts.validateContribution(chain[i], lookup) + if #findings > 0 then + local owner = owners[i] + local mod = owner and self.mods[owner] + local reason = ("map_scripts %s: %s"):format(mapId, + table.concat(findings, "; ")) + if mod and (mod.manifest.api or 1) >= 2 then + self:_fail(mod, "failed", reason) + failed = true + else + Logger.warn("[%s] %s", tostring(owner or Schemas.ENGINE), reason) + end + end + end + end + if not failed then return end + -- purge the failed mods and whatever dependency enforcement takes with + -- them, exactly as an entry-chunk failure would have + self:_enforceDependencies() + for i = #self.loaded, 1, -1 do + local mod = self.loaded[i] + if mod.failed then + self:_rollback(mod.manifest.id) + table.remove(self.loaded, i) + end + end + for i = #self.order, 1, -1 do + local mod = self.mods[self.order[i]] + if mod and mod.failed then table.remove(self.order, i) end + end +end + +-- ------- audio provenance +-- An audio def only fails when its cue fires, long after the load phase has +-- handed its report to the manager, so the merge leaves behind who wrote +-- each def for Music/Sound to name in the failure (13.3). Engine records +-- stay unstamped on purpose: they resolve to "base", which Runtime.reportError +-- keeps out of the manager's error feed because no mod can be blamed for them. + +local AUDIO_OWNERS = { + music = "songs", sfx = "sfx", cries = "cries", map_songs = "mapSongs", +} + +local function stampAudioOwners(data, name, registry) + local key = AUDIO_OWNERS[name] + if not key then return end + local owners = Data.ensure(data, "audio._owners") + local map = owners[key] or {} + for id in pairs(registry.ops) do + local owner = registry.owners[id] + -- a tombstoned id has no def left to attribute, and a resurrected one + -- belongs to whoever wrote it last + if owner == nil or owner == Schemas.ENGINE or registry:get(id) == nil then + map[id] = nil + else + map[id] = owner + end + end + owners[key] = map end function Loader:load(data) self.baseData = data + -- every registry folds against the pristine view of its Data target; + -- resolution is lazy so optional namespaces may appear later + for _, registry in pairs(self.content) do + local target = registry.spec.target + if target then + registry.base = function() + return data and resolvePath(data, target) + end + end + end + -- vanilla content is registrations too, and they land before discovery so + -- a mod's register collides with the engine's and has to say override + require("src.mods.Builtins").install(self.content, data) self:_loadState() self:_discover() - local ok, ordered = pcall(topoSort, self.mods) - if not ok then - self.errors[#self.errors + 1] = ordered - Logger.error("mod dependency resolution failed: %s", tostring(ordered)) - return false + for id, mod in pairs(self.mods) do + mod.enabled = not self.disabled[id] + mod.state = mod.enabled and "pending" or "disabled" end + -- engine call sites reach these buses -- and this error feed, for failures + -- that only surface at play time -- through Runtime from here on + Runtime.install(self.events, self.hooks, self.errors) + self:_validate() + local ordered = self:_resolve() + if self.dev then self:_installDevShim() end for _, mod in ipairs(ordered) do - mod.enabled = not self.disabled[mod.manifest.id] - local success, err = true, nil - if mod.enabled then - success, err = pcall(self._loadMod, self, mod) - end - if success and mod.enabled then - self.loaded[#self.loaded + 1] = mod - Logger.info("loaded mod %s %s", mod.manifest.id, mod.manifest.version) - else - self.errors[#self.errors + 1] = mod.manifest.id .. ": " .. tostring(err) - Logger.error("mod %s failed: %s", mod.manifest.id, tostring(err)) + -- a mod ahead of this one may have failed and taken its dependents with + -- it, so the order list is filtered as it is walked + if isActive(mod) then + local modId = mod.manifest.id + self.journal = {} + -- the dev tripwire attributes requires to whoever is running + Runtime.currentMod = modId + local success, err = pcall(self._loadMod, self, mod) + Runtime.currentMod = nil + if not success then self:_rollback(modId) end + self.journal = nil + if success then + mod.state = "loaded" + self.loaded[#self.loaded + 1] = mod + self.order[#self.order + 1] = modId + self:_checkLinkClaims(mod) + Logger.info("loaded mod %s %s", modId, mod.manifest.version) + else + self:_fail(mod, "failed", tostring(err)) + self:_enforceDependencies() + end end end - -- Native content registrations override the imported base definitions. - for name, registry in pairs(self.content) do - local target = data and data[name] - if name == "music" and data and data.audio then - data.audio.songs = data.audio.songs or {} - target = data.audio.songs - end - if type(target) == "table" then - for id, value in pairs(registry.values) do target[id] = value end + -- the commands registry is final once every entry chunk has run, so + -- each map_scripts contribution's rows can be judged before they merge + self:_validateScripts() + -- merge: fold every touched id from its pristine base value and write it + -- home, creating the Data namespace when the base modules never shipped + -- one. A registry nobody wrote to -- engine included -- is skipped, so + -- the namespaces that appear are exactly the ones with content behind them. + for _, name in ipairs(self:_mergeOrder()) do + local registry = self.content[name] + local spec = registry.spec + if data and spec.target and next(registry.ops) ~= nil then + local target = Data.ensure(data, spec.target) + if spec.write then + -- ids that do not map one-to-one onto target keys (type_chart's + -- ordered rows, battle_anims' per-kind subtables) place themselves + spec.write(target, registry) + elseif spec.semantics == "compose" then + for id in pairs(registry.ops) do + local chain = registry:chain(id) + if #chain == 0 then + -- an emptied chain still has to say which kind of empty it is: + -- a tombstone keeps the (empty) chain so the consumer drops its + -- own base contribution too, while a chain nobody wrote to + -- leaves the id untouched and base dispatches as it always did + if registry:chainReplacesBase(id) then + target[id] = { replacesBase = true } + else + target[id] = nil + end + else + -- owner records ride the chain under a named key ipairs + -- skips, so the consumer can attribute each contribution + -- (map_scripts builds runner sources from these) + local owners = registry:chainOwners(id) + for i = 1, #chain do + local owner = owners[i] + local mod = owner and self.mods[owner] + owners[i] = { modId = mod and owner or nil, + strict = mod and (mod.manifest.api or 1) >= 2 or nil } + end + chain.owners = owners + -- an override chain is a total conversion: the consumer must + -- leave its own base contribution out (09 4.4) + chain.replacesBase = registry:chainReplacesBase(id) or nil + target[id] = chain + end + end + else + local tombstones = {} + for id in pairs(registry.ops) do + local value = registry:get(id) + if value == nil then + tombstones[#tombstones + 1] = id + else + target[id] = value + end + end + -- tombstones survive the fold as an explicit delete pass so + -- consumers see the id as absent, not as a stale record + for _, id in ipairs(tombstones) do target[id] = nil end + end + stampAudioOwners(data, name, registry) end end + -- dangling f.id references are attributed to the id's last writer; + -- api 1 mods keep the warning-only compat path + if data then + for _, problem in ipairs(Schemas.crossValidate(self, data)) do + local ownerMod = problem.owner and self.mods[problem.owner] + local apiLevel = ownerMod and (ownerMod.manifest.api or 1) or 1 + local message = tostring(problem.owner or "?") .. ": " .. problem.message + if apiLevel >= 2 then + self.errors[#self.errors + 1] = message + Logger.error("%s", message) + else + Logger.warn("%s", message) + end + end + end + -- content freezes at the merge boundary; the event/hook buses stay open + -- so mods may subscribe at any point for the life of the process + for _, registry in pairs(self.content) do + registry:freeze() + end + -- the load set is final here, so every surviving mod's recipe builds its + -- derived art before the resolver is first asked to serve it; stamped, so + -- a boot that changed nothing pays only the stat + AssetTransform.run(self) + -- and the same final load set becomes the asset search path, so an + -- overrides/ file or a transform's output shadows the generated cache + -- from the next image load on. No mods means an empty search path, + -- which resolves every path to itself (14 §asset resolution). + Assets.installLoader(self) self.events:emit("mods.loaded", { loader = self, data = data }) - self.events:seal() - self.hooks:seal() self.initialized = true return #self.errors == 0 end +-- the manager reads api, profile, permissions, per-mod state and the load +-- order from here; enabled stays the user's flag so a failed mod still +-- renders as enabled-but-broken instead of silently switching itself off function Loader:status() local available, loaded = {}, {} for _, mod in pairs(self.mods) do local manifest = {} for key, value in pairs(mod.manifest) do manifest[key] = value end manifest.enabled = mod.enabled ~= false + manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") + manifest.error = mod.failure available[#available + 1] = manifest - if manifest.enabled then loaded[#loaded + 1] = manifest end + if manifest.state == "loaded" then loaded[#loaded + 1] = manifest end end table.sort(available, function(a, b) return a.id < b.id end) table.sort(loaded, function(a, b) return a.id < b.id end) - return { available = available, loaded = loaded, errors = self.errors } + return { available = available, loaded = loaded, errors = self.errors, + order = self.order } end return Loader diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 11385b88..53cb81f5 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -1,13 +1,41 @@ --- Built-in mod manager using the same tile boxes, cursor, spacing, and --- navigation language as the game's START menu. +-- Mod manager v2 (18-mod-manager-ux.md): one stack state routing a fixed +-- set of screens -- list (MODS/PROF/ERRS tabs), detail, options, +-- permissions, errors, apply -- over mapped input, so gamepad and touch +-- drive it like any other menu. Toggles resolve their dependency closure +-- before they land, edits stage until one apply/restart, and safe mode is +-- read from Runtime.safeMode (19 owns the detection). local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Semver = require("src.mods.Semver") +local Version = require("src.core.Version") +local Theme = require("src.ui.Theme") +local OptionRows = require("src.ui.OptionRows") local ManagerState = {} ManagerState.__index = ManagerState ManagerState.isOpaque = true +-- stamped here as well as by Screens.push so the F10 toggle in +-- Game:keypressed recognizes a directly-pushed instance +ManagerState.screenId = "ManagerState" -local CURSOR = 0xED -local DOWN_ARROW = 0xEE +-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it +-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy +local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" } + +local TABS = { "MODS", "PROFILES", "ERRORS" } +local TAB_LINE = { "[MODS] PROF ERRS", "MODS [PROF] ERRS", "MODS PROF [ERRS]" } + +local LIST_TOP = 3 -- first content row (tile y) +local LIST_ROWS = 11 -- single-line rows in the scroll region + +-- what the mod declared it does, shown before the player enables it +local PERMISSION_ROWS = { + engine_internals = { glyph = "!", text = "PATCHES ENGINE CODE" }, + network = { glyph = "!", text = "USES THE NETWORK" }, + filesystem = { glyph = "!", text = "READS/WRITES FILES" }, +} + +local OPTION_TYPES = { toggle = true, choice = true, number = true, text = true } local function wrap(text, width) local lines = {} @@ -38,69 +66,575 @@ local function wrap(text, width) return lines end +local function clampIndex(i, n) + if i < 1 then return n end + if i > n then return 1 end + return i +end + +-- enabled at boot is the loader's verdict, not the user's flag: setEnabled +-- flips manifest.enabled but never state, so the difference is exactly the +-- staged-since-boot set and survives closing the manager +local function bootEnabled(m) + return m.state ~= "disabled" +end + +-- ------- pure toggle resolution +-- The closure a requested flip drags along (18 "enable/disable flow"). +-- mods is id -> status manifest; enabledSet is the current desired set. +-- Module-level so tests table-drive it without a game. + +function ManagerState.resolveToggle(mods, id, want, enabledSet) + local r = { apply = {}, alsoEnable = {}, alsoDisable = {}, + conflicts = {}, missing = {}, badVersion = {} } + local function enabledAfter(mid) + if r.apply[mid] ~= nil then return r.apply[mid] end + return enabledSet[mid] and true or false + end + local function enableWalk(mid, root) + if r.apply[mid] then return end + r.apply[mid] = true + if not root then r.alsoEnable[#r.alsoEnable + 1] = mid end + local m = mods[mid] + if not m then return end + if m.game_version and not Semver.satisfies(Version.engine, m.game_version) then + r.badVersion[#r.badVersion + 1] = + { id = mid, need = m.game_version, got = Version.engine, engine = true } + end + for _, spec in ipairs(m.dependencySpecs or {}) do + local dep = mods[spec.id] + if not dep then + r.missing[#r.missing + 1] = spec.id + elseif spec.range and not Semver.satisfies(dep.version, spec.range) then + r.badVersion[#r.badVersion + 1] = + { id = spec.id, need = spec.range, got = dep.version } + elseif not enabledAfter(spec.id) then + enableWalk(spec.id, false) + end + end + end + local function disableWalk(mid, root) + if r.apply[mid] == false then return end + r.apply[mid] = false + if not root then r.alsoDisable[#r.alsoDisable + 1] = mid end + -- reverse hard deps: whoever needs mid has to switch off with it + for otherId, other in pairs(mods) do + if enabledAfter(otherId) then + for _, spec in ipairs(other.dependencySpecs or {}) do + if spec.id == mid then + disableWalk(otherId, false) + break + end + end + end + end + end + if want then enableWalk(id, true) else disableWalk(id, true) end + if want then + -- a conflict blocks whichever side declared it + for mid in pairs(r.apply) do + local m = mods[mid] + for _, spec in ipairs((m and m.conflictSpecs) or {}) do + local other = mods[spec.id] + if other and spec.id ~= mid and enabledAfter(spec.id) + and (not spec.range or Semver.satisfies(other.version, spec.range)) then + r.conflicts[#r.conflicts + 1] = spec.id + end + end + for otherId, other in pairs(mods) do + if otherId ~= mid and enabledAfter(otherId) then + for _, spec in ipairs(other.conflictSpecs or {}) do + if spec.id == mid and (not spec.range + or not m or Semver.satisfies(m.version, spec.range)) then + r.conflicts[#r.conflicts + 1] = otherId + end + end + end + end + end + end + return r +end + +-- ------- lifecycle + function ManagerState.new(game) return setmetatable({ game = game, - mode = "categories", - categoryIndex = 1, - modIndex = 1, + screen = "list", + tab = 1, + cursor = 1, scroll = 1, + backStack = {}, + descScroll = 1, restartPending = false, }, ManagerState) end function ManagerState:enter() - self:rebuildCategories() -end - -function ManagerState:rebuildCategories() - local status = self.game.modStatus or { available = {} } - self.categories = {} - self.byCategory = {} - for _, manifest in ipairs(status.available or {}) do - local category = manifest.category or "OTHER" - self.byCategory[category] = self.byCategory[category] or {} - self.byCategory[category][#self.byCategory[category] + 1] = manifest + self:refresh() + if Runtime.safeMode then + self.banner = "SAFE MODE - ALL MODS OFF" end - for category in pairs(self.byCategory) do - self.categories[#self.categories + 1] = category + self:snapCursor() +end + +function ManagerState:refresh() + local loader = self.game.mods + self.status = (loader and loader.status and loader:status()) + or self.game.modStatus or { available = {}, errors = {} } + self.byId = {} + for _, m in ipairs(self.status.available or {}) do + self.byId[m.id] = m + end + if self.currentMod then + self.currentMod = self.byId[self.currentMod.id] + end + self.restartPending = #self:stagedList() > 0 + -- a live set that drifted off the named profile reverts to ad-hoc + local opts = self:optionsTable() + if opts.activeProfile then + local p = self:findProfile(opts.activeProfile) + if not p or not self:matchesProfile(p) then + opts.activeProfile = nil + end end - table.sort(self.categories) - self.categoryIndex = math.min(self.categoryIndex, math.max(1, #self.categories)) end -function ManagerState:currentMods() - return self.byCategory[self.categories[self.categoryIndex]] or {} +function ManagerState:optionsTable() + local save = self.game.save + return (save and save.options) or {} end -function ManagerState:currentMod() - return self:currentMods()[self.modIndex] +function ManagerState:manifestMap() + return self.byId or {} end -function ManagerState:openCategory() - self.mode = "mods" - self.modIndex = 1 +function ManagerState:enabledSet() + local set = {} + for _, m in ipairs(self.status.available or {}) do + if m.enabled then set[m.id] = true end + end + return set end -function ManagerState:openMod() - self.mode = "detail" - self.scroll = 1 +function ManagerState:isStaged(m) + return (m.enabled and true or false) ~= bootEnabled(m) end -function ManagerState:toggleCurrent() - local manifest = self:currentMod() - if not manifest then return end - self.game.mods:setEnabled(manifest.id, not manifest.enabled) - self.game.modStatus = self.game.mods:status() - self.restartPending = true - self:rebuildCategories() - for _, candidate in ipairs(self:currentMods()) do - if candidate.id == manifest.id then - self.modIndex = _ +function ManagerState:stagedList() + local out = {} + for _, m in ipairs(self.status.available or {}) do + if self:isStaged(m) then out[#out + 1] = m end + end + table.sort(out, function(a, b) return a.id < b.id end) + return out +end + +function ManagerState:glyphFor(m) + if self:isStaged(m) then return GLYPH.staged end + if not m.enabled then return GLYPH.disabled end + if m.state == "blocked_dependency" then return GLYPH.blocked end + if m.error then return GLYPH.errored end + return " " +end + +-- ------- row models +-- Every screen is one flat row list the shared cursor walks; headers are +-- skipped by the cursor and drawn dim. + +function ManagerState:modRows() + local rows = {} + local byCategory, categories = {}, {} + for _, m in ipairs(self.status.available or {}) do + local cat = m.category or "OTHER" + if not byCategory[cat] then + byCategory[cat] = {} + categories[#categories + 1] = cat + end + table.insert(byCategory[cat], m) + end + table.sort(categories) + for _, cat in ipairs(categories) do + rows[#rows + 1] = { header = true, label = cat } + for _, m in ipairs(byCategory[cat]) do + rows[#rows + 1] = { mod = m, label = m.name or m.id, + glyph = self:glyphFor(m) } + end + end + if #rows == 0 then + rows[1] = { header = true, label = "NO MODS INSTALLED" } + end + return rows +end + +function ManagerState:profileRows() + local rows = {} + local opts = self:optionsTable() + for _, p in ipairs(opts.modProfiles or {}) do + rows[#rows + 1] = { profile = p, label = p.name, + glyph = opts.activeProfile == p.name and GLYPH.errored or " " } + end + rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." } + rows[#rows + 1] = { adhoc = true, + label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" } + return rows +end + +function ManagerState:errorLines(mod) + local lines = {} + if mod and mod.error then + for _, line in ipairs(wrap("FAILED: " .. mod.error, 16)) do + lines[#lines + 1] = line + end + end + for _, err in ipairs(self.status.errors or {}) do + for _, line in ipairs(wrap(err, 16)) do + lines[#lines + 1] = line + end + end + if #lines == 0 then lines[1] = "NO ERRORS" end + return lines +end + +function ManagerState:errorRows(mod) + local rows = {} + for _, line in ipairs(self:errorLines(mod)) do + rows[#rows + 1] = { label = line, inert = true } + end + return rows +end + +function ManagerState:detailRows(m) + local rows = {} + rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", + action = function() self:beginToggle(m) end } + if self:schemaFor(m) then + rows[#rows + 1] = { label = "OPTIONS..", + action = function() self:openOptions(m) end } + end + if m.permissions and #m.permissions > 0 then + rows[#rows + 1] = { label = "PERMISSIONS..", + action = function() self:goTo("permissions") end } + end + if m.error then + rows[#rows + 1] = { label = "VIEW ERROR..", + action = function() self:goTo("errors") end } + end + rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end } + return rows +end + +function ManagerState:applyRows() + local rows = {} + rows[#rows + 1] = { label = "APPLY & RESTART", action = function() + self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end) + end } + rows[#rows + 1] = { label = "DISCARD CHANGES", action = function() + self:discardChanges() + end } + rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end } + return rows +end + +function ManagerState:permissionRows(m) + local rows = {} + for _, name in ipairs(m.permissions or {}) do + local info = PERMISSION_ROWS[name] + rows[#rows + 1] = { inert = true, + glyph = info and info.glyph or "?", + label = info and info.text or name } + end + if #rows == 0 then + rows[1] = { inert = true, label = "DATA & API ONLY" } + end + return rows +end + +function ManagerState:rowsForScreen() + if self.screen == "list" then + if self.tab == 1 then return self:modRows() end + if self.tab == 2 then return self:profileRows() end + return self:errorRows(nil) + elseif self.screen == "detail" then + return self.currentMod and self:detailRows(self.currentMod) or {} + elseif self.screen == "errors" then + return self:errorRows(self.currentMod) + elseif self.screen == "permissions" then + return self.currentMod and self:permissionRows(self.currentMod) or {} + elseif self.screen == "apply" then + return self:applyRows() + end + return {} +end + +-- ------- navigation + +function ManagerState:goTo(screen) + self.backStack[#self.backStack + 1] = + { screen = self.screen, cursor = self.cursor, scroll = self.scroll } + self.screen = screen + self.cursor = 1 + self.scroll = screen == "options" and 0 or 1 + self.descScroll = 1 + self:snapCursor() +end + +function ManagerState:goBack() + local prev = table.remove(self.backStack) + if prev then + self.screen = prev.screen + self.cursor = prev.cursor + self.scroll = prev.scroll + self:refresh() + else + self.game.stack:pop() + end +end + +function ManagerState:snapCursor() + local rows = self:rowsForScreen() + local row = rows[self.cursor] + if row and not row.header then return end + for i, candidate in ipairs(rows) do + if not candidate.header then + self.cursor = i + return + end + end + self.cursor = 1 +end + +function ManagerState:moveCursor(dir) + local rows = self:rowsForScreen() + local n = #rows + if n == 0 then return end + local i = self.cursor + for _ = 1, n do + i = clampIndex(i + dir, n) + if not rows[i].header then + self.cursor = i break end end - self.mode = "detail" + -- keep the cursor inside the scroll window + if self.cursor < self.scroll then + self.scroll = self.cursor + elseif self.cursor > self.scroll + LIST_ROWS - 1 then + self.scroll = self.cursor - LIST_ROWS + 1 + end +end + +function ManagerState:adjustOrTab(dir) + if self.screen == "list" then + self.tab = clampIndex(self.tab + dir, #TABS) + self.cursor, self.scroll = 1, 1 + self:snapCursor() + elseif self.screen == "detail" then + self.descScroll = math.max(1, self.descScroll + dir) + else + for _ = 1, LIST_ROWS do self:moveCursor(dir) end + end +end + +function ManagerState:focusedRow() + return self:rowsForScreen()[self.cursor] +end + +function ManagerState:confirmSound() + if self.game.data then + require("src.core.Sound").play(self.game.data, "Press_AB") + end +end + +function ManagerState:notify(text) + self.notice = text + self.noticeTimer = 90 +end + +function ManagerState:activate() + local row = self:focusedRow() + if not row or row.header or row.inert then return end + self:confirmSound() + if row.action then + row.action() + elseif row.mod then + self.currentMod = row.mod + self:goTo("detail") + elseif row.profile then + self:applyProfile(row.profile) + elseif row.saveAs then + self:saveCurrentAs() + elseif row.adhoc then + self:optionsTable().activeProfile = nil + self:notify("AD-HOC SET ACTIVE") + end +end + +function ManagerState:pressStart() + if self.screen == "list" and self.tab == 2 then + local row = self:focusedRow() + if row and row.profile then + self:openConfirm({ "DELETE " .. row.profile.name .. "?" }, function() + self:deleteProfile(row.profile) + end) + return + end + end + if self.screen == "apply" then return end + if self.restartPending or Runtime.safeMode then + self:goTo("apply") + else + self:notify("NO CHANGES") + end +end + +function ManagerState:quickToggle() + if self.screen == "list" and self.tab == 1 then + local row = self:focusedRow() + if row and row.mod then self:beginToggle(row.mod) end + elseif self.screen == "list" and self.tab == 2 then + local row = self:focusedRow() + if row and row.profile then self:renameProfile(row.profile) end + elseif self.screen == "detail" and self.currentMod then + self:beginToggle(self.currentMod) + end +end + +function ManagerState:update() + if self.notice then + self.noticeTimer = (self.noticeTimer or 0) - 1 + if self.noticeTimer <= 0 then self.notice = nil end + end + local input = self.game.input + if self.overlay then return self:updateOverlay(input) end + if self.screen == "options" then return self:updateOptions(input) end + if input:wasPressed("up") then self:moveCursor(-1) + elseif input:wasPressed("down") then self:moveCursor(1) + elseif input:wasPressed("left") then self:adjustOrTab(-1) + elseif input:wasPressed("right") then self:adjustOrTab(1) + elseif input:wasPressed("a") then self:activate() + elseif input:wasPressed("b") then self:goBack() + elseif input:wasPressed("start") then self:pressStart() + elseif input:wasPressed("select") then self:quickToggle() + end +end + +-- ------- overlays + +function ManagerState:openBlocked(r) + local lines = {} + for _, depId in ipairs(r.missing) do + lines[#lines + 1] = "NEEDS " .. depId + lines[#lines + 1] = "NOT INSTALLED" + end + for _, otherId in ipairs(r.conflicts) do + local other = self.byId[otherId] + lines[#lines + 1] = "CONFLICTS WITH" + lines[#lines + 1] = (other and other.name or otherId) + lines[#lines + 1] = "DISABLE IT FIRST" + end + for _, bad in ipairs(r.badVersion) do + if bad.engine then + lines[#lines + 1] = "NEEDS ENGINE " .. bad.need + lines[#lines + 1] = "HAVE " .. bad.got + else + lines[#lines + 1] = "NEEDS " .. bad.id .. " " .. bad.need + end + end + self.overlay = { kind = "ok", lines = lines } +end + +function ManagerState:openCascade(r, m, want) + local lines = {} + if want then + local names = {} + for _, depId in ipairs(r.alsoEnable) do + local dep = self.byId[depId] + names[#names + 1] = dep and dep.name or depId + end + lines[#lines + 1] = "ALSO ENABLE" + lines[#lines + 1] = table.concat(names, ", ") .. "?" + else + local dep = self.byId[r.alsoDisable[1]] + lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS." + lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?" + end + self.overlay = { kind = "confirm", lines = lines, index = 1, + onYes = function() self:commitToggle(r.apply) end } +end + +function ManagerState:openConfirm(lines, onYes) + self.overlay = { kind = "confirm", lines = lines, index = 1, onYes = onYes } +end + +function ManagerState:updateOverlay(input) + local overlay = self.overlay + if overlay.kind == "ok" then + if input:wasPressed("a") or input:wasPressed("b") then + self.overlay = nil + end + return + end + if input:wasPressed("up") or input:wasPressed("down") then + overlay.index = overlay.index == 1 and 2 or 1 + elseif input:wasPressed("a") then + self:confirmSound() + self.overlay = nil + if overlay.index == 1 and overlay.onYes then overlay.onYes() end + elseif input:wasPressed("b") then + self.overlay = nil + end +end + +-- ------- the enable/disable flow + +function ManagerState:beginToggle(m) + if not m then return end + local want = not m.enabled + local loader = self.game.mods + local r + if loader and loader.resolveToggle then + r = loader:resolveToggle(m.id, want, self:enabledSet()) + else + r = ManagerState.resolveToggle(self:manifestMap(), m.id, want, + self:enabledSet()) + end + if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then + self:openBlocked(r) + elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then + self:openCascade(r, m, want) + else + self:commitToggle(r.apply) + end +end + +function ManagerState:commitToggle(apply) + local loader = self.game.mods + local opts = self:optionsTable() + for id, en in pairs(apply) do + if loader and loader.setEnabled then loader:setEnabled(id, en) end + -- mirror into the live options so a later writeOptions cannot revert + -- what setEnabled just persisted + opts.mods = opts.mods or {} + opts.mods[id] = en + end + if loader and loader.status then self.game.modStatus = loader:status() end + self:refresh() +end + +function ManagerState:discardChanges() + local loader = self.game.mods + local opts = self:optionsTable() + for _, m in ipairs(self:stagedList()) do + local en = bootEnabled(m) + if loader and loader.setEnabled then loader:setEnabled(m.id, en) end + opts.mods = opts.mods or {} + opts.mods[m.id] = en + end + if loader and loader.status then self.game.modStatus = loader:status() end + self:refresh() + self:notify("CHANGES DISCARDED") end function ManagerState:restartGame() @@ -111,132 +645,467 @@ function ManagerState:restartGame() end end -function ManagerState:back() - if self.mode == "detail" then - self.mode = "mods" - elseif self.mode == "mods" then - self.mode = "categories" - else - self.game.stack:pop() +-- ------- profiles (named enable-sets, not the manifest profile field) + +function ManagerState:findProfile(name) + for _, p in ipairs(self:optionsTable().modProfiles or {}) do + if p.name == name then return p end + end + return nil +end + +function ManagerState:matchesProfile(p) + for _, m in ipairs(self.status.available or {}) do + local want = p.enabled[m.id] ~= false + if (m.enabled and true or false) ~= want then return false end + end + return true +end + +function ManagerState:persistOptions() + if self.game.writeOptions then self.game:writeOptions() end +end + +function ManagerState:applyProfile(p) + local mods = self:manifestMap() + local set = self:enabledSet() + local combined = {} + for _, m in ipairs(self.status.available or {}) do + local want = p.enabled[m.id] ~= false + local cur = set[m.id] and true or false + if cur ~= want then + local r = ManagerState.resolveToggle(mods, m.id, want, set) + if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then + self:openBlocked(r) + return + end + for id, en in pairs(r.apply) do + combined[id] = en + set[id] = en or nil + end + end + end + self:commitToggle(combined) + self:optionsTable().activeProfile = p.name + self:persistOptions() + self:notify("PROFILE STAGED") +end + +function ManagerState:saveCurrentAs() + local NamingScreen = require("src.ui.NamingScreen") + self.game.stack:push(NamingScreen.new(self.game, { + title = "PROFILE NAME?", + maxLen = 10, + onDone = function(name) + local opts = self:optionsTable() + opts.modProfiles = opts.modProfiles or {} + local enabled = {} + for _, m in ipairs(self.status.available or {}) do + enabled[m.id] = m.enabled and true or false + end + local existing = self:findProfile(name) + if existing then + existing.enabled = enabled + else + opts.modProfiles[#opts.modProfiles + 1] = + { name = name, enabled = enabled } + end + opts.activeProfile = name + self:persistOptions() + self:refresh() + end, + })) +end + +function ManagerState:renameProfile(p) + local NamingScreen = require("src.ui.NamingScreen") + self.game.stack:push(NamingScreen.new(self.game, { + title = "RENAME?", + maxLen = 10, + default = p.name, + onDone = function(name) + local opts = self:optionsTable() + if opts.activeProfile == p.name then opts.activeProfile = name end + p.name = name + self:persistOptions() + end, + })) +end + +function ManagerState:deleteProfile(p) + local opts = self:optionsTable() + local profiles = opts.modProfiles or {} + for i, candidate in ipairs(profiles) do + if candidate == p then + table.remove(profiles, i) + break + end + end + if opts.activeProfile == p.name then opts.activeProfile = nil end + self:persistOptions() + self:snapCursor() +end + +-- ------- per-mod options (auto-UI from options_schema) + +-- the loader captured schemas when mod.options:define ran; a mod that only +-- shipped the manifest options_schema file gets it loaded here on demand +function ManagerState:schemaFor(m) + local loader = self.game.mods + if not loader then return nil end + local schema = loader.optionSchemas and loader.optionSchemas[m.id] + if schema == nil and m.options_schema and m.path + and loader.fs and loader.fs.load then + local chunk = loader.fs.load(m.path .. "/" .. m.options_schema) + if chunk then + local ok, rows = pcall(chunk) + if ok and type(rows) == "table" then + schema = rows + if loader.optionSchemas then loader.optionSchemas[m.id] = schema end + end + end + end + return schema +end + +function ManagerState:optionValue(modId, row) + local loader = self.game.mods + local stored = loader and loader.modOptions and loader.modOptions[modId] + local v = stored and stored[row.key] + if v == nil then v = row.default end + return v +end + +function ManagerState:setOption(modId, key, value) + local save = self.game.save + if save and save.options then + save.options.modOptions = save.options.modOptions or {} + local t = save.options.modOptions + t[modId] = t[modId] or {} + t[modId][key] = value + end + local loader = self.game.mods + if loader then + loader.modOptions = loader.modOptions or {} + loader.modOptions[modId] = loader.modOptions[modId] or {} + loader.modOptions[modId][key] = value + end + self:persistOptions() + if loader and loader.events then + loader.events:emit("mod.options_changed", + { mod = modId, key = key, value = value }) end end -function ManagerState:onKeyPressed(key) - local activate = key == "return" or key == "kpenter" or key == "z" - or key == "space" - if key == "escape" or key == "f10" or key == "x" or key == "backspace" then - self:back() +function ManagerState:buildOptionRows(m, schema) + local rows = {} + local modId = m.id + for _, row in ipairs(schema) do + if type(row) ~= "table" or type(row.key) ~= "string" or row.key == "" + or not OPTION_TYPES[row.type] then + -- malformed rows are skipped, reported where the errors screen reads + Runtime.reportError(modId, "options row skipped: " + .. tostring(type(row) == "table" and (row.key or row.type) or row)) + elseif row.type == "toggle" then + rows[#rows + 1] = { id = row.key, label = row.label or row.key, + value = function() + return self:optionValue(modId, row) and "ON" or "OFF" + end, + step = function() + self:setOption(modId, row.key, not self:optionValue(modId, row)) + return true + end } + elseif row.type == "choice" then + rows[#rows + 1] = { id = row.key, label = row.label or row.key, + value = function() + local cur = self:optionValue(modId, row) + for _, choice in ipairs(row.choices or {}) do + if choice[2] == cur then return choice[1] end + end + local first = (row.choices or {})[1] + return first and first[1] or "----" + end, + step = function(_, dir) + local choices = row.choices or {} + if #choices == 0 then return false end + local cur = self:optionValue(modId, row) + local index = 1 + for i, choice in ipairs(choices) do + if choice[2] == cur then index = i break end + end + index = clampIndex(index + dir, #choices) + self:setOption(modId, row.key, choices[index][2]) + return true + end } + elseif row.type == "number" then + local function clamp(v) + if row.min then v = math.max(row.min, v) end + if row.max then v = math.min(row.max, v) end + return v + end + rows[#rows + 1] = { id = row.key, label = row.label or row.key, + value = function() + return tostring(self:optionValue(modId, row) or 0) + end, + step = function(_, dir) + local cur = tonumber(self:optionValue(modId, row)) or 0 + self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1))) + return true + end, + activate = function() + local QuantityBox = require("src.ui.QuantityBox") + self.game.stack:push(QuantityBox.new(self.game, { + max = row.max or 99, + start = math.max(1, tonumber(self:optionValue(modId, row)) or 1), + onDone = function(qty) + if qty then self:setOption(modId, row.key, clamp(qty)) end + end, + })) + end } + elseif row.type == "text" then + rows[#rows + 1] = { id = row.key, label = row.label or row.key, + value = function() + return tostring(self:optionValue(modId, row) or "") + end, + activate = function() + local NamingScreen = require("src.ui.NamingScreen") + self.game.stack:push(NamingScreen.new(self.game, { + title = (row.label or row.key) .. "?", + maxLen = row.maxLen or 7, + default = self:optionValue(modId, row), + onDone = function(name) + self:setOption(modId, row.key, name) + end, + })) + end } + end + end + rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS", + value = function() return "" end, + activate = function() + for _, row in ipairs(schema) do + if type(row) == "table" and type(row.key) == "string" + and OPTION_TYPES[row.type] then + self:setOption(modId, row.key, row.default) + end + end + self:notify("DEFAULTS RESTORED") + end } + return rows +end + +function ManagerState:openOptions(m) + local schema = self:schemaFor(m) + if not schema then + self:notify("NO OPTIONS") return end - if self.mode == "categories" then - if key == "up" and #self.categories > 0 then - self.categoryIndex = self.categoryIndex > 1 and self.categoryIndex - 1 or #self.categories - elseif key == "down" and #self.categories > 0 then - self.categoryIndex = self.categoryIndex < #self.categories and self.categoryIndex + 1 or 1 - elseif activate and #self.categories > 0 then - self:openCategory() + self.optionRows = self:buildOptionRows(m, schema) + self:goTo("options") +end + +function ManagerState:updateOptions(input) + local rows = self.optionRows or {} + local n = #rows + if input:wasPressed("b") then + self:goBack() + return + end + if n == 0 then return end + if input:wasPressed("up") then + self.cursor = clampIndex(self.cursor - 1, n) + elseif input:wasPressed("down") then + self.cursor = clampIndex(self.cursor + 1, n) + elseif input:wasPressed("left") or input:wasPressed("right") + or input:wasPressed("a") then + local dir = input:wasPressed("left") and -1 or 1 + local row = rows[self.cursor] + if row.activate and input:wasPressed("a") then + self:confirmSound() + row.activate() + elseif row.step then + row.step(self.game, dir) end - elseif self.mode == "mods" then - local mods = self:currentMods() - if key == "up" and #mods > 0 then - self.modIndex = self.modIndex > 1 and self.modIndex - 1 or #mods - elseif key == "down" and #mods > 0 then - self.modIndex = self.modIndex < #mods and self.modIndex + 1 or 1 - elseif activate and #mods > 0 then - self:openMod() + end + self.scroll = OptionRows.clampScroll(self.cursor, self.scroll or 0, n, nil) +end + +-- ------- drawing + +local function drawTruncated(text, x, y, cols) + text = tostring(text or "") + if #text > cols then text = text:sub(1, cols) end + Font.draw(text, x, y) +end + +function ManagerState:drawRows(rows) + local last = math.min(#rows, self.scroll + LIST_ROWS - 1) + local y = LIST_TOP + for i = self.scroll, last do + local row = rows[i] + if row.header then + drawTruncated(row.label, 16, y * 8, 17) + else + if row.glyph and row.glyph ~= " " then + Font.draw(row.glyph, 16, y * 8) + end + drawTruncated(row.label, 32, y * 8, 15) + if i == self.cursor then + Font.drawCode(Theme.cursor, 8, y * 8) + end end + y = y + 1 + end + if #rows > last then + Font.drawCode(Theme.moreArrow, 18 * 8, (LIST_TOP + LIST_ROWS) * 8) + end +end + +function ManagerState:drawFooter(line1, line2) + if self.notice then + Font.draw(self.notice, 16, 15 * 8) + return + end + if line1 then Font.draw(line1, 16, 15 * 8) end + if line2 then Font.draw(line2, 16, 16 * 8) end +end + +function ManagerState:drawList() + Font.draw(TAB_LINE[self.tab], 16, 2 * 8) + self:drawRows(self:rowsForScreen()) + if self.tab == 1 then + self:drawFooter("A:OPEN SEL:TOGGLE", "START:APPLY B:EXIT") + elseif self.tab == 2 then + self:drawFooter("A:APPLY SEL:RENAME", "START:DELETE") else - if key == "up" then self.scroll = math.max(1, self.scroll - 1) - elseif key == "down" then self.scroll = self.scroll + 1 - elseif activate then - if self.restartPending then self:restartGame() - else self:toggleCurrent() end - end + self:drawFooter("UP/DOWN:SCROLL") end end -function ManagerState:update() end - -local function drawList(items, index, tx, ty, tw, th) - local visible = math.max(1, math.floor((th - 2) / 2)) - local first = math.max(1, index - visible + 1) - local y = ty + 1 - for itemIndex = first, math.min(#items, first + visible - 1) do - local itemLines = wrap(items[itemIndex], tw - 2) - if itemIndex == index then - Font.drawCode(CURSOR, (tx + 1) * 8, y * 8) - end - for lineIndex = 1, math.min(2, #itemLines) do - Font.draw(itemLines[lineIndex], (tx + 2) * 8, - (y + lineIndex - 1) * 8) - end - y = y + 2 +function ManagerState:drawDetail() + local m = self.currentMod + if not m then return end + local title = wrap(m.name or m.id, 14) + drawTruncated(title[1] .. " " .. (m.version or ""), 16, 2 * 8, 17) + local statusLine = m.enabled and "ENABLED" or "DISABLED" + if m.state == "blocked_dependency" then + statusLine = statusLine .. " ?" + elseif m.error then + statusLine = statusLine .. " !" end - if #items > first + visible - 1 then - Font.drawCode(DOWN_ARROW, (tx + tw - 2) * 8, (ty + th - 1) * 8) - end -end - -function ManagerState:drawDetail(manifest) - local title = wrap(manifest.name, 16) - Font.draw(title[1], 2 * 8, 4 * 8) - Font.draw(manifest.enabled and "ENABLED" or "DISABLED", 3 * 8, 6 * 8) - local lines = wrap(manifest.description, 16) - -- Rows 8-12 are description, row 13 is deliberately blank, and row 14 - -- is the option/restart action. + if self:isStaged(m) then statusLine = statusLine .. " (STAGED)" end + drawTruncated(statusLine, 16, 3 * 8, 17) + drawTruncated((m.category or "OTHER") .. " / " .. (m.profile or "content"), + 16, 4 * 8, 17) + local lines = wrap(m.error and ("FAILED: " .. m.error) or m.description, 16) local visible = 5 - for row = 1, visible do - local line = lines[self.scroll + row - 1] + for i = 1, visible do + local line = lines[self.descScroll + i - 1] if not line then break end - Font.draw(line, 2 * 8, (7 + row) * 8) + Font.draw(line, 16, (5 + i) * 8) end - if self.scroll + visible <= #lines then - Font.drawCode(DOWN_ARROW, 17 * 8, 12 * 8) + if self.descScroll + visible <= #lines then + Font.drawCode(Theme.moreArrow, 17 * 8, 10 * 8) end - if self.restartPending then - Font.draw("RESTART REQUIRED", 2 * 8, 14 * 8) - Font.draw("A:RESTART", 11 * 8, 15 * 8) + local rows = self:rowsForScreen() + local y = 11 + for i, row in ipairs(rows) do + drawTruncated(row.label, 32, y * 8, 15) + if i == self.cursor then Font.drawCode(Theme.cursor, 24, y * 8) end + y = y + 1 + end + self:drawFooter("A:CHOOSE B:BACK") +end + +function ManagerState:drawPermissions() + drawTruncated("PERMISSIONS", 16, 2 * 8, 17) + self:drawRows(self:rowsForScreen()) + self:drawFooter("DECLARED BY AUTHOR,", "NOT ENFORCED") +end + +function ManagerState:drawErrors() + drawTruncated("ERRORS", 16, 2 * 8, 17) + self:drawRows(self:rowsForScreen()) + self:drawFooter("UP/DOWN:SCROLL B:BACK") +end + +function ManagerState:drawApply() + drawTruncated("PENDING CHANGES", 16, 2 * 8, 17) + local staged = self:stagedList() + local y = LIST_TOP + local shown = math.min(#staged, 7) + for i = 1, shown do + local m = staged[i] + local verb = m.enabled and "ON " or "OFF " + drawTruncated(verb .. (m.name or m.id), 16, y * 8, 17) + y = y + 1 + end + if #staged == 0 then + Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8) + y = y + 1 + end + local rows = self:rowsForScreen() + local base = 12 + for i, row in ipairs(rows) do + drawTruncated(row.label, 32, (base + i - 1) * 8, 15) + if i == self.cursor then Font.drawCode(Theme.cursor, 24, (base + i - 1) * 8) end + end + self:drawFooter("A:CHOOSE B:BACK") +end + +function ManagerState:drawOverlay() + local overlay = self.overlay + local lines = {} + for _, raw in ipairs(overlay.lines) do + for _, line in ipairs(wrap(raw, 14)) do lines[#lines + 1] = line end + end + local th = math.max(6, #lines + (overlay.kind == "confirm" and 5 or 3)) + local ty = math.max(1, math.floor((18 - th) / 2)) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8) + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(2, ty, 16, th) + for i, line in ipairs(lines) do + drawTruncated(line, 4 * 8, (ty + i) * 8, 14) + end + if overlay.kind == "confirm" then + local yesY = ty + #lines + 1 + Font.draw("YES", 5 * 8, yesY * 8) + Font.draw("NO", 5 * 8, (yesY + 1) * 8) + Font.drawCode(Theme.cursor, 4 * 8, + (overlay.index == 1 and yesY or yesY + 1) * 8) else - Font.draw(manifest.enabled and "DISABLE" or "ENABLE", 2 * 8, 14 * 8) - Font.draw("A:CHANGE", 11 * 8, 15 * 8) + Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8) end end function ManagerState:draw() + if self.screen == "options" then + OptionRows.draw(self.game, self.optionRows or {}, self.cursor, + self.scroll or 0) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136) + love.graphics.setColor(1, 1, 1, 1) + if self.overlay then self:drawOverlay() end + return + end love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) Font.drawBox(0, 0, 20, 18) - Font.draw("MOD MENU", 2 * 8, 1 * 8) - - if self.mode == "detail" then - self:drawDetail(self:currentMod()) - return - end - - local categoryItems = {} - for _, category in ipairs(self.categories) do - categoryItems[#categoryItems + 1] = category - end - if #categoryItems == 0 then categoryItems[1] = "NO MODS" end - if self.mode == "categories" then - drawList(categoryItems, self.categoryIndex, 1, 4, 18, 11) - Font.draw("A:OPEN", 2 * 8, 16 * 8) - Font.draw("B:BACK", 12 * 8, 16 * 8) - return - end - - if self.mode == "mods" then - local mods = self:currentMods() - local labels = {} - for _, manifest in ipairs(mods) do - labels[#labels + 1] = (manifest.enabled and "" or "*") .. manifest.name - end - Font.draw(self.categories[self.categoryIndex] or "MODS", 2 * 8, 4 * 8) - drawList(labels, self.modIndex, 1, 6, 18, 9) - Font.draw("A:OPEN", 2 * 8, 16 * 8) - Font.draw("B:BACK", 12 * 8, 16 * 8) + Font.draw(self.banner or "MOD MANAGER", 16, 8) + if self.screen == "list" then + self:drawList() + elseif self.screen == "detail" then + self:drawDetail() + elseif self.screen == "permissions" then + self:drawPermissions() + elseif self.screen == "errors" then + self:drawErrors() + elseif self.screen == "apply" then + self:drawApply() end + if self.overlay then self:drawOverlay() end end return ManagerState diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 467cf1d8..e0622d96 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -1,11 +1,55 @@ +-- Manifest v2: a strict superset of v1, so every shipped v1 manifest stays +-- valid. Pure (no filesystem): the loader's validate phase owns the checks +-- that need to stat a file, this owns shape, vocabulary and range grammar. +local Logger = require("src.core.Logger") +local Semver = require("src.mods.Semver") +local Version = require("src.core.Version") + local Manifest = {} +Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true } +Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true } + +-- link-relevant registries; a mod that writes into one of these while +-- declaring affects_link = false gets an attributed warning from the loader +Manifest.LINK_REGISTRIES = { + pokemon = true, moves = true, type_chart = true, + statuses = true, move_effects = true, +} + local function array(value) if value == nil then return {} end assert(type(value) == "table", "manifest arrays must be tables") return value end +-- api 2 treats vocabulary violations as load errors; api 1 keeps loading and +-- gets an attributed warning so v1 mods never break on a field they predate +local function violation(strict, id, message) + if strict then error(message, 0) end + Logger.warn("[%s] %s", tostring(id), message) +end + +-- "id" or "id@"; a malformed id or range fails for every api level +-- because there is no sane fallback reading for it +local function parseSpecs(list, field) + local specs = {} + for _, entry in ipairs(list) do + assert(type(entry) == "string" and entry ~= "", + field .. " entries must be non-empty strings") + local id, range = entry:match("^([%w_%-]+)@(.+)$") + if not id then + id = entry:match("^([%w_%-]+)$") + assert(id, ("malformed %s entry %q"):format(field, entry)) + range = nil + end + local ok, err = Semver.validRange(range) + assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err))) + specs[#specs + 1] = { id = id, range = range } + end + return specs +end + function Manifest.validate(raw, path) assert(type(raw) == "table", "manifest must be an object") assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"), @@ -13,18 +57,68 @@ function Manifest.validate(raw, path) assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required") assert(type(raw.version) == "string" and raw.version ~= "", "manifest version is required") assert(type(raw.entry) == "string" and raw.entry ~= "", "manifest entry is required") + + -- absent means 1: full v1 compat, schema violations downgrade to warnings + assert(raw.api == nil or tonumber(raw.api) ~= nil, "manifest api must be a number") + local api = tonumber(raw.api) or 1 + assert(api >= 1 and api % 1 == 0, "manifest api must be a positive integer") + assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") + :format(api, Version.modApi)) + local strict = api >= 2 + + local profile = raw.profile or "content" + if not Manifest.PROFILES[profile] then + violation(strict, raw.id, ("unknown profile %q"):format(tostring(profile))) + profile = "content" + end + + local permissions, permissionSet = {}, {} + for _, name in ipairs(array(raw.permissions)) do + if Manifest.PERMISSIONS[name] then + permissions[#permissions + 1] = name + permissionSet[name] = true + else + violation(strict, raw.id, ("unknown permission %q"):format(tostring(name))) + end + end + + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) + assert(gameVersionOk, ("malformed game_version %q: %s") + :format(tostring(raw.game_version), tostring(gameVersionErr))) + + -- overhauls and total conversions are assumed to move the link + -- fingerprint unless the manifest says otherwise; content packs are not + local affectsLink = profile ~= "content" + if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end + + local function optionalFile(value, field) + if value == nil then return nil end + assert(type(value) == "string" and value ~= "", field .. " must be a file path") + return value + end + return { id = raw.id, name = raw.name, version = raw.version, entry = raw.entry, + api = api, priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), conflicts = array(raw.conflicts), + dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"), + optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"), + conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"), category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", + profile = profile, + affects_link = affectsLink, + permissions = permissions, + permissionSet = permissionSet, + options_schema = optionalFile(raw.options_schema, "options_schema"), + assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), path = path, raw = raw, } diff --git a/src/mods/Merge.lua b/src/mods/Merge.lua new file mode 100644 index 00000000..5c82ed9e --- /dev/null +++ b/src/mods/Merge.lua @@ -0,0 +1,128 @@ +-- Deep-merge engine shared by Registry:patch, the deep registries, and the +-- save-migration runner. Pure Lua, no love.*, so the headless loader and +-- offline tools can require it. +local Logger = require("src.core.Logger") + +local Merge = {} + +-- patch payloads carry this where a key must be unset; mods reach it as +-- mod.DELETE (assigning nil into a patch table would simply omit the key) +Merge.DELETE = setmetatable({}, { __tostring = function() return "" end }) + +-- arrays are contiguous 1..n; empty tables count as dictionaries so a bare +-- {} patch is a no-op instead of wiping the target list +local function isArray(t) + local n = 0 + for k in pairs(t) do + if type(k) ~= "number" then return false end + n = n + 1 + end + if n == 0 then return false end + for i = 1, n do + if t[i] == nil then return false end + end + return true +end + +Merge.isArray = isArray + +-- the documented list-extension wrappers; a mod writes +-- { __append = {row} } where a bare list would replace, or __prepend to +-- reach the front, and the wrapper is unwrapped so it never reaches Data +local function isWrapper(t) + return type(t) == "table" and (t.__append ~= nil or t.__prepend ~= nil) +end + +Merge.isWrapper = isWrapper + +local function extend(dst, src) + if type(dst) ~= "table" then dst = {} end + local rows = src.__prepend + if type(rows) == "table" then + for i = #rows, 1, -1 do table.insert(dst, 1, Merge.deepCopy(rows[i])) end + end + rows = src.__append + if type(rows) == "table" then + for _, element in ipairs(rows) do dst[#dst + 1] = Merge.deepCopy(element) end + end + return dst +end + +-- deep registries accumulate lists so two mods adding rows to the same key +-- both land; a list arriving over a dictionary is still a shape clash +local function concat(dst, src, key) + if type(dst) ~= "table" or (next(dst) ~= nil and not isArray(dst)) then + if dst ~= nil then + Logger.warn("merge: %slist replaces %s", key and (tostring(key) .. ": ") or "", + type(dst) == "table" and "dictionary" or type(dst)) + end + return Merge.deepCopy(src) + end + for _, element in ipairs(src) do dst[#dst + 1] = Merge.deepCopy(element) end + return dst +end + +function Merge.deepCopy(value, seen) + if type(value) ~= "table" or value == Merge.DELETE then return value end + seen = seen or {} + if seen[value] then return seen[value] end + local copy = {} + seen[value] = copy + for k, v in pairs(value) do copy[k] = Merge.deepCopy(v, seen) end + return copy +end + +-- dst is mutated and returned. Dictionaries merge per key; DELETE unsets; +-- a table/non-table shape clash replaces with a warning so a typo'd patch +-- stays visible instead of silently nesting. Arrays replace wholesale and +-- extend only through the __append/__prepend wrappers, except under "deep" +-- semantics, where lists append so two mods adding rows to one key both +-- land; there override is the verb that drops a list +function Merge.deepMerge(dst, src, semantics) + if type(src) ~= "table" or src == Merge.DELETE then return src end + -- an extension wrapper builds the list even where there was none, so it + -- is resolved before the shape-clash guard below + if isWrapper(src) then return extend(dst, src) end + if type(dst) ~= "table" then + if dst ~= nil then + Logger.warn("merge: table replaces non-table value") + end + return Merge.deepCopy(src) + end + -- a whole-list payload takes the same rule the per-key branch below + -- applies one level down + if isArray(src) then + if semantics == "deep" then return concat(dst, src) end + return Merge.deepCopy(src) + end + for key, value in pairs(src) do + if value == Merge.DELETE then + dst[key] = nil + elseif type(value) == "table" then + if isWrapper(value) then + dst[key] = extend(dst[key], value) + elseif isArray(value) then + if semantics == "deep" then + dst[key] = concat(dst[key], value, key) + else + dst[key] = Merge.deepCopy(value) + end + elseif type(dst[key]) == "table" then + Merge.deepMerge(dst[key], value, semantics) + else + if dst[key] ~= nil then + Logger.warn("merge: %s: table replaces %s", tostring(key), type(dst[key])) + end + dst[key] = Merge.deepCopy(value) + end + else + if type(dst[key]) == "table" then + Logger.warn("merge: %s: %s replaces table", tostring(key), type(value)) + end + dst[key] = value + end + end + return dst +end + +return Merge diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index f6845142..3c7ff4bb 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -1,38 +1,274 @@ --- Ordered, namespaced registries used by the native mod API. --- Mods register definitions here; the loader merges them into the live data --- only after every enabled mod has initialized successfully. +-- Ordered, namespaced content registries used by the native mod API. +-- Each registry stores an op log per id (register/override/patch/remove) +-- folded over the base record at read/merge time, so patches stack across +-- mods in load order and undoing a failed mod is just dropping its ops. +-- The loader merges effective values into the live data only after every +-- enabled mod has initialized successfully. +local Merge = require("src.mods.Merge") + local Registry = {} Registry.__index = Registry -function Registry.new(name) - return setmetatable({ name = name, values = {}, owners = {} }, Registry) +-- exposed to mods as mod.DELETE: a patch value that unsets a field +Registry.DELETE = Merge.DELETE + +-- spec comes from Schemas.REGISTRIES[name]; bare Registry.new(name) keeps +-- the v1 record behavior for standalone use in tests and tools +function Registry.new(name, spec) + return setmetatable({ + name = name, + spec = spec or { semantics = "record" }, + ops = {}, -- id -> ordered { op, value, owner } + owners = {}, -- id -> last-writing owner (provenance for errors) + order = {}, -- ids in first-touch order, for array-rebuilding targets + seen = {}, -- id -> true, keeps order free of duplicates + cache = {}, -- id -> { value } memoized fold + base = nil, -- installed by the loader: fn() -> base table or nil + frozen = false, + }, Registry) end -function Registry:register(id, value, owner, replace) - assert(type(id) == "string" and id ~= "", self.name .. " id is required") - assert(value ~= nil, self.name .. " value is required for " .. id) - if self.values[id] ~= nil and not replace then - error(("%s already registered: %s"):format(self.name, id)) +local function append(self, id, op, value, owner) + if self.frozen then + error(self.name .. ": content is frozen after load") end - self.values[id] = value + assert(type(id) == "string" and id ~= "", self.name .. " id is required") + local list = self.ops[id] + if not list then + list = {} + self.ops[id] = list + end + -- a rolled-back id keeps its slot: order is registration history, not a + -- live key set, so a resurrected id stays where it first appeared + if not self.seen[id] then + self.seen[id] = true + self.order[#self.order + 1] = id + end + list[#list + 1] = { op = op, value = value, owner = owner } self.owners[id] = owner + self.cache[id] = nil return value end +-- spec.baseAt lets a registry whose ids do not map one-to-one onto target +-- keys (battle_anims routes by id prefix) resolve its own pristine value +local function baseValue(self, id) + local base = self.base and self.base() + if base == nil then return nil end + if self.spec.baseAt then return self.spec.baseAt(base, id) end + return base[id] +end + +-- effective value = base plus the op list; a tombstone folds to nil and a +-- later register may resurrect the id +local function fold(self, value, opList) + local deep = self.spec.semantics == "deep" + for _, entry in ipairs(opList or {}) do + local op = entry.op + -- a payload that IS the sentinel folds as a delete, never a value; + -- without this the bare DELETE table would leak into Data as a record + if entry.value == Merge.DELETE then + value = nil + elseif op == "override" or (op == "register" and not deep) then + value = entry.value + elseif op == "register" or op == "patch" then + -- deep registries treat register and patch alike; scalar payloads + -- (a lone top-level value) replace outright + if type(entry.value) == "table" then + value = Merge.deepMerge(Merge.deepCopy(value == nil and {} or value), + entry.value, self.spec.semantics) + else + value = entry.value + end + elseif op == "remove" then + value = nil + end + end + return value +end + +function Registry:register(id, value, owner, replace) + if replace then return self:override(id, value, owner) end -- v1 signature + assert(value ~= nil, self.name .. " value is required for " .. tostring(id)) + -- duplicates collide against the base table too, forcing an explicit + -- override; compose chains accumulate and deep keys merge instead + if self.spec.semantics == "record" and self:get(id) ~= nil then + error(("%s already registered: %s"):format(self.name, id)) + end + return append(self, id, "register", value, owner) +end + function Registry:override(id, value, owner) - return self:register(id, value, owner, true) + assert(value ~= nil, self.name .. " value is required for " .. tostring(id)) + return append(self, id, "override", value, owner) +end + +function Registry:patch(id, partial, owner) + assert(partial ~= nil, self.name .. " patch value is required for " .. tostring(id)) + if self.spec.semantics == "compose" then + error(self.name .. ": patch is not supported on compose registries") + end + return append(self, id, "patch", partial, owner) +end + +-- tombstone: consumers treat the id as absent after the merge +function Registry:remove(id, owner) + return append(self, id, "remove", nil, owner) end function Registry:get(id) - return self.values[id] + if self.spec.semantics == "compose" then + -- chain() sorts top priority first, so the head is the effective value + local chain = self:chain(id) + return chain[1] + end + local hit = self.cache[id] + if hit then return hit.value end + local value = fold(self, baseValue(self, id), self.ops[id]) + self.cache[id] = { value = value } + return value end function Registry:has(id) - return self.values[id] ~= nil + return self:get(id) ~= nil end +-- compose fold: the ordered entry list for an id. Override is the +-- total-conversion escape hatch (09 4.4) -- it clears the whole chain, every +-- owner's entries alike, and installs itself as the only contribution; +-- remove tombstones the whole entry the same way but installs nothing. +-- Order is priority (higher first) then registration order. The second +-- return says the chain was cleared, which is how a consumer holding an +-- out-of-band base contribution (MapScripts) knows to leave it out. +local function composed(self, id) + local entries, replacesBase = {}, false + for seq, entry in ipairs(self.ops[id] or {}) do + if entry.op == "register" then + entries[#entries + 1] = { value = entry.value, owner = entry.owner, seq = seq } + elseif entry.op == "override" then + for i = #entries, 1, -1 do entries[i] = nil end + entries[1] = { value = entry.value, owner = entry.owner, seq = seq } + replacesBase = true + elseif entry.op == "remove" then + -- owner-scoped removal would leave the consumer's own base + -- contribution standing, so the map would still dispatch; 09 4.4 + -- makes remove a whole-entry tombstone. A later register still + -- resurrects the id, ops after this one survive the clear + for i = #entries, 1, -1 do entries[i] = nil end + replacesBase = true + end + end + table.sort(entries, function(a, b) + local pa = type(a.value) == "table" and a.value.priority or 0 + local pb = type(b.value) == "table" and b.value.priority or 0 + if pa ~= pb then return pa > pb end + return a.seq < b.seq + end) + return entries, replacesBase +end + +-- compose only: the ordered value list for an id +function Registry:chain(id) + assert(self.spec.semantics == "compose", + self.name .. ": chain is compose-only") + local entries = composed(self, id) + local values = {} + for i = 1, #entries do values[i] = entries[i].value end + return values +end + +-- chain()'s owners, index-aligned with its values: consumers that +-- attribute dispatch (map_scripts runner sources) read both sides of the +-- same fold +function Registry:chainOwners(id) + assert(self.spec.semantics == "compose", + self.name .. ": chainOwners is compose-only") + local entries = composed(self, id) + local owners = {} + for i = 1, #entries do owners[i] = entries[i].owner end + return owners +end + +-- compose only: true once an override has cleared this id's chain, so a +-- consumer that keeps its own base contribution outside the registry +-- (MapScripts' engine scripts) knows the total conversion excluded it +function Registry:chainReplacesBase(id) + assert(self.spec.semantics == "compose", + self.name .. ": chainReplacesBase is compose-only") + local _, replacesBase = composed(self, id) + return replacesBase +end + +-- iterator over the merged view: base ids first, then op-only ids; +-- tombstoned ids are skipped. No ordering guarantee. +function Registry:each() + local ids, seen = {}, {} + local base = self.base and self.base() + if base then + -- spec.baseIds names the ids hiding inside a structured target; without + -- it the target's own keys are the id space + if self.spec.baseIds then + for _, id in ipairs(self.spec.baseIds(base)) do + seen[id] = true + ids[#ids + 1] = id + end + else + for id in pairs(base) do + seen[id] = true + ids[#ids + 1] = id + end + end + end + for id in pairs(self.ops) do + if not seen[id] then ids[#ids + 1] = id end + end + local i = 0 + return function() + while true do + i = i + 1 + local id = ids[i] + if id == nil then return nil end + local value = self:get(id) + if value ~= nil then return id, value end + end + end +end + +-- v1 compat: the values mods contributed, folded to their effective form function Registry:items() - return self.values + local out = {} + for id in pairs(self.ops) do out[id] = self:get(id) end + return out +end + +-- deletes every op an owner appended, in one pass; the loader calls this +-- before the merge so a failed mod leaves zero trace in Data +function Registry:rollback(owner) + if owner == nil then return end + for id, list in pairs(self.ops) do + local touched = false + for i = #list, 1, -1 do + if list[i].owner == owner then + table.remove(list, i) + touched = true + end + end + if touched then + if #list == 0 then + self.ops[id] = nil + self.owners[id] = nil + else + self.owners[id] = list[#list].owner + end + self.cache[id] = nil + end + end +end + +-- set once the boot merge has run; unlike the event/hook buses, content +-- stays deterministic by refusing registration after that point +function Registry:freeze() + self.frozen = true end return Registry diff --git a/src/mods/Runtime.lua b/src/mods/Runtime.lua new file mode 100644 index 00000000..1128dedc --- /dev/null +++ b/src/mods/Runtime.lua @@ -0,0 +1,65 @@ +-- Process-wide access to the mod event/hook buses. Engine files require +-- this instead of threading the Game object through call sites; Loader:load +-- installs the live buses. Until then the null objects below make every +-- emit/call site a safe pass-through, so headless code paths and tools that +-- never run a loader need no guards. + +local Runtime = {} + +local NullEvents = {} +function NullEvents:emit() end +function NullEvents:removeOwner() end + +local NullHooks = {} +function NullHooks:call(name, vanilla, ...) return vanilla(...) end +function NullHooks:removeOwner() end + +Runtime.events = NullEvents +Runtime.hooks = NullHooks + +-- the live loader's error list, lent out by install. Failures that only +-- surface long after the load phase -- a mod's audio def that first fails +-- when its cue fires -- have to land in the same feed the mod manager +-- reads, and nil here means nobody is collecting. +Runtime.errors = nil + +-- id of the mod whose code is currently running, set by the loader around +-- every mod-authored frame; nil on engine paths, which is how the dev-mode +-- permissions tripwire knows there is nobody to attribute to +Runtime.currentMod = nil + +function Runtime.install(events, hooks, errors) + Runtime.events, Runtime.hooks = events, hooks + Runtime.errors = errors +end + +-- attribute a runtime failure to the mod that owns the offending record. +-- "base" is the engine's own owner id: a vanilla record that fails is a +-- console line, not something the manager can ask the player to disable. +function Runtime.reportError(modId, message) + local errors = Runtime.errors + if not errors or not modId or modId == "base" then return end + errors[#errors + 1] = tostring(modId) .. ": " .. tostring(message) +end + +function Runtime.emit(name, payload) + Runtime.events:emit(name, payload) +end + +function Runtime.call(name, vanilla, ...) + return Runtime.hooks:call(name, vanilla, ...) +end + +-- fast guards so hot call sites can skip payload/ctx construction when +-- nothing is subscribed (the null objects have no listeners/chains tables) +function Runtime.wants(name) + local listeners = Runtime.events.listeners + return listeners ~= nil and listeners[name] ~= nil +end + +function Runtime.wantsHook(name) + local chains = Runtime.hooks.chains + return chains ~= nil and chains[name] ~= nil +end + +return Runtime diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua new file mode 100644 index 00000000..5fcbef53 --- /dev/null +++ b/src/mods/Schemas.lua @@ -0,0 +1,1073 @@ +-- Single source of truth for the registry catalog: per-registry merge +-- semantics (record | deep | compose), the Data target path each merge +-- writes, and the value schema every mod registration is checked against. +-- The loader builds its registries from this table and the reference docs +-- are generated from it, so neither can drift from the engine. +-- Pure Lua, no love.*, so the headless loader and doc generator run it. +local Merge = require("src.mods.Merge") + +local Schemas = {} + +-- ------- field-type combinators + +local f = {} +Schemas.f = f + +local function leaf(kind, desc, check) + return { kind = kind, desc = desc, check = check } +end + +f.str = leaf("str", "string", function(v) return type(v) == "string" end) +f.num = leaf("num", "number", function(v) return type(v) == "number" end) +f.bool = leaf("bool", "boolean", function(v) return type(v) == "boolean" end) +f.fn = leaf("fn", "function", function(v) return type(v) == "function" end) +f.any = leaf("any", "any value", function() return true end) +f.path = leaf("path", "file path", function(v) + return type(v) == "string" and v ~= "" +end) +f.token = leaf("token", "text token name", function(v) + return type(v) == "string" and v:match("^[%w_:%*]+$") ~= nil +end) + +function f.int(min, max) + local desc = "integer" + if min and max then desc = ("integer %d..%d"):format(min, max) + elseif min then desc = ("integer >= %d"):format(min) end + return { kind = "int", min = min, max = max, desc = desc, + check = function(v) + return type(v) == "number" and v % 1 == 0 + and (min == nil or v >= min) and (max == nil or v <= max) + end } +end + +function f.enum(values) + local set = {} + for _, value in ipairs(values) do set[value] = true end + local desc = 'one of "' .. table.concat(values, '" | "') .. '"' + return { kind = "enum", set = set, values = values, desc = desc, + check = function(v) return set[v] == true end } +end + +function f.opt(inner) + return { kind = "opt", inner = inner, desc = inner.desc } +end + +function f.list(inner) + return { kind = "list", inner = inner, desc = "list of " .. inner.desc } +end + +function f.map(key, value) + return { kind = "map", key = key, value = value, + desc = ("map of %s -> %s"):format(key.desc, value.desc) } +end + +function f.rec(fields) + local names = {} + for name in pairs(fields) do names[#names + 1] = name end + table.sort(names) + local parts = {} + for _, name in ipairs(names) do + local ft = fields[name] + parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "") + end + return { kind = "rec", fields = fields, + desc = "{" .. table.concat(parts, ", ") .. "}" } +end + +function f.union(alts) + local parts = {} + for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end + return { kind = "union", alts = alts, desc = table.concat(parts, " | ") } +end + +-- cross-registry reference; the type check at register time is string-only, +-- resolution happens in the post-merge pass so forward references work +function f.id(registry) + return { kind = "id", registry = registry, desc = registry .. " id", + check = function(v) return type(v) == "string" and v ~= "" end } +end + +-- ------- validation + +-- snake_case/camelCase typos normalize to the same key, which is how the +-- classic base_stats-for-baseStats mistake gets a suggestion +local function normalizeName(name) + return tostring(name):lower():gsub("_", "") +end + +local function suggest(fields, unknown) + local want = normalizeName(unknown) + for known in pairs(fields) do + if normalizeName(known) == want then return known end + end + return nil +end + +local function got(value) + if type(value) == "string" then return string.format("%q", value) end + if type(value) == "table" then return "table" end + return tostring(value) +end + +local function fail(errors, path, expected, value) + errors[#errors + 1] = ("%s: expected %s, got %s"):format(path, expected, got(value)) +end + +-- top marks the outermost value of a spec.value registry; opt and union +-- re-dispatch on the same value so they carry it, descending drops it +local checkValue +checkValue = function(t, value, path, patchMode, errors, top) + if value == Merge.DELETE then + if not patchMode then fail(errors, path, t.desc, value) end + return + end + local kind = t.kind + if kind == "any" then return end + if kind == "opt" then return checkValue(t.inner, value, path, patchMode, errors, top) end + if kind == "list" then + if type(value) ~= "table" then return fail(errors, path, t.desc, value) end + -- lists replace wholesale, so every row is a complete value even + -- inside a patch; an extension wrapper carries the same rows and is + -- typed the same way instead of slipping through unseen + if Merge.isWrapper(value) then + for _, key in ipairs({ "__prepend", "__append" }) do + for i, element in ipairs(value[key] or {}) do + checkValue(t.inner, element, ("%s.%s[%d]"):format(path, key, i), + false, errors) + end + end + return + end + for i, element in ipairs(value) do + checkValue(t.inner, element, path .. "[" .. i .. "]", false, errors) + end + return + end + if kind == "map" then + if type(value) ~= "table" then return fail(errors, path, t.desc, value) end + for k, v in pairs(value) do + if not t.key.check(k) then + fail(errors, path .. "." .. tostring(k), "key " .. t.key.desc, k) + end + checkValue(t.value, v, path .. "." .. tostring(k), patchMode, errors) + end + return + end + if kind == "rec" then + if type(value) ~= "table" then return fail(errors, path, t.desc, value) end + for key, sub in pairs(value) do + local ft = t.fields[key] + if ft == nil then + -- the top-level record stays extensible like the spec.fields path: + -- unknown keys are preserved unless they read as a typo of a known + -- field. Nested recs stay strict, that is where typos hide. + local hint = suggest(t.fields, key) + if hint or not top then + errors[#errors + 1] = ("%s.%s: unknown field%s"):format(path, tostring(key), + hint and (' (did you mean "' .. hint .. '"?)') or "") + end + else + checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors) + end + end + if not patchMode then + for key, ft in pairs(t.fields) do + if value[key] == nil and ft.kind ~= "opt" then + errors[#errors + 1] = ("%s.%s: missing required field (%s)") + :format(path, key, ft.desc) + end + end + end + return + end + if kind == "union" then + for _, alt in ipairs(t.alts) do + local scratch = {} + checkValue(alt, value, path, patchMode, scratch, top) + if #scratch == 0 then return end + end + return fail(errors, path, t.desc, value) + end + if not t.check(value) then fail(errors, path, t.desc, value) end +end + +-- mode is "register" | "override" (full record: required fields enforced), +-- "patch" (only provided leaves checked, DELETE legal) or "remove" (no +-- value). Unknown top-level fields are allowed and preserved -- extensible +-- records are a feature -- but a patch key that is only a case/underscore +-- variant of a schema field is the classic typo and gets rejected with a +-- suggestion. +function Schemas.check(spec, registryName, id, value, mode) + if mode == "remove" or spec == nil then return true end + -- register and patch are synonyms on a deep registry, so a partial + -- payload is the normal case there and only override is a full value + local patchMode = mode == "patch" + or (spec.semantics == "deep" and mode == "register") + local errors = {} + local path = registryName .. "." .. tostring(id) + if spec.keys or spec.keyValue then + -- deep registries are open namespaces: a key the catalog does not + -- describe is a mod's own data, not a mistake. keyValue types every + -- key alike, for namespaces whose keys are content (one per map). + local keyType = (spec.keys and spec.keys[id]) or spec.keyValue + if keyType then checkValue(keyType, value, path, patchMode, errors, true) end + elseif spec.value then + checkValue(spec.value, value, path, patchMode, errors, true) + if #errors == 0 and not patchMode and spec.extra then + local problem = spec.extra(id, value) + if problem then errors[#errors + 1] = ("%s: %s"):format(path, problem) end + end + elseif spec.fields then + if type(value) ~= "table" then + fail(errors, path, "record table", value) + else + for key, sub in pairs(value) do + local ft = spec.fields[key] + if ft ~= nil then + checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors) + elseif patchMode then + local hint = suggest(spec.fields, key) + if hint then + errors[#errors + 1] = ('%s.%s: unknown field (did you mean "%s"?)') + :format(path, tostring(key), hint) + end + end + end + if not patchMode then + for key, ft in pairs(spec.fields) do + if value[key] == nil and ft.kind ~= "opt" then + errors[#errors + 1] = ("%s.%s: missing required field (%s)") + :format(path, key, ft.desc) + end + end + end + if #errors == 0 and not patchMode and spec.extra then + local problem = spec.extra(id, value) + if problem then + errors[#errors + 1] = ("%s: %s"):format(path, problem) + end + end + end + end + if #errors == 0 then return true end + return nil, table.concat(errors, "; ") +end + +-- ------- cross-reference pass + +local collectRefs +collectRefs = function(t, value, path, out) + if value == nil or value == Merge.DELETE then return end + local kind = t.kind + if kind == "opt" then return collectRefs(t.inner, value, path, out) end + if kind == "id" then + if type(value) == "string" then + out[#out + 1] = { registry = t.registry, ref = value, path = path } + end + return + end + if kind == "list" and type(value) == "table" then + for i, element in ipairs(value) do + collectRefs(t.inner, element, path .. "[" .. i .. "]", out) + end + elseif kind == "map" and type(value) == "table" then + for k, v in pairs(value) do + collectRefs(t.value, v, path .. "." .. tostring(k), out) + end + elseif kind == "rec" and type(value) == "table" then + for key, ft in pairs(t.fields) do + collectRefs(ft, value[key], path .. "." .. tostring(key), out) + end + elseif kind == "union" then + -- refs live in whichever alternative the value satisfies + for _, alt in ipairs(t.alts) do + local scratch = {} + checkValue(alt, value, path, true, scratch) + if #scratch == 0 then return collectRefs(alt, value, path, out) end + end + end +end + +-- every f.id ref reachable from one record, tagged with its field path +local function refsFor(spec, name, id, value) + local refs = {} + if spec.keys or spec.keyValue then + local keyType = (spec.keys and spec.keys[id]) or spec.keyValue + if keyType then + collectRefs(keyType, value, name .. "." .. tostring(id), refs) + end + elseif spec.fields and type(value) == "table" then + for key, ft in pairs(spec.fields) do + collectRefs(ft, value[key], name .. "." .. tostring(id) .. "." .. key, refs) + end + elseif spec.value then + collectRefs(spec.value, value, name .. "." .. tostring(id), refs) + end + return refs +end + +-- a structured target (battle_anims' per-kind subtables) hides its ids one +-- level down, so the pristine scan asks the spec instead of the raw keys +local function baseEntries(registry, base) + local spec = registry.spec + if not spec.baseIds then return pairs(base) end + local ids = spec.baseIds(base) + local i = 0 + return function() + i = i + 1 + local id = ids[i] + if id == nil then return nil end + return id, spec.baseAt(base, id) + end +end + +-- runs once after the merge. Records mods touched are scanned for dangling +-- refs (the op logs limit that, so a mod-free boot does zero work); if any +-- id folded to nil the scan widens to the untouched base records too, so a +-- remove that strands a vanilla reference is caught and attributed to the +-- removing mod instead of surfacing as an unowned crash later. References +-- into registries the catalog does not declare yet are skipped, not guessed. +function Schemas.crossValidate(loader, data) + local problems = {} + local tombstoned, removed = {}, false + for name, registry in pairs(loader.content) do + for id in pairs(registry.ops) do + if registry:get(id) == nil then + tombstoned[name] = tombstoned[name] or {} + tombstoned[name][id] = true + removed = true + end + end + end + for name, registry in pairs(loader.content) do + local spec = registry.spec + for id in pairs(registry.ops) do + local value = registry:get(id) + if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then + for _, ref in ipairs(refsFor(spec, name, id, value)) do + local refRegistry = Schemas.REGISTRIES[ref.registry] + and loader.content[ref.registry] + if refRegistry and refRegistry:get(ref.ref) == nil then + problems[#problems + 1] = { + owner = registry.owners[id], + message = ("%s: unresolved reference to %s %q") + :format(ref.path, ref.registry, ref.ref), + } + end + end + end + end + if removed then + local base = registry.base and registry.base() + if base then + for id, value in baseEntries(registry, base) do + if registry.ops[id] == nil then + for _, ref in ipairs(refsFor(spec, name, id, value)) do + local set = tombstoned[ref.registry] + if set and set[ref.ref] then + problems[#problems + 1] = { + owner = loader.content[ref.registry].owners[ref.ref], + message = ("%s: unresolved reference to removed %s %q") + :format(ref.path, ref.registry, ref.ref), + } + end + end + end + end + end + end + end + return problems +end + +-- ------- the catalog + +-- v1 registry names that live on as thin views of a renamed registry; both +-- names share one op log and diagnostics report the canonical name +Schemas.ALIASES = { scripts = "map_scripts", ui = "screens" } + +-- owner of the engine's own registrations (src/mods/Builtins.lua); vanilla +-- content is internally consistent by construction, so the cross-reference +-- pass skips it and stays zero-work on a mod-free boot +Schemas.ENGINE = "engine" + +local R = {} +Schemas.REGISTRIES = R + +R.pokemon = { + semantics = "record", target = "pokemon", + fields = { + id = f.str, name = f.str, dex = f.int(1), + index = f.opt(f.int(0, 255)), + types = f.list(f.id("type_chart")), + baseStats = f.rec{ hp = f.int(1, 255), attack = f.int(1, 255), + defense = f.int(1, 255), speed = f.int(1, 255), + special = f.int(1, 255) }, + catchRate = f.int(0, 255), baseExp = f.int(0, 255), + level1Moves = f.list(f.id("moves")), + growthRate = f.id("growth_rates"), + tmhm = f.opt(f.list(f.id("moves"))), + learnset = f.list(f.rec{ level = f.int(1), move = f.id("moves") }), + evolutions = f.list(f.rec{ method = f.id("evolution_methods"), + level = f.opt(f.int(1)), + item = f.opt(f.id("items")), + species = f.id("pokemon") }), + spriteFront = f.path, spriteBack = f.path, frontSize = f.int(1, 7), + dexEntry = f.opt(f.rec{ kind = f.str, heightFt = f.int(0), + heightIn = f.int(0, 11), weight = f.num, + text = f.str }), + icon = f.opt(f.union{ f.str, f.rec{ image = f.path, + frames = f.opt(f.int(1)) } }), + cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")), + trueColor = f.opt(f.bool), + }, + example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })', +} + +R.moves = { + semantics = "record", target = "moves", + fields = { + id = f.str, name = f.str, + index = f.opt(f.int(0, 255)), + type = f.id("type_chart"), + power = f.int(0, 255), + accuracy = f.int(0, 100), + pp = f.int(0, 64), + effect = f.id("move_effects"), + anim = f.opt(f.any), + category = f.opt(f.enum{ "physical", "special", "status" }), + priority = f.opt(f.int(-7, 7)), + highCrit = f.opt(f.bool), + fixedDamage = f.opt(f.union{ f.int(1), f.fn }), + chargeText = f.opt(f.str), + semiInvulnerable = f.opt(f.bool), + -- a fixed count or the distribution a uniform roll picks from + multiHit = f.opt(f.union{ f.int(1), f.list(f.int(1)) }), + counterable = f.opt(f.bool), + }, + example = 'mod.content.moves:patch("BLIZZARD", { accuracy = 70 })', +} + +R.items = { + semantics = "record", target = "items", + fields = { + id = f.str, name = f.str, + index = f.opt(f.int(0, 255)), + price = f.int(0), + machine = f.opt(f.rec{ kind = f.str, move = f.id("moves"), + number = f.int(0) }), + effect = f.opt(f.id("item_effects")), + ball = f.opt(f.id("balls")), + tossable = f.opt(f.bool), + needsTarget = f.opt(f.bool), + }, + example = 'mod.content.items:patch("POTION", { price = 100 })', +} + +R.maps = { + semantics = "record", target = "maps", + fields = { + -- the byte cap is a ROM table artifact; mod maps use ids at or above + -- 1000, and the indoor/connection range compares read the number + id = f.str, label = f.opt(f.str), index = f.opt(f.int(0)), + tileset = f.id("tilesets"), + width = f.int(1), height = f.int(1), + blocks = f.list(f.int(0, 255)), + borderBlock = f.opt(f.int(0, 255)), + warps = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0), + destMap = f.str, destWarp = f.int(0) })), + objects = f.opt(f.list(f.any)), + signs = f.opt(f.list(f.any)), + connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)), + }, + extra = function(_, value) + if type(value.blocks) == "table" and type(value.width) == "number" + and type(value.height) == "number" + and #value.blocks ~= value.width * value.height then + return ("blocks has %d entries, expected width*height = %d") + :format(#value.blocks, value.width * value.height) + end + end, + example = 'mod.content.maps:register("MY_CAVE", { tileset = "CAVERN", ... })', +} + +R.tilesets = { + semantics = "record", target = "tilesets", + fields = { + id = f.opt(f.str), image = f.path, + imageWidth = f.opt(f.int(1)), imageHeight = f.opt(f.int(1)), + tilesPerRow = f.opt(f.int(1)), + blocks = f.list(f.any), + walkable = f.opt(f.any), counterTiles = f.opt(f.any), + doorTiles = f.opt(f.any), warpTiles = f.opt(f.any), + animation = f.opt(f.str), + trueColor = f.opt(f.bool), + }, + extra = function(_, value) + if type(value.blocks) == "table" then + for i, row in ipairs(value.blocks) do + if type(row) ~= "table" or #row ~= 16 then + return ("blocks[%d] must be a row of 16 tile ids"):format(i) + end + end + end + end, + example = 'mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } })', +} + +R.encounters = { + semantics = "record", target = "encounters", + fields = { + id = f.opt(f.str), + grass = f.opt(f.rec{ rate = f.int(0, 255), + slots = f.list(f.rec{ level = f.int(1), + species = f.id("pokemon") }) }), + water = f.opt(f.rec{ rate = f.int(0, 255), + slots = f.list(f.rec{ level = f.int(1), + species = f.id("pokemon") }) }), + }, + example = 'mod.content.encounters:patch("ROUTE_1", { grass = { rate = 30 } })', +} + +R.trainers = { + semantics = "record", target = "trainers", + fields = { + id = f.str, name = f.str, + index = f.opt(f.int(0, 255)), + -- unused vanilla classes ship without a pic, so it cannot be required + pic = f.opt(f.path), + baseMoney = f.opt(f.int(0)), + parties = f.list(f.list(f.rec{ level = f.int(1), + species = f.id("pokemon") })), + aiMods = f.opt(f.any), + aiClass = f.opt(f.id("ai_classes")), + brain = f.opt(f.fn), + battleTheme = f.opt(f.id("music")), + }, + example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })', +} + +R.sprites = { + semantics = "record", target = "sprites", + fields = { + id = f.opt(f.str), + image = f.path, + frames = f.int(1), + walker = f.opt(f.bool), + trueColor = f.opt(f.bool), + }, + example = 'mod.content.sprites:register("SPRITE_HERO", { image = "...", frames = 6 })', +} + +R.text = { + semantics = "record", target = "text", + value = f.str, + example = 'mod.content.text:override("_PalletTownText1", "HELLO!")', +} + +-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2); +-- shared by every namespace that plays a chip program +local chipProgram = f.rec{ + blob = f.str, channels = f.any, waves = f.opt(f.any), + drums = f.opt(f.any), engine = f.opt(f.num), +} + +-- value union dispatched per def shape: rom chip ref, file-backed song, or +-- an authored chip program (ChipAsm) +R.music = { + semantics = "record", target = "audio.songs", + value = f.union{ + f.rec{ address = f.int(0), bank = f.int(0), engine = f.opt(f.num) }, + f.rec{ file = f.path, loopFile = f.opt(f.path), seconds = f.opt(f.num), + loopSeconds = f.opt(f.num), intro = f.opt(f.any) }, + f.rec{ program = f.any, channels = f.any, waves = f.opt(f.any), + drums = f.opt(f.any) }, + f.rec{ chip = chipProgram }, + }, + example = 'mod.content.music:register("MOD_SONG", { file = "song.ogg" })', +} + +-- whole-key replacement of Data.audio, the v1 escape hatch. Kept working +-- forever; the granular sfx / cries / map_songs registries supersede it and +-- whole-key swaps (programFile, bankOrder) remain its one honest use. +R.audio = { + semantics = "record", target = "audio", + value = f.any, + deprecated = { useInstead = "sfx / cries / map_songs / music" }, + example = 'mod.content.audio:override("mapSongs", { ... })', +} + +-- compose: registrations accumulate into per-map chains instead of +-- replacing each other. Data.map_scripts is the interim home consumed by +-- data/scripts/init.lua until the M5 dispatcher reads chain() directly. +-- rows, a handler, or false: false is the explicit suppression that wins the +-- single-winner resolution and hides every lower-precedence entry (09 4.4) +local scriptEntry = f.union{ + f.list(f.any), f.fn, + leaf("suppress", "false", function(v) return v == false end), +} + +R.map_scripts = { + semantics = "compose", target = "map_scripts", + value = f.rec{ + talk = f.opt(f.map(f.str, scriptEntry)), + scripts = f.opt(f.map(f.str, scriptEntry)), + onEnter = f.opt(f.fn), onStep = f.opt(f.fn), onInteract = f.opt(f.fn), + onVictory = f.opt(f.fn), onBoulderMoved = f.opt(f.fn), + -- the flute wake sequence ItemEffects/BagMenu look up by map id; the + -- other legacy ad-hoc keys stay unknown-but-preserved + snorlaxWake = f.opt(f.rec{ + objName = f.opt(f.str), beatFlag = f.opt(f.str), script = f.list(f.any), + }), + priority = f.opt(f.num), + }, + example = 'mod.content.map_scripts:register("PALLET_TOWN", { talk = { ... } })', +} + +R.screens = { + semantics = "record", target = "screens", + value = f.union{ f.fn, f.rec{ new = f.fn } }, + example = 'mod.content.screens:register("QuestLog", { new = function(game) ... end })', +} + +-- ------- battle + +-- Two id forms share one registry: "ATTACKER>DEFENDER" matchup rows and +-- bare type ids. Neither lives at a key of Data.type_chart (the rows are +-- an ordered array), so the registry owns the whole target: reads see only +-- registrations -- the engine makes them all -- and the merge rebuilds +-- matchups and types from the op order. +R.type_chart = { + semantics = "record", target = "type_chart", + value = f.union{ + f.rec{ multiplier = f.int(0) }, + f.rec{ name = f.opt(f.str), category = f.enum{ "physical", "special" }, + index = f.opt(f.int(0, 255)) }, + }, + baseAt = function() return nil end, + baseIds = function() return {} end, + write = function(target, registry) + local matchups, types = {}, {} + for _, id in ipairs(registry.order) do + local value = registry:get(id) + if value ~= nil then + local attacker, defender = id:match("^([^>]+)>([^>]+)$") + if attacker then + local row = Merge.deepCopy(value) + row.attacker, row.defender = attacker, defender + matchups[#matchups + 1] = row + else + types[id] = value + end + end + end + target.matchups, target.types = matchups, types + end, + example = 'mod.content.type_chart:register("BUG>PSYCHIC_TYPE", { multiplier = 20 })', +} + +R.statuses = { + semantics = "record", target = "statuses", + fields = { + id = f.opt(f.str), label = f.str, + hudLabel = f.opt(f.str), + canInflict = f.opt(f.fn), onInflict = f.opt(f.fn), + beforeMove = f.opt(f.fn), beforeMovePriority = f.opt(f.int(0)), + residual = f.opt(f.fn), + catchBonus = f.opt(f.int(0, 255)), shakeBonus = f.opt(f.int(0, 255)), + statPenalty = f.opt(f.rec{ stat = f.str, div = f.int(1) }), + cureOnSwitch = f.opt(f.bool), + }, + example = 'mod.content.statuses:patch("BRN", { catchBonus = 12 })', +} + +-- run is optional because the "full" effects are steered from inside the +-- damage pipeline and have no standalone handler to register yet; M7 gives +-- them the effect context that makes one possible +R.move_effects = { + semantics = "record", target = "move_effects", + fields = { + kind = f.enum{ "primary", "secondary", "full" }, + accuracyChecked = f.opt(f.bool), + run = f.opt(f.fn), + }, + example = 'mod.content.move_effects:register("DRAIN_PP_EFFECT", { kind = "primary", run = fn })', +} + +R.item_effects = { + semantics = "record", target = "item_effects", + fields = { + use = f.fn, + needsTarget = f.opt(f.bool), battle = f.opt(f.bool), field = f.opt(f.bool), + }, + example = 'mod.content.item_effects:register("MOON_FLUTE", { use = fn, field = true })', +} + +-- MASTER_BALL catches unconditionally and never rolls, so randMax 0 is a +-- legal record and hpFactor is only read on the wobble path +R.balls = { + semantics = "record", target = "balls", + fields = { + randMax = f.int(0, 255), + hpFactor = f.opt(f.int(1)), wobbleFactor = f.opt(f.int(1)), + autoCatch = f.opt(f.bool), flicker = f.opt(f.bool), + tossAnim = f.opt(f.str), attempt = f.opt(f.fn), + }, + example = 'mod.content.balls:override("GREAT_BALL", { randMax = 180, hpFactor = 12 })', +} + +R.rulesets = { + semantics = "record", target = "rulesets", + fields = { name = f.str }, + example = 'mod.content.rulesets:register("no_crits", { name = "no crits", critRate = 0 })', +} + +-- three record kinds share the registry: "class" is the per-trainer +-- item/switch behavior, "layer" a move-scoring pass (the vanilla three are +-- LAYER_1..LAYER_3), "brain" a full action chooser +R.ai_classes = { + semantics = "record", target = "ai_classes", + fields = { + kind = f.opt(f.enum{ "class", "layer", "brain" }), + uses = f.opt(f.int(0)), chance = f.opt(f.int(0, 256)), + item = f.opt(f.id("items")), + switch = f.opt(f.bool), switchChance = f.opt(f.int(0, 256)), + switchBelow = f.opt(f.int(1)), hpBelow = f.opt(f.int(1)), + onStatus = f.opt(f.bool), + score = f.opt(f.fn), choose = f.opt(f.fn), brain = f.opt(f.fn), + }, + example = 'mod.content.ai_classes:patch("OPP_BROCK", { uses = 9 })', +} + +-- ids route into the target's per-kind subtables: a bare move id is a move +-- animation, "subanim:" and "tilesheet:" address the shared pieces +local function animRoute(id) + local kind, index = tostring(id):match("^(%a+):(%d+)$") + if kind == "subanim" then return "subanims", tonumber(index) end + if kind == "tilesheet" then return "tilesheets", tonumber(index) end + return "moveAnims", id +end + +R.battle_anims = { + semantics = "record", target = "battle_anims", + value = f.union{ + f.rec{ seq = f.list(f.any), source = f.opt(f.str) }, + f.rec{ blocks = f.list(f.any), type = f.opt(f.str) }, + f.rec{ path = f.path, width = f.int(1), height = f.int(1), + tiles = f.int(1), source = f.opt(f.str) }, + }, + baseAt = function(base, id) + local sub, key = animRoute(id) + local table_ = base[sub] + return table_ and table_[key] or nil + end, + baseIds = function(base) + local ids = {} + for id in pairs(base.moveAnims or {}) do ids[#ids + 1] = id end + for index in pairs(base.subanims or {}) do ids[#ids + 1] = "subanim:" .. index end + for index in pairs(base.tilesheets or {}) do ids[#ids + 1] = "tilesheet:" .. index end + return ids + end, + write = function(target, registry) + for _, id in ipairs(registry.order) do + local sub, key = animRoute(id) + local into = target[sub] + if not into then + into = {} + target[sub] = into + end + into[key] = registry:get(id) + end + end, + example = 'mod.content.battle_anims:register("SHADOW_BALL", { seq = { ... } })', +} + +R.transitions = { + semantics = "record", target = "transitions", + fields = { + frames = f.int(1), draw = f.opt(f.fn), sound = f.opt(f.str), + flash = f.opt(f.bool), + }, + example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })', +} + +-- ------- progression + +R.evolution_methods = { + semantics = "record", target = "evolution_methods", + fields = { check = f.fn, describe = f.opt(f.fn) }, + example = 'mod.content.evolution_methods:register("FRIENDSHIP", { check = fn })', +} + +R.growth_rates = { + semantics = "record", target = "growth_rates", + fields = { expForLevel = f.fn }, + -- a curve that does not grow makes levelForExp loop forever + extra = function(_, value) + if type(value.expForLevel) == "function" then + local ok, low, high = pcall(function() + return value.expForLevel(1), value.expForLevel(2) + end) + if ok and type(low) == "number" and type(high) == "number" + and high <= low then + return "expForLevel must increase with level" + end + end + end, + example = 'mod.content.growth_rates:register("ERRATIC", { expForLevel = fn })', +} + +-- ------- audio (per-def shapes, dispatched by the consumer) + +R.sfx = { + semantics = "record", target = "audio.sfx", + value = f.union{ + f.str, + f.rec{ address = f.int(0), bank = f.int(0), engine = f.opt(f.num) }, + f.rec{ file = f.path }, + f.rec{ chip = chipProgram }, + }, + example = 'mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })', +} + +-- base names the species whose header a derived cry borrows, so it resolves +-- against this same registry (13.9) +R.cries = { + semantics = "record", target = "audio.cries", + value = f.union{ + f.rec{ header = f.any, pitch = f.int(0, 255), length = f.int(0, 255) }, + f.rec{ file = f.path }, + f.rec{ base = f.id("cries"), pitch = f.opt(f.int(0, 255)), + length = f.opt(f.int(0, 255)) }, + f.rec{ chip = chipProgram, pitch = f.opt(f.int(0, 255)), + length = f.opt(f.int(0, 255)) }, + }, + example = 'mod.content.cries:patch("PIKACHU", { pitch = 200 })', +} + +R.map_songs = { + semantics = "record", target = "audio.mapSongs", + value = f.id("music"), + example = 'mod.content.map_songs:override("PALLET_TOWN", "Music_Routes1")', +} + +-- ------- presentation + +-- vanilla palettes are four raw {r,g,b} triples; the named-record form is +-- the v2 shape a mod may register instead +R.palettes = { + semantics = "record", target = "palettes.palettes", + value = f.union{ + f.list(f.list(f.int(0, 255))), + f.rec{ colors = f.list(f.rec{ r = f.int(0, 255), g = f.int(0, 255), + b = f.int(0, 255) }) }, + }, + extra = function(_, value) + local colors = value.colors or value + if type(colors) == "table" and #colors ~= 4 then + return ("needs exactly 4 colors, got %d"):format(#colors) + end + end, + example = 'mod.content.palettes:override("MEWMON", { {255,255,255}, ... })', +} + +-- keyed by species id, unlike the vanilla byDex array: a species past the +-- end of the dex gets an icon without punching a hole in the list +R.icons = { + semantics = "record", target = "icons.bySpecies", + value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } }, + example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })', +} + +-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a +-- registered page takes a range of its own above them (a kana block at +-- $100), so neither a base nor a charmap code is capped at one byte. +-- Two id forms share the registry (14 §registry schemas): a bare id is a +-- page, "charmap:" is one sequence->code row. A page carries its own +-- charmap only as a convenience -- replacing a sheet must not force an +-- author to restate the table. +local function fontIsCharmap(id) + return tostring(id):match("^charmap:.+$") ~= nil +end + +R.font = { + semantics = "record", target = "font", + value = f.union{ + f.rec{ image = f.path, base = f.int(0), glyphsPerRow = f.opt(f.int(1)), + advance = f.opt(f.int(1)), + charmap = f.opt(f.list(f.rec{ code = f.int(0), seq = f.str })) }, + f.rec{ seq = f.str, code = f.int(0) }, + }, + extra = function(id, value) + if fontIsCharmap(id) then + if type(value.seq) ~= "string" or value.seq == "" then + return "a charmap: entry needs a non-empty seq" + end + if type(value.code) ~= "number" then + return "a charmap: entry needs a code" + end + elseif value.image == nil or value.base == nil then + return "a font page needs an image and a base" + end + end, + baseAt = function(base, id) + if fontIsCharmap(id) then return nil end + return base.pages and base.pages[id] or nil + end, + baseIds = function(base) + local ids = {} + for id in pairs(base.pages or {}) do ids[#ids + 1] = id end + return ids + end, + write = function(target, registry) + local pages = target.pages or {} + target.pages = pages + -- the extractor's rows have no id and stay put; the registry's own are + -- rebuilt every merge so a re-merge replaces them instead of stacking + local rows = {} + for _, entry in ipairs(target.charmap or {}) do + if type(entry) ~= "table" or entry.id == nil then rows[#rows + 1] = entry end + end + for _, id in ipairs(registry.order) do + local value = registry:get(id) + if fontIsCharmap(id) then + if value ~= nil then + rows[#rows + 1] = { id = id, seq = value.seq, code = value.code } + end + else + pages[id] = value + end + end + target.charmap = rows + end, + example = 'mod.content.font:register("charmap:hiragana_a", { seq = "\227\129\130", code = 256 })', +} + +-- ------- scripting and text plumbing + +-- a record is the bare handler (the v1 shape every engine verb still uses) +-- or the flagged table Commands.resolve already unpacks (09 §4.2) +R.commands = { + semantics = "record", target = "commands", + value = f.union{ f.fn, f.rec{ fn = f.fn, foreground = f.opt(f.bool), + blocking = f.opt(f.bool) } }, + example = 'mod.content.commands:register("shake_screen", function(ctx, frames) ... end)', +} + +R.tokens = { + semantics = "record", target = "tokens", + value = f.fn, + example = 'mod.content.tokens:register("CLOCK", function(game) return "12" end)', +} + +-- ------- deep registries: id is a top-level key of the target table + +-- The rules the engine used to hard-code as Kanto/Red literals. Keys the +-- importer does not stamp are seeded with their vanilla value at data load +-- (src/core/Data.lua) so a patch always has something to fold over. +R.constants = { + semantics = "deep", target = "constants", + keys = { + bagSize = f.int(1), partyMax = f.int(1), + boxCount = f.int(1), boxSize = f.int(1), + moveMax = f.int(1), + dexSize = f.int(1), dexDigits = f.int(1), + levelCap = f.int(1), coinCap = f.int(0), moneyCap = f.int(0), + -- ordered: list position is the badge number the trainer card draws + badges = f.list(f.rec{ id = f.id("items"), name = f.opt(f.str), + icon = f.opt(f.path), item = f.opt(f.id("items")) }), + hmMoves = f.list(f.id("moves")), + encounterBuckets = f.list(f.int(1, 256)), + }, + example = 'mod.content.constants:patch("levelCap", 80)', +} + +-- The overworld's data grab bag. Only the keys this milestone routes are +-- typed; the rest of the 37-subtable inventory stays open until its +-- consumers move off their literals. +R.field = { + semantics = "deep", target = "field", + keys = { + ledges = f.list(f.rec{ + facing = f.enum{ "up", "down", "left", "right" }, + input = f.enum{ "up", "down", "left", "right" }, + standingTile = f.int(0), ledgeTile = f.int(0), + tileset = f.opt(f.id("tilesets")) }), + hiddenItems = f.map(f.str, f.list(f.rec{ + x = f.int(0), y = f.int(0), item = f.id("items") })), + badgeGates = f.map(f.str, f.rec{ + badge = f.opt(f.id("items")), text = f.opt(f.str), + passText = f.opt(f.str), failText = f.opt(f.str), + -- omit it and the gate gets "PASSED_"; Route 22 keeps its + -- pre-v2 spelling only because saves already carry that flag + passedFlag = f.opt(f.str), + coords = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0) })), + guards = f.opt(f.list(f.any)) }), + townMap = f.rec{ + background = f.opt(f.any), + gridPixelSize = f.opt(f.int(1)), + cursorOrder = f.opt(f.list(f.str)), + locations = f.opt(f.map(f.str, f.rec{ x = f.int(0), y = f.int(0), + name = f.opt(f.str) })), + nest = f.opt(f.any) }, + flyOrder = f.list(f.str), + -- the new-game and boot config a total conversion replaces + boot = f.rec{ + startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)), + startFacing = f.opt(f.enum{ "up", "down", "left", "right" }), + playerName = f.opt(f.str), rivalName = f.opt(f.str), + startMoney = f.opt(f.int(0)), + lastHeal = f.opt(f.rec{ map = f.str, x = f.int(0), y = f.int(0) }), + namePresets = f.opt(f.rec{ player = f.opt(f.list(f.str)), + rival = f.opt(f.list(f.str)) }), + screens = f.opt(f.rec{ splash = f.opt(f.str), title = f.opt(f.str), + newGame = f.opt(f.str) }), + starterScript = f.opt(f.str), + title = f.opt(f.any) }, + }, + example = 'mod.content.field:patch("boot", { startMap = "SABLE_COVE" })', +} + +-- Every key is a map label carrying the same per-TEXT-constant shape, so +-- one keyValue types them all: a mod adds a single sign binding without +-- restating the map. label is the extractor's field, text the authored +-- one; Data:resolveText reads text and falls back to the hand-ported +-- script when only asm is set. +R.text_pointers = { + semantics = "deep", target = "text_pointers", + keyValue = f.map(f.str, f.rec{ + text = f.opt(f.str), label = f.opt(f.str), asm = f.opt(f.bool), + mart = f.opt(f.list(f.id("items"))), + nurse = f.opt(f.bool), pc = f.opt(f.bool), cableClub = f.opt(f.bool), + }), + example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })', +} + +-- ------- persistence + +-- compose, keyed by the owning mod id: the runner walks each owner's chain +-- in semver order against the versions recorded in the save +R.migrations = { + semantics = "compose", + value = f.rec{ since = f.str, run = f.fn }, + example = 'mod.content.migrations:register("my_mod", { since = "1.0.0", run = fn })', +} + +-- ------- link play + +-- id = the extra-bag mon field a mod wants to force fingerprint agreement on. +-- Only rev reaches the digest: pack/unpack are Lua, and function bytes are not +-- portably hashable, so the author bumps rev when the codec's meaning changes +-- (the affects_link mod version is the backstop when they forget). No engine +-- content, so the registry is empty on a mod-free boot and the fingerprint's +-- link_fields section is absent on both peers. +R.link_fields = { + semantics = "record", target = "link_fields", + fields = { + rev = f.union{ f.int(0), f.str }, + pack = f.opt(f.fn), unpack = f.opt(f.fn), + }, + example = 'mod.content.link_fields:register("held_item", { rev = 1, pack = fn, unpack = fn })', +} + +return Schemas diff --git a/src/mods/Semver.lua b/src/mods/Semver.lua new file mode 100644 index 00000000..cdafcbe9 --- /dev/null +++ b/src/mods/Semver.lua @@ -0,0 +1,156 @@ +-- Semantic versions and the range grammar manifests use for game_version +-- and for dependency/conflict pins. No requires, so the headless loader, +-- the doc generator and tools all match on the same implementation. +-- +-- Ranges: comparators = > >= < <= ^ (bare version means =), space-separated +-- comparators AND together, || separates alternatives. + +local Semver = {} + +-- "1", "1.2", "1.2.3", "1.2.3-beta.1"; absent components are 0 and build +-- metadata is parsed then discarded. nil for anything unparsable -- mod +-- versions stay free-form strings, only range checks need a parse. +function Semver.parse(text) + if type(text) ~= "string" then return nil end + local body = text:match("^%s*(.-)%s*$"):gsub("^[vV]", "") + local plus = body:find("+", 1, true) + if plus then body = body:sub(1, plus - 1) end + local core, pre = body:match("^([^%-]+)%-?(.*)$") + if not core then return nil end + if core:match("[^%d%.]") or core:match("^%.") or core:match("%.$") + or core:find("..", 1, true) then + return nil + end + local nums = {} + for part in core:gmatch("[^%.]+") do nums[#nums + 1] = tonumber(part) end + if #nums == 0 or #nums > 3 then return nil end + if pre == "" then + pre = nil + elseif not pre:match("^[%w%.%-]+$") then + return nil + end + return { major = nums[1], minor = nums[2] or 0, patch = nums[3] or 0, pre = pre } +end + +-- SemVer 2.0 pre-release precedence: a release outranks its pre-releases, +-- numeric identifiers compare numerically and rank below alphanumeric ones, +-- and a longer identifier list wins when every shared field is equal +local function comparePre(a, b) + if a == b then return 0 end + if a == nil then return 1 end + if b == nil then return -1 end + local left, right = {}, {} + for part in a:gmatch("[^%.]+") do left[#left + 1] = part end + for part in b:gmatch("[^%.]+") do right[#right + 1] = part end + local count = #left > #right and #left or #right + for i = 1, count do + local x, y = left[i], right[i] + if x == nil then return -1 end + if y == nil then return 1 end + local nx, ny = tonumber(x), tonumber(y) + if nx and ny then + if nx ~= ny then return nx < ny and -1 or 1 end + elseif nx then + return -1 + elseif ny then + return 1 + elseif x ~= y then + return x < y and -1 or 1 + end + end + return 0 +end + +-- accepts strings or already-parsed tables; nil when either side is unparsable +function Semver.compare(a, b) + local va = type(a) == "table" and a or Semver.parse(a) + local vb = type(b) == "table" and b or Semver.parse(b) + if not va or not vb then return nil end + for _, field in ipairs({ "major", "minor", "patch" }) do + local x, y = va[field] or 0, vb[field] or 0 + if x ~= y then return x < y and -1 or 1 end + end + return comparePre(va.pre, vb.pre) +end + +-- ------- ranges + +local OPS = { + ["="] = true, ["=="] = true, [">"] = true, [">="] = true, + ["<"] = true, ["<="] = true, ["^"] = true, +} + +-- ^ pins the leftmost non-zero component: ^1.2 is >=1.2 <2.0, ^0.2 is +-- >=0.2 <0.3, ^0.0.3 is >=0.0.3 <0.0.4 +local function caretUpper(v) + if v.major > 0 then return { major = v.major + 1, minor = 0, patch = 0 } end + if v.minor > 0 then return { major = 0, minor = v.minor + 1, patch = 0 } end + return { major = 0, minor = 0, patch = v.patch + 1 } +end + +local function matchToken(version, token) + local op, rest = token:match("^([=<>%^]*)(.*)$") + if op == "" then op = "=" end + if not OPS[op] then + return nil, ("unknown comparator %q in range"):format(op) + end + local target = Semver.parse(rest) + if not target then + return nil, ("unparsable version %q in range"):format(rest) + end + local order = Semver.compare(version, target) + if op == "=" or op == "==" then return order == 0 end + if op == ">" then return order > 0 end + if op == ">=" then return order >= 0 end + if op == "<" then return order < 0 end + if op == "<=" then return order <= 0 end + return order >= 0 and Semver.compare(version, caretUpper(target)) < 0 +end + +-- true only when every space-separated comparator in one alternative holds +local function matchAlternative(version, alternative) + local tokens = 0 + local ok = true + for token in alternative:gmatch("%S+") do + tokens = tokens + 1 + local hit, err = matchToken(version, token) + if err then return nil, err end + if not hit then ok = false end + end + if tokens == 0 then return nil, "empty range alternative" end + return ok +end + +-- returns false with no reason for a clean miss and false plus a reason for +-- an unparsable version or a malformed range; callers turn the reason into a +-- load error (api 2) or a warning (api 1) +function Semver.satisfies(version, range) + local parsed = Semver.parse(version) + if not parsed then + return false, ("unparsable version %q"):format(tostring(version)) + end + if range == nil or range == "" then return true end + if type(range) ~= "string" then return false, "range must be a string" end + local matched = false + local rest = range + while true do + local head, tail = rest:match("^(.-)||(.*)$") + local alternative = head or rest + local ok, err = matchAlternative(parsed, alternative) + if err then return false, err end + matched = matched or ok + if not tail then break end + rest = tail + end + return matched +end + +-- grammar-only check for manifest validation, where no version is in hand yet +function Semver.validRange(range) + if range == nil or range == "" then return true end + local _, err = Semver.satisfies("0.0.0", range) + if err then return false, err end + return true +end + +return Semver diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 8d12a3b4..30de704b 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -2,13 +2,80 @@ -- level evolutions trigger after battles once the level is reached, -- stone evolutions on item use, and trade evolutions when a link trade -- completes (src/link/Protocol.lua TradeSession:apply). +-- +-- Method dispatch runs through the merged evolution_methods registry: +-- a record's check(game, mon, evo, trigger) answers whether that +-- evolutions[] row fires for the trigger ({ kind = "levelup" | "item" | +-- "trade" | "manual" | , item = id?, ... }), wrapped by the +-- evolution.check hook so a mod can cancel or force any evolution. +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") local Stats = require("src.pokemon.Stats") local TextBox = require("src.render.TextBox") local Evolution = {} --- Find a pending level evolution for a mon (nil if none). +Evolution.METHODS = { + LEVEL = { + check = function(game, mon, evo, trigger) + return trigger.kind == "levelup" and mon.level >= (evo.level or 0) + end, + describe = function(evo) + return ("Level %d"):format(evo.level or 0) + end, + }, + ITEM = { + check = function(game, mon, evo, trigger) + return trigger.kind == "item" and trigger.item == evo.item + end, + describe = function(evo, data) + return (data and data.items[evo.item] or {}).name or evo.item + end, + consumesItem = true, + }, + TRADE = { + check = function(game, mon, evo, trigger) + return trigger.kind == "trade" + end, + describe = function() return "Trade" end, + }, +} + +function Evolution.registerInto(registry, _, owner) + for id, record in pairs(Evolution.METHODS) do + registry:register(id, record, owner) + end +end + +-- Single dispatch point over the merged registry, wrapped by the +-- evolution.check hook. Returns species, evo for the first matching +-- evolutions[] row, or nil. +function Evolution.pendingFor(game, mon, trigger) + trigger = trigger or { kind = "manual" } + local data = game.data + local def = data.pokemon[mon.species] + local methods = data.evolution_methods or Evolution.METHODS + for _, evo in ipairs(def.evolutions or {}) do + local method = methods[evo.method] + if method and method.check then + local should + if Runtime.wantsHook("evolution.check") then + should = Runtime.call("evolution.check", function(g, m, e, t) + return method.check(g, m, e, t) + end, game, mon, evo, trigger) + else + should = method.check(game, mon, evo, trigger) + end + if should then return evo.species, evo end + end + end + return nil +end + +-- Find a pending level evolution for a mon (nil if none). Frozen v1 +-- shim: callers pass a plain data table, so it stays a hookless LEVEL +-- check; game-holding callers use pendingFor. function Evolution.pendingLevelEvo(data, mon) local def = data.pokemon[mon.species] for _, evo in ipairs(def.evolutions) do @@ -20,9 +87,11 @@ function Evolution.pendingLevelEvo(data, mon) end -- Mutate the mon into the new species (stats, HP delta, dex flags). -function Evolution.apply(game, mon, newSpecies) +-- via is the evolution method id when the caller knows it. +function Evolution.apply(game, mon, newSpecies, via) local newDef = game.data.pokemon[newSpecies] assert(newDef, "evolve into unknown species " .. tostring(newSpecies)) + local fromSpecies = mon.species local hpLost = mon.stats.hp - mon.hp mon.species = newSpecies mon.stats = Stats.calc(newDef, mon.level, mon.dvs, mon.statExp) @@ -31,30 +100,46 @@ function Evolution.apply(game, mon, newSpecies) game.save.pokedex.seen[newSpecies] = true game.save.pokedex.owned[newSpecies] = true end + Runtime.emit("pokemon.evolved", { + mon = mon, fromSpecies = fromSpecies, toSpecies = newSpecies, via = via, + }) end -- Play the evolution movie (flashing forms), then apply + text. -- Headless (no real graphics) falls back to the plain text flow. -function Evolution.evolve(game, mon, newSpecies, onDone) +function Evolution.evolve(game, mon, newSpecies, onDone, via) if love.image and love.image.newImageData then - local EvolutionState = require("src.ui.EvolutionState") - game.stack:push(EvolutionState.new(game, mon, newSpecies, onDone)) + Screens.push(game, "EvolutionState", mon, newSpecies, onDone) return end local oldName = mon.nickname or game.data.pokemon[mon.species].name - Evolution.apply(game, mon, newSpecies) + Evolution.apply(game, mon, newSpecies, via) local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!") :format(oldName, oldName, game.data.pokemon[newSpecies].name) game.stack:push(TextBox.new(game, msg, onDone)) end +-- Entry point for mods whose methods fire outside the vanilla moments +-- (location or time triggers): runs pendingFor with the caller's trigger +-- and, on a match, plays the standard evolve movie. Returns the target +-- species or nil. +function Evolution.request(game, mon, trigger, onDone) + local species, evo = Evolution.pendingFor(game, mon, trigger) + if not species then + if onDone then onDone() end + return nil + end + Evolution.evolve(game, mon, species, onDone, evo and evo.method) + return species +end + -- After-battle hook: evolve everyone who qualifies (queued one at a time). function Evolution.checkParty(game, onDone) local pending = {} for _, mon in ipairs(game.save.party) do - local target = Evolution.pendingLevelEvo(game.data, mon) + local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" }) if target then - table.insert(pending, { mon = mon, to = target }) + table.insert(pending, { mon = mon, to = target, via = evo and evo.method }) end end local i = 0 @@ -65,7 +150,7 @@ function Evolution.checkParty(game, onDone) if onDone then onDone() end return end - Evolution.evolve(game, p.mon, p.to, nextOne) + Evolution.evolve(game, p.mon, p.to, nextOne, p.via) end nextOne() return #pending diff --git a/src/pokemon/Growth.lua b/src/pokemon/Growth.lua index 4bedf0b1..b67b5bac 100644 --- a/src/pokemon/Growth.lua +++ b/src/pokemon/Growth.lua @@ -1,5 +1,9 @@ -- Experience growth curves, ported from engine/pokemon/experience.asm --- (GrowthRateTable coefficients). +-- (GrowthRateTable coefficients). The merged Data.growth_rates registry +-- serves records over these curves; callers that pass it get mod curves, +-- callers that don't keep the vanilla six. + +local Logger = require("src.core.Logger") local Growth = {} @@ -13,15 +17,43 @@ local CURVES = { FAST = function(n) return math.floor((4 * n * n * n) / 5) end, SLOW = function(n) return math.floor((5 * n * n * n) / 4) end, } +Growth.CURVES = CURVES -function Growth.expForLevel(growthRate, level) - local curve = CURVES[growthRate] or CURVES.MEDIUM_FAST +local warned = {} + +-- rates is the merged Data.growth_rates (optional); an unknown curve +-- name logs once and falls back to MEDIUM_FAST instead of mis-leveling +-- silently +function Growth.expForLevel(growthRate, level, rates) + local record = rates and rates[growthRate] + if record and record.expForLevel then + return math.max(0, record.expForLevel(level)) + end + local curve = CURVES[growthRate] + if not curve then + if growthRate ~= nil and not warned[growthRate] then + warned[growthRate] = true + Logger.warn("unknown growth rate %s; using MEDIUM_FAST", tostring(growthRate)) + end + curve = CURVES.MEDIUM_FAST + end return math.max(0, curve(level)) end -function Growth.levelForExp(growthRate, exp) +-- one record per curve, each closing over the same clamped evaluation the +-- engine calls, so a registry lookup and Growth.expForLevel cannot diverge +function Growth.registerInto(registry, _, owner) + for id in pairs(CURVES) do + registry:register(id, { expForLevel = function(level) + return Growth.expForLevel(id, level) + end }, owner) + end +end + +function Growth.levelForExp(growthRate, exp, cap, rates) + cap = cap or 100 local level = 1 - while level < 100 and Growth.expForLevel(growthRate, level + 1) <= exp do + while level < cap and Growth.expForLevel(growthRate, level + 1, rates) <= exp do level = level + 1 end return level diff --git a/src/render/Assets.lua b/src/render/Assets.lua new file mode 100644 index 00000000..5f41af88 --- /dev/null +++ b/src/render/Assets.lua @@ -0,0 +1,105 @@ +-- Central image cache plus the mod-visible asset search path. Every +-- renderer that used to call love.graphics.newImage(path) straight goes +-- through Assets.image, so an enabled mod shadows a generated asset with +-- its own file without editing a single record, and one flush() drops +-- every downstream cache for dev-mode hot reload. +-- +-- No loader installed means resolve() is the identity, which is what +-- keeps a mod-free boot (and every headless test) loading exactly the +-- paths it always did. + +local Assets = {} + +-- resolved path -> love Image +local cache = {} +-- downstream caches that must empty when the search path changes +local invalidators = {} + +-- The loader bridge: overrideOrder() yields mods highest-priority-first +-- and derivedPath(rel) yields an existing save/mod-derived//. +-- nil until the loader installs one. +Assets.loader = nil + +local GENERATED = "assets/generated/" + +local function exists(path) + local fs = love and love.filesystem + if not (fs and fs.getInfo) then return false end + return fs.getInfo(path) ~= nil +end +Assets.exists = exists + +-- an override dir shadows the generated cache; a transform's derived +-- output is the fallback under it, so hand-authored art beats generated +function Assets.resolve(path) + local loader = Assets.loader + if not loader or type(path) ~= "string" then return path end + if path:sub(1, #GENERATED) ~= GENERATED then return path end + local rel = path:sub(#GENERATED + 1) + for _, mod in ipairs(loader:overrideOrder()) do + local candidate = mod.path .. "/overrides/" .. rel + if exists(candidate) then return candidate end + end + return loader:derivedPath(rel) or path +end + +function Assets.image(path) + local resolved = Assets.resolve(path) + local image = cache[resolved] + if not image then + image = love.graphics.newImage(resolved) + cache[resolved] = image + end + return image +end + +-- pixel-level reads (tile-shift variants, the spinner strip blit) resolve +-- the same way but stay uncached: the caller keeps the derived product +function Assets.imageData(path) + return love.image.newImageData(Assets.resolve(path)) +end + +function Assets.register(invalidate) + invalidators[#invalidators + 1] = invalidate +end + +-- hot reload's single entry point (20-developer-tooling): drop the central +-- cache and fan out to every registered downstream one. A cache whose +-- invalidator throws must not strand the ones behind it in the list. +function Assets.invalidate() + cache = {} + for _, fn in ipairs(invalidators) do pcall(fn) end +end + +Assets.flush = Assets.invalidate + +-- Loader:load hands over the live mod set once the merge is done. Load +-- order is priority ascending, so the search walks it backwards: the mod +-- that wins the record merge wins the asset lookup too. +function Assets.installLoader(loader) + if not loader then + Assets.loader = nil + Assets.invalidate() + return + end + local bridge = {} + function bridge:overrideOrder() + local order = {} + local loaded = loader.loaded or {} + for i = #loaded, 1, -1 do + order[#order + 1] = { id = loaded[i].manifest.id, path = loaded[i].path } + end + return order + end + function bridge:derivedPath(rel) + for _, mod in ipairs(self:overrideOrder()) do + local candidate = "save/mod-derived/" .. mod.id .. "/" .. rel + if exists(candidate) then return candidate end + end + return nil + end + Assets.loader = bridge + Assets.invalidate() +end + +return Assets diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index b6066a8e..88e40c4b 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -9,6 +9,8 @@ -- enemy is stronger (wBattleTransitionSpiralDirection). -- Pushed above the overworld; pops itself and runs onDone at the end. +local Runtime = require("src.mods.Runtime") + local BattleTransition = {} BattleTransition.__index = BattleTransition BattleTransition.isOpaque = false -- draws over the frozen overworld @@ -105,21 +107,73 @@ local function sweepOrder(arms) return tiles end +-- The eight wipes as records: frames is the wipe length, flash marks the +-- two circle wipes that call BattleTransition_FlashScreen first. new() +-- reads them, and the transitions registry serves the same table. +BattleTransition.STYLES = { + doublecircle = { kind = "wipe", frames = 40, flash = true }, + spiralin = { kind = "wipe", frames = 40 }, + circle = { kind = "wipe", frames = 40, flash = true }, + spiralout = { kind = "wipe", frames = 40 }, + hstripes = { kind = "wipe", frames = 24 }, + shrink = { kind = "wipe", frames = 24 }, + vstripes = { kind = "wipe", frames = 24 }, + split = { kind = "wipe", frames = 24 }, +} + +-- the eight wipes plus Transition's two warp fades: one registrant owns +-- the whole transitions namespace, so Builtins wires it once +function BattleTransition.registerInto(registry, data, owner) + for id, record in pairs(BattleTransition.STYLES) do + registry:register(id, record, owner) + end + require("src.render.Transition").registerInto(registry, data, owner) +end + +-- the merged record; the built-in table is the fallback for headless +-- callers and for any state built before Data:load +local function styleDef(game, style) + local data = game and game.data + local record = data and data.transitions and data.transitions[style] + return record or BattleTransition.STYLES[style] +end + local ORDERS = {} -- cached per style -local function orderFor(style) - if not ORDERS[style] then - if style == "spiralout" then - ORDERS[style] = outwardSpiralOrder() - elseif style == "spiralin" then - ORDERS[style] = inwardSpiralOrder() - elseif style == "circle" then - ORDERS[style] = sweepOrder(1) - elseif style == "doublecircle" then - ORDERS[style] = sweepOrder(2) +local BUILTIN_ORDERS = { + spiralout = outwardSpiralOrder, + spiralin = inwardSpiralOrder, + circle = function() return sweepOrder(1) end, + doublecircle = function() return sweepOrder(2) end, +} + +-- A registered style may bring its own tile order (a list of {x, y}, or a +-- function returning one); the four built-in orders are the defaults for +-- the styles that have always had them. +local function orderFor(style, def) + if ORDERS[style] == nil then + local order = def and def.order + if type(order) == "function" then + local ok, built = pcall(order) + order = ok and built or nil end + if type(order) ~= "table" then + local build = BUILTIN_ORDERS[style] + order = build and build() or false + end + ORDERS[style] = order or false end - return ORDERS[style] + return ORDERS[style] or nil +end + +-- the vanilla 3-bit select (battle_transitions.asm), and the default of +-- the transition.style hook a mod wraps to choose its own wipe +local BIT_STYLES = { [0] = "doublecircle", "spiralin", "circle", "spiralout", + "hstripes", "shrink", "vstripes", "split" } + +local function vanillaStyle(ctx) + return BIT_STYLES[(ctx.trainer and 1 or 0) + (ctx.stronger and 2 or 0) + + (ctx.dungeon and 4 or 0)] end -- opts: trainer (bool), stronger (bool), dungeon (bool) @@ -129,16 +183,20 @@ function BattleTransition.new(game, onDone, opts) self.onDone = onDone self.t = 0 opts = opts or {} - local bits = (opts.trainer and 1 or 0) + (opts.stronger and 2 or 0) - + (opts.dungeon and 4 or 0) - self.style = ({ [0] = "doublecircle", "spiralin", "circle", "spiralout", - "hstripes", "shrink", "vstripes", "split" })[bits] + local ctx = { trainer = opts.trainer, stronger = opts.stronger, + dungeon = opts.dungeon, game = game } + local style = Runtime.call("transition.style", vanillaStyle, ctx) + local def = styleDef(game, style) + -- a hook that names an unregistered style falls back to the vanilla bits + if not def then + style = vanillaStyle(ctx) + def = styleDef(game, style) + end + self.style = style + self.def = def -- only the circle wipes flash first (battle_transitions.asm:585,628) - self.phase = (self.style == "circle" or self.style == "doublecircle") - and "flash" or "wipe" - self.wipeLen = (self.style == "spiralin" or self.style == "spiralout" - or self.style == "circle" - or self.style == "doublecircle") and 40 or 24 + self.phase = def.flash and "flash" or "wipe" + self.wipeLen = def.frames return self end @@ -174,7 +232,14 @@ function BattleTransition:draw() local prog = math.min(1, self.t / self.wipeLen) local style = self.style - local order = orderFor(style) + -- a registered style may draw itself; the eight built-ins do not + if self.def and self.def.draw then + self.def.draw(self, prog) + love.graphics.setColor(1, 1, 1, 1) + return + end + + local order = orderFor(style, self.def) if order then -- tile-order wipes: spiral / circle sweeps local n = math.floor(#order * prog) diff --git a/src/render/Font.lua b/src/render/Font.lua index 6a1eaa17..ed8c8363 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -1,41 +1,105 @@ -- Text renderer using the real extracted font sheets and charmap. --- font.png holds glyph codes $80-$FF, font_extra.png $60-$7F (borders etc). +-- Glyphs live on *pages*: font.png holds codes $80-$FF, font_extra.png +-- $60-$7F (borders etc), and a mod registers more (a kana block at $100, +-- a replacement sheet for an existing page) through the font registry, +-- which merges into data.font.pages. A page may set its own `advance` +-- for variable-width text; the default is the GB's flat 8px. -- The charmap is matched greedily (longest sequence first) so multi-byte -- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs. +local Assets = require("src.render.Assets") + local Font = {} +local GLYPH = 8 + local state +local loadedFrom + +-- the two vanilla pages as the legacy def spells them, so a cache that +-- predates the pages table still loads and a mod that registers only one +-- page replaces just that one +local function pagesOf(def) + local pages = {} + if def.image then + pages.main = { image = def.image, base = def.mainBase or 0x80, + glyphsPerRow = def.glyphsPerRow or 16 } + end + if def.imageExtra then + pages.extra = { image = def.imageExtra, base = def.extraBase or 0x60, + glyphsPerRow = def.glyphsPerRow or 16 } + end + for id, page in pairs(def.pages or {}) do + if type(page) == "table" and page.image then pages[id] = page end + end + return pages +end function Font.load(data) + loadedFrom = data local def = data.font - local main = love.graphics.newImage(def.image) - local extra = love.graphics.newImage(def.imageExtra) - state = { - def = def, - main = main, - extra = extra, - mainQuads = {}, - extraQuads = {}, - byFirstByte = {}, - } - local function buildQuads(img, quads) - local iw, ih = img:getDimensions() - local perRow = iw / 8 - for i = 0, perRow * (ih / 8) - 1 do - quads[i] = love.graphics.newQuad((i % perRow) * 8, - math.floor(i / perRow) * 8, 8, 8, iw, ih) + state = { def = def, pages = {}, order = {}, byFirstByte = {} } + for id, page in pairs(pagesOf(def)) do + local ok, img = pcall(Assets.image, page.image) + if ok then + local iw, ih = img:getDimensions() + local perRow = page.glyphsPerRow or math.floor(iw / GLYPH) + local quads = {} + for i = 0, perRow * math.floor(ih / GLYPH) - 1 do + quads[i] = love.graphics.newQuad((i % perRow) * GLYPH, + math.floor(i / perRow) * GLYPH, GLYPH, GLYPH, iw, ih) + end + local entry = { id = id, image = img, quads = quads, + base = page.base, advance = page.advance or GLYPH } + state.pages[id] = entry + state.order[#state.order + 1] = entry end end - buildQuads(main, state.mainQuads) - buildQuads(extra, state.extraQuads) - -- charmap comes sorted longest-first from the extractor; bucket by first - -- byte for fast greedy matching - for _, entry in ipairs(def.charmap) do + -- highest base first: a code resolves against the last page that starts + -- at or below it, which is exactly what the old main/extra chain did + table.sort(state.order, function(a, b) return a.base > b.base end) + + -- Bucket the charmap by first byte for fast greedy matching, longest + -- sequence first *within* each bucket. The sort is ours rather than + -- the extractor's: a mod's page ships its own entries and nothing has + -- put them in length order. + local function bucket(entry) + if type(entry) ~= "table" or type(entry.seq) ~= "string" + or entry.seq == "" then return end local b = entry.seq:byte(1) state.byFirstByte[b] = state.byFirstByte[b] or {} table.insert(state.byFirstByte[b], entry) end + for _, entry in ipairs(def.charmap or {}) do bucket(entry) end + for _, page in pairs(def.pages or {}) do + for _, entry in ipairs(type(page) == "table" and page.charmap or {}) do + bucket(entry) + end + end + for _, entries in pairs(state.byFirstByte) do + table.sort(entries, function(a, b) return #a.seq > #b.seq end) + end + + Font.BORDER = {} + for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end + for key, code in pairs(def.border or {}) do Font.BORDER[key] = code end +end + +-- re-run load against the data it last saw, so hot reload picks up an +-- edited sheet or a newly merged page +function Font.invalidate() + if loadedFrom then Font.load(loadedFrom) end +end + +Assets.register(Font.invalidate) + +-- the page a glyph code draws from, or nil when nothing covers it +local function pageFor(code) + if not state then return nil end + for _, page in ipairs(state.order) do + if code >= page.base then return page end + end + return nil end -- Convert a text string into a list of glyph codes. Unknown characters @@ -72,27 +136,39 @@ function Font.encode(text) end function Font.drawCode(code, x, y) - local def = state.def - if code >= def.mainBase then - love.graphics.draw(state.main, state.mainQuads[code - def.mainBase], x, y) - elseif code >= def.extraBase then - love.graphics.draw(state.extra, state.extraQuads[code - def.extraBase], x, y) - end + local page = pageFor(code) + if not page then return end + local quad = page.quads[code - page.base] + if quad then love.graphics.draw(page.image, quad, x, y) end end --- Draw a plain single-line string at pixel (x, y). +-- how far the pen moves past a glyph; 8 unless its page says otherwise +function Font.advanceOf(code) + local page = pageFor(code) + return page and page.advance or GLYPH +end + +-- Draw a plain single-line string at pixel (x, y). Returns the width +-- drawn, which is #codes * 8 for every fixed-width page. function Font.draw(text, x, y) local codes = Font.encode(text) - for i, code in ipairs(codes) do - Font.drawCode(code, x + (i - 1) * 8, y) + local pen = x + for _, code in ipairs(codes) do + Font.drawCode(code, pen, y) + pen = pen + Font.advanceOf(code) end - return #codes * 8 + return pen - x end --- Border glyph codes (font_extra.png, from charmap.asm $79-$7E) -Font.BORDER = { +-- Border glyph codes (font_extra.png, from charmap.asm $79-$7E). A font +-- that draws its boxes from different glyphs sets data.font.border and +-- Font.load folds it over these; the table itself stays writable so a mod +-- can retheme one corner without shipping a whole page. +Font.DEFAULT_BORDER = { tl = 0x79, h = 0x7A, tr = 0x7B, v = 0x7C, bl = 0x7D, br = 0x7E, } +Font.BORDER = {} +for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end -- Draw a Game Boy style bordered box in tile coordinates. function Font.drawBox(tx, ty, tw, th) diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index 372b3d00..6c3299c8 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -3,14 +3,34 @@ -- status sheet (font_battle_extra -> $62) and the HUD line tiles -- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73). +local Assets = require("src.render.Assets") + local HudTiles = {} +-- The four HUD sheets are glyph pages like any other, so they resolve +-- through the font registry: mod.content.font:register("battle_hud_1", +-- { image = ..., base = 0x6D }) reskins the HP bar. These are the +-- vanilla pages the importer's cache carries, in the order the asm +-- overlays them ($6D lands on top of font_battle_extra's tail). +local PAGES = { + { id = "font_battle_extra", + image = "assets/generated/battle/font_battle_extra.png", base = 0x62 }, + { id = "battle_hud_1", + image = "assets/generated/battle/battle_hud_1.png", base = 0x6D }, + { id = "battle_hud_2", + image = "assets/generated/battle/battle_hud_2.png", base = 0x73 }, + { id = "battle_hud_3", + image = "assets/generated/battle/battle_hud_3.png", base = 0x76 }, +} + local tiles function HudTiles.tile(code, x, y, tint) if not tiles then tiles = {} + local registered = require("src.core.Data").font + registered = registered and registered.pages or nil local function add(path, base) - local ok, img = pcall(love.graphics.newImage, path) + local ok, img = pcall(Assets.image, path) if not ok then return end local iw, ih = img:getDimensions() local per = iw / 8 @@ -22,10 +42,11 @@ function HudTiles.tile(code, x, y, tint) } end end - add("assets/generated/battle/font_battle_extra.png", 0x62) - add("assets/generated/battle/battle_hud_1.png", 0x6D) -- overrides - add("assets/generated/battle/battle_hud_2.png", 0x73) - add("assets/generated/battle/battle_hud_3.png", 0x76) + for _, page in ipairs(PAGES) do + local override = registered and registered[page.id] + add(override and override.image or page.image, + override and override.base or page.base) + end end local t = tiles[code] if not t then return end @@ -35,6 +56,13 @@ function HudTiles.tile(code, x, y, tint) love.graphics.setColor(r, g, b, a) end +-- lazy: the next tile() rebuilds every page from the search path +function HudTiles.invalidate() + tiles = nil +end + +Assets.register(HudTiles.invalidate) + -- The bar's right-end tile follows wHPBarType (DrawHPBar's "Right" -- branch): only type 1 -- the player's in-battle bar and the status -- screen -- gets the double-bar $6D; the enemy bar (0) and the party diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 9fad582e..54a1575a 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -69,13 +69,57 @@ function PaletteFX.keyedShader() return keyedShader or nil end --- ATTR_BLK inclusive tile rect -> pixel-space zone +-- ATTR_BLK inclusive tile rect -> pixel-space zone. colors == false is +-- the trueColor opt-out: a real zone whose rect blits with no shader, so +-- full-color art survives the pass. nil still means "no zone at all". function PaletteFX.zone(colors, tx1, ty1, tx2, ty2) - if not colors then return nil end + if colors == nil then return nil end return { colors = colors, x = tx1 * 8, y = ty1 * 8, w = (tx2 - tx1 + 1) * 8, h = (ty2 - ty1 + 1) * 8 } end +-- the trueColor zone a sprite/tileset record asks for by name +function PaletteFX.trueColorZone(tx1, ty1, tx2, ty2) + return PaletteFX.zone(false, tx1, ty1, tx2, ty2) +end + +-- ------- trueColor zone collection + +-- A sprites/tilesets record carrying trueColor = true must not reach the +-- shade-remap shader (14 §trueColor propagation), but the states that +-- build the zone list know nothing about which records the frame drew. +-- So the renderer that draws one reports its covering rect here, in the +-- coordinates of the canvas it is filling, and Renderer:endFrame appends +-- the frame's rects to that pass's zone list as colors == false zones -- +-- the region is then re-blit unshaded on top of the colorized pass. +-- No vanilla record sets the flag, so both buckets stay empty every frame +-- and the zone lists are exactly the ones the states returned. +local trueColorRects = { ui = {}, world = {} } +local currentPass = nil + +-- which canvas the renderer is filling. nil for a pass that composites +-- with no zone list of its own (tilt's upright billboards carry their own +-- per-sprite colorization), which drops its rects on the floor. +function PaletteFX.setPass(name) + currentPass = trueColorRects[name] and name or nil +end + +function PaletteFX.clearTrueColor() + for _, rects in pairs(trueColorRects) do + for i = #rects, 1, -1 do rects[i] = nil end + end +end + +function PaletteFX.markTrueColor(x, y, w, h) + local rects = currentPass and trueColorRects[currentPass] + if not rects or w <= 0 or h <= 0 then return end + rects[#rects + 1] = { colors = false, x = x, y = y, w = w, h = h } +end + +function PaletteFX.trueColorRects(name) + return trueColorRects[name] or {} +end + function PaletteFX.whole(colors) return PaletteFX.zone(colors, 0, 0, 19, 17) end diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 3e16223a..46f7cc67 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -8,6 +8,7 @@ local Zoom = require("src.render.Zoom") local Tilt = require("src.render.Tilt") +local PaletteFX = require("src.render.PaletteFX") local Renderer = {} @@ -61,6 +62,9 @@ end function Renderer:beginFrame(transparent) self.worldActive = false self.uprightActive = false + -- last frame's trueColor rects go before anything draws this one + PaletteFX.clearTrueColor() + PaletteFX.setPass("ui") love.graphics.setCanvas(self.canvas) if transparent then love.graphics.clear(0, 0, 0, 0) @@ -77,11 +81,13 @@ function Renderer:beginWorldPass() self.worldCanvas:setFilter("nearest", "nearest") end self.worldActive = true + PaletteFX.setPass("world") love.graphics.setCanvas(self.worldCanvas) love.graphics.clear(1, 1, 1, 1) end function Renderer:endWorldPass() + PaletteFX.setPass("ui") love.graphics.setCanvas(self.canvas) end @@ -103,6 +109,7 @@ function Renderer:beginUprightPass() self.uprightCanvas:setFilter("nearest", "nearest") end self.uprightActive = true + PaletteFX.setPass(nil) love.graphics.setCanvas(self.uprightCanvas) love.graphics.clear(0, 0, 0, 0) -- shift the whole pass into the padded canvas so billboards keep drawing @@ -115,6 +122,7 @@ end -- return to the ground world canvas (the world pass owns it until draw() -- calls endWorldPass) function Renderer:endUprightPass() + PaletteFX.setPass("world") love.graphics.pop() love.graphics.setCanvas(self.worldCanvas) end @@ -181,7 +189,6 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target) local shader = self:tiltShader() local mesh = self:tiltMesh() if not (shader and mesh) then return false end - local PaletteFX = require("src.render.PaletteFX") local wvw = self.worldCanvas:getWidth() local wvh = self.worldCanvas:getHeight() @@ -201,8 +208,15 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target) local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil if zoneShader then love.graphics.setShader(zoneShader) + -- same trueColor sentinel the flat blit honors (14 §trueColor) + local bare = false for _, z in ipairs(zoneList) do - PaletteFX.sendColors(zoneShader, z.colors) + local plain = z.colors == false + if plain ~= bare then + bare = plain + love.graphics.setShader(not plain and zoneShader or nil) + end + if not plain then PaletteFX.sendColors(zoneShader, z.colors) end local x, y = math.max(0, z.x), math.max(0, z.y) local x2, y2 = math.min(wvw, z.x + z.w), math.min(wvh, z.y + z.h) if x2 > x and y2 > y then @@ -240,6 +254,20 @@ local function scissorClamped(x, y, w, h, ox, oy, vpw, vph) return true end +-- Splice the pass's trueColor rects (reported by the renderers that drew +-- a record carrying the flag) onto the end of its zone list, so each one +-- re-blits its region with no shader over the colorized pass. An absent +-- or empty zone list is left alone: that already draws the whole canvas +-- unshaded, which is what the rects were asking for. +local function withTrueColor(zoneList, pass) + local rects = PaletteFX.trueColorRects(pass) + if not (rects[1] and zoneList and zoneList[1]) then return zoneList end + local merged = {} + for i = 1, #zoneList do merged[i] = zoneList[i] end + for i = 1, #rects do merged[#merged + 1] = rects[i] end + return merged +end + -- zones: optional list of SGB palette regions (see PaletteFX) in -- 160x144 UI space, applied to the UI pass. worldZones: optional -- regions in world-canvas pixels (overworld survey zoom colors each @@ -255,12 +283,17 @@ function Renderer:endFrame(zones, worldZones) local vpw, vph = self.WIDTH * S, self.HEIGHT * S local ox = math.floor((ww - vpw) / 2) local oy = math.floor((wh - vph) / 2) - local PaletteFX = require("src.render.PaletteFX") local GBCFX = require("src.render.GBCFX") -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. zones = PaletteFX.ensureZones(zones) if worldZones then worldZones = PaletteFX.ensureZones(worldZones) end + -- the UI rects are in 160x144 canvas space and the world rects in world- + -- canvas pixels, matching the zone list each is appended to. A world + -- pass with no world zones falls back to the UI list, whose coordinate + -- space the world rects are not in, so they are dropped there. + zones = withTrueColor(zones, "ui") + worldZones = withTrueColor(worldZones, "world") local needPresent = GBCFX.active() local present = nil @@ -289,8 +322,17 @@ function Renderer:endFrame(zones, worldZones) return end love.graphics.setShader(shader) + -- a colors == false zone is the trueColor opt-out: its rect draws with + -- no shader at all. Nothing sets one without a mod, so a vanilla zone + -- list never toggles and issues exactly the calls it always did. + local bare = false for _, z in ipairs(zoneList) do - PaletteFX.sendColors(shader, z.colors) + local plain = z.colors == false + if plain ~= bare then + bare = plain + love.graphics.setShader(not plain and shader or nil) + end + if not plain then PaletteFX.sendColors(shader, z.colors) end if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale, z.w * zoneScale, z.h * zoneScale, boxX, boxY, boxW, boxH) then @@ -345,6 +387,7 @@ function Renderer:endFrame(zones, worldZones) end self.worldActive = false self.uprightActive = false + PaletteFX.setPass(nil) end return Renderer diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 9c5d089f..7895eea6 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -3,6 +3,9 @@ -- Right-facing frames are horizontal flips of the left frames. -- Sprites draw 4px above their cell, like the GB engine. +local Assets = require("src.render.Assets") +local PaletteFX = require("src.render.PaletteFX") + local SpriteRenderer = {} SpriteRenderer.__index = SpriteRenderer @@ -10,11 +13,19 @@ local imageCache = {} local function getImage(path) if not imageCache[path] then - imageCache[path] = love.graphics.newImage(path) + imageCache[path] = Assets.image(path) end return imageCache[path] end +-- hot reload drops the sheets; live instances hold their own image, so +-- the world rebuilds them (MapLoader.invalidateAll) rather than this +function SpriteRenderer.invalidate() + imageCache = {} +end + +Assets.register(SpriteRenderer.invalidate) + local STAND = { down = 0, up = 1, left = 2, right = 2 } local WALK = { down = 3, up = 4, left = 5, right = 5 } @@ -35,6 +46,8 @@ end function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) local x = math.floor(px - camX) local y = math.floor(py - camY) - 4 + -- full-color art claims its 16x16 cell out of the shade-remap pass + if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 16) end -- single-frame sprites (item balls, fossils...) have one fixed pose; -- still 3-frame sprites turn to face (the nurse at her machine, -- facePlayer on STAY NPCs) but never show walk frames diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index a7b9beb2..eeabd45b 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -7,13 +7,14 @@ -- the text is exhausted and A is pressed, then calls onDone. local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") local TextBox = {} TextBox.__index = TextBox +-- theme-free fallbacks; geometry resolves against Theme.textBox at +-- construction time, so an unthemed boot stays byte-identical local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6 -local LINE1_Y, LINE2_Y = (BOX_TY + 2) * 8, (BOX_TY + 4) * 8 -local TEXT_X = 8 local MAX_COLS = 18 -- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops @@ -33,8 +34,17 @@ function TextBox.new(game, text, onDone, opts) self.choice = opts and opts.choice self.defaultNo = opts and opts.defaultNo self.auto = opts and opts.auto + local box = Theme.textBox or {} + self.boxTx = box.tx or BOX_TX + self.boxTy = box.ty or BOX_TY + self.boxTw = box.tw or BOX_TW + self.boxTh = box.th or BOX_TH + self.maxCols = box.maxCols or MAX_COLS + self.textX = (self.boxTx + 1) * 8 + self.line1Y = (self.boxTy + 2) * 8 + self.line2Y = (self.boxTy + 4) * 8 text = TextBox.substitute(game, text) - self.pages = TextBox.paginate(text) + self.pages = TextBox.paginate(text, self.maxCols) self.pageIndex = 1 self.lineIndex = 1 self.charIndex = 0 @@ -46,23 +56,35 @@ function TextBox.new(game, text, onDone, opts) return self end -function TextBox.substitute(game, text) - local save = game.save - text = text:gsub("{PLAYER}", save.player.name or "RED") - text = text:gsub("{RIVAL}", save.player.rival or "BLUE") - -- wStringBuffer: give_item copies the item name here, like GiveItem -> - -- CopyToStringBuffer (home/give.asm); "received item!" texts read it - -- (staying set afterwards mirrors pokered's stale-buffer semantics) - if game.stringBuffer then - text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer) +-- The runtime tokens substitute() knows, as handlers the tokens registry +-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token. +-- RAM keeps pokered's stale-buffer semantics: give_item copies the item +-- name into stringBuffer, like GiveItem -> CopyToStringBuffer +-- (home/give.asm), and it stays set afterwards. +TextBox.TOKENS = { + PLAYER = function(game) return game.save.player.name or "RED" end, + RIVAL = function(game) return game.save.player.rival or "BLUE" end, + RAM = function(game, arg) + return arg == "wStringBuffer" and game.stringBuffer or nil + end, +} + +function TextBox.registerInto(registry, _, owner) + for id, handler in pairs(TextBox.TOKENS) do + registry:register(id, handler, owner) end - text = text:gsub("{[%w_:]+}", "") -- other runtime tokens: drop visibly-empty - return text +end + +function TextBox.substitute(game, text) + local Tokens = require("src.script.Tokens") + local handlers = game.data and game.data.tokens or TextBox.TOKENS + return Tokens.expand(game, text, handlers) end -- Split marked-up text into pages of lines. \v-scrolled lines become -- additional lines on the same page (the box scrolls them). -function TextBox.paginate(text) +function TextBox.paginate(text, maxCols) + maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS local pages = {} for pageText in (text .. "\f"):gmatch("(.-)\f") do if pageText ~= "" then @@ -70,9 +92,9 @@ function TextBox.paginate(text) for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do local line = chunk -- wrap long lines defensively (the source rarely needs it) - while #line > MAX_COLS do - local cut = MAX_COLS - for i = MAX_COLS, 1, -1 do + while #line > maxCols do + local cut = maxCols + for i = maxCols, 1, -1 do if line:sub(i, i) == " " then cut = i break end end table.insert(lines, line:sub(1, cut)) @@ -194,25 +216,27 @@ function TextBox:update(dt) end function TextBox:draw() - Font.drawBox(BOX_TX, BOX_TY, BOX_TW, BOX_TH) + Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh) love.graphics.setColor(0, 0, 0, 1) if self.scrollPx and self.scrollPx > 0 then self.scrollPx = self.scrollPx - 2 if self.scrollPx <= 0 then self.scrollPx = nil end end local off = self.scrollPx or 0 - local ys = { LINE1_Y, LINE2_Y } + local ys = { self.line1Y, self.line2Y } for i, line in ipairs(self.shown) do - local y = (ys[i] or LINE2_Y) + off + local y = (ys[i] or self.line2Y) + off for j, code in ipairs(line) do - Font.drawCode(code, TEXT_X + (j - 1) * 8, y) + Font.drawCode(code, self.textX + (j - 1) * 8, y) end end if (self.waiting or (self.done and not self.choice and not self.auto)) and self.blink < 30 then - -- page-advance cursor: glyph $EE, the blinking down arrow the original - -- prints via `ld a, "▼"` (home/text.asm) - Font.drawCode(0xEE, 18 * 8, (BOX_TY + 5) * 8 - 4) + -- page-advance cursor: glyph $EE by default, the blinking down arrow + -- the original prints via `ld a, "▼"` (home/text.asm) + Font.drawCode(Theme.moreArrow or 0xEE, + (self.boxTx + self.boxTw - 2) * 8, + (self.boxTy + self.boxTh - 1) * 8 - 4) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 7e7e259f..f8773806 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -2,6 +2,9 @@ -- a single static SpriteBatch covering the map plus a border-block ring -- (the ring plays the role of the GB border blocks around small maps). +local Assets = require("src.render.Assets") +local PaletteFX = require("src.render.PaletteFX") + local TileRenderer = {} TileRenderer.__index = TileRenderer @@ -24,7 +27,7 @@ local imageCache = {} local function getImage(path) if not imageCache[path] then - imageCache[path] = love.graphics.newImage(path) + imageCache[path] = Assets.image(path) end return imageCache[path] end @@ -33,6 +36,9 @@ end -- Tile animation (home/vcopy.asm): tilesets with TILEANIM_WATER[_FLOWER] -- rotate water tile $14 one pixel every 20 frames (4 steps right, 4 -- left) and cycle flower tile $03 through 3 frames. +-- Those two cycles are the *defaults* a vanilla tileset record derives +-- from its `animation` string; a tileset that carries `animatedTiles` +-- declares its own set instead and animates with no engine change. -- ------------------------------------------------------------------ local WATER_TILE, FLOWER_TILE = 0x14, 0x03 @@ -40,6 +46,13 @@ local WATER_TILE, FLOWER_TILE = 0x14, 0x03 local WATER_OFFSETS = { 1, 2, 3, 2, 1, 0, 7, 0 } -- flower frame per step (wMovingBGTilesCounter2 & 3: <2 -> 1, 2, 3) local FLOWER_FRAMES = { 1, 2, 3, 1, 1, 2, 3, 1 } +local ANIM_PERIOD = 20 +local FLOWER_IMAGES = { + "assets/generated/tilesets/flower1.png", + "assets/generated/tilesets/flower2.png", + "assets/generated/tilesets/flower3.png", +} +local SPINNER_STRIP = "assets/generated/tilesets/spinners.png" local animFrame = 0 function TileRenderer.tick() @@ -87,19 +100,26 @@ function TileRenderer.spinBlurActive() return spinning and (math.floor(animFrame / 8) % 2 == 0) end --- the 8 shifted variants of a tileset's water tile (built once per sheet) -local waterVariants = {} -local function getWaterVariants(tilesetImagePath, perRow) - if waterVariants[tilesetImagePath] ~= nil then - return waterVariants[tilesetImagePath] - end +-- ------------------------------------------------------------------ +-- animatedTiles: the per-kind resource builders. Each returns the +-- texture list a step indexes into, or false when the pixels are +-- unreachable (headless, or a missing frame file) -- false disables that +-- one entry and leaves the static batch showing through, which is what +-- the water/flower branches did before they were data. +-- ------------------------------------------------------------------ + +-- the 8 shifted variants of one tile (built once per sheet + tile id) +local shiftVariants = {} +local function getShiftVariants(tilesetImagePath, perRow, tile) + local key = tilesetImagePath .. "#" .. tile + if shiftVariants[key] ~= nil then return shiftVariants[key] end if not (love.image and love.image.newImageData) then - waterVariants[tilesetImagePath] = false + shiftVariants[key] = false return false end - local id = love.image.newImageData(tilesetImagePath) - local sx = (WATER_TILE % perRow) * 8 - local sy = math.floor(WATER_TILE / perRow) * 8 + local id = Assets.imageData(tilesetImagePath) + local sx = (tile % perRow) * 8 + local sy = math.floor(tile / perRow) * 8 local out = {} for o = 0, 7 do local v = love.image.newImageData(8, 8) @@ -111,74 +131,153 @@ local function getWaterVariants(tilesetImagePath, perRow) end out[o + 1] = love.graphics.newImage(v) end - waterVariants[tilesetImagePath] = out + shiftVariants[key] = out return out end -local flowerFrames -local function getFlowerFrames() - if flowerFrames ~= nil then return flowerFrames end - flowerFrames = {} - for i = 1, 3 do - local ok, img = pcall(love.graphics.newImage, - ("assets/generated/tilesets/flower%d.png"):format(i)) - if not ok then flowerFrames = false return false end - flowerFrames[i] = img +local frameImages = {} +local function getFrameImages(paths) + local key = table.concat(paths, "|") + if frameImages[key] ~= nil then return frameImages[key] end + local out = {} + for i, path in ipairs(paths) do + local ok, img = pcall(getImage, path) + if not ok then + frameImages[key] = false + return false + end + out[i] = img end - return flowerFrames + frameImages[key] = out + return out end --- the tileset's own atlas ImageData with the 4 spinner-tile slots blitted --- over with the shared blur strip (assets/generated/tilesets/spinners.png, --- extracted from gfx/overworld/spinners.png); cached per tileset image path -local spinnerBlurImages = {} -local spinnerStripData -local function getSpinnerBlurImage(tilesetId, tilesetImagePath, perRow) - if spinnerBlurImages[tilesetImagePath] ~= nil then - return spinnerBlurImages[tilesetImagePath] - end - if not (love.image and love.image.newImageData) then - spinnerBlurImages[tilesetImagePath] = false +-- the tileset's own atlas ImageData with the patched tile slots blitted +-- over with a shared strip (vanilla: assets/generated/tilesets/spinners.png, +-- extracted from gfx/overworld/spinners.png); cached per tileset + strip +local toggleImages = {} +local stripData = {} +local function getToggleImage(spec, tilesetImagePath, perRow) + local key = tilesetImagePath .. "#" .. tostring(spec.image) + if toggleImages[key] ~= nil then return toggleImages[key] end + local offsets = spec.stripOffsets + if not (love.image and love.image.newImageData) or not offsets then + toggleImages[key] = false return false end - local destTiles = TileRenderer.SPINNER_ARROW_TILES[tilesetId] - local offsets = SPINNER_STRIP_OFFSET[tilesetId] - if not (destTiles and offsets) then - spinnerBlurImages[tilesetImagePath] = false + if stripData[spec.image] == nil then + local ok, id = pcall(Assets.imageData, spec.image) + stripData[spec.image] = ok and id or false + end + local strip = stripData[spec.image] + if not strip then + toggleImages[key] = false return false end - if spinnerStripData == nil then - local ok, id = pcall(love.image.newImageData, - "assets/generated/tilesets/spinners.png") - spinnerStripData = ok and id or false - end - if not spinnerStripData then - spinnerBlurImages[tilesetImagePath] = false - return false - end - local atlas = love.image.newImageData(tilesetImagePath) + local atlas = Assets.imageData(tilesetImagePath) local clone = love.image.newImageData(atlas:getWidth(), atlas:getHeight()) clone:paste(atlas, 0, 0, 0, 0, atlas:getWidth(), atlas:getHeight()) - for _, id in ipairs(destTiles) do - local sx = offsets[id] * 8 + for id, offset in pairs(offsets) do + local sx = offset * 8 local dx = (id % perRow) * 8 local dy = math.floor(id / perRow) * 8 for y = 0, 7 do for x = 0, 7 do - local r, g, b, a = spinnerStripData:getPixel(sx + x, y) + local r, g, b, a = strip:getPixel(sx + x, y) clone:setPixel(dx + x, dy + y, r, g, b, a) end end end local img = love.graphics.newImage(clone) - spinnerBlurImages[tilesetImagePath] = img + toggleImages[key] = img return img end +-- a toggle entry names the predicate that decides whether its patch shows +-- this frame; an unknown name (or none) is always on +TileRenderer.GATES = { + spinning = function() return TileRenderer.spinBlurActive() end, +} + +function TileRenderer.registerGate(name, predicate) + TileRenderer.GATES[name] = predicate +end + +local function gateOpen(name) + local predicate = TileRenderer.GATES[name] + if not predicate then return true end + return predicate() and true or false +end + +-- The vanilla animation set as data: what the importer would write onto a +-- tileset record derived from its `animation` string and its spinner-tile +-- row. Consulted only when the record declares no animatedTiles of its +-- own, so the vanilla frame is byte-for-byte what it always was. +function TileRenderer.defaultAnimatedTiles(tileset) + local out = {} + local anim = tileset.animation + if anim == "TILEANIM_WATER" or anim == "TILEANIM_WATER_FLOWER" then + out[#out + 1] = { tile = WATER_TILE, kind = "hshift", + period = ANIM_PERIOD, offsets = WATER_OFFSETS } + end + if anim == "TILEANIM_WATER_FLOWER" then + out[#out + 1] = { tile = FLOWER_TILE, kind = "frames", + period = ANIM_PERIOD, images = FLOWER_IMAGES, + sequence = FLOWER_FRAMES } + end + local spinners = TileRenderer.SPINNER_ARROW_TILES[tileset.id] + if spinners then + out[#out + 1] = { tiles = spinners, kind = "toggle", image = SPINNER_STRIP, + stripOffsets = SPINNER_STRIP_OFFSET[tileset.id], + gate = "spinning" } + end + return out +end + +-- one entry's runtime form: the tile ids it claims, the textures a step +-- picks from, and either a step sequence (hshift/frames) or a gate +-- (toggle). nil when the entry's pixels could not be built. +local function buildAnim(spec, tilesetImagePath, perRow, quads) + local tiles = spec.tiles + if not tiles then + if spec.tile == nil then return nil end + tiles = { spec.tile } + end + local period = spec.period or ANIM_PERIOD + if spec.kind == "hshift" then + local offsets = spec.offsets + if not offsets or #offsets == 0 then return nil end + local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1]) + if not textures then return nil end + local sequence = {} + for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end + return { tiles = tiles, textures = textures, sequence = sequence, + period = period } + elseif spec.kind == "frames" then + local sequence = spec.sequence + if not (spec.images and sequence and #sequence > 0) then return nil end + local textures = getFrameImages(spec.images) + if not textures then return nil end + return { tiles = tiles, textures = textures, sequence = sequence, + period = period } + elseif spec.kind == "toggle" then + local image = getToggleImage(spec, tilesetImagePath, perRow) + if not image then return nil end + -- the patch texture is a whole-atlas clone, so each cell needs the + -- quad of the tile it stands in rather than a single-tile image + return { tiles = tiles, textures = { image }, gate = spec.gate, + quadFor = function(tile) return quads[tile] end } + end + return nil +end + function TileRenderer.new(map) local self = setmetatable({}, TileRenderer) self.map = map self.image = getImage(map.tileset.image) + -- a full-color atlas colors everything it paints, ring and border fill + -- included, so every draw entry point claims its rect out of the pass + self.trueColor = map.tileset.trueColor or nil local iw, ih = self.image:getDimensions() self.quads = {} @@ -195,20 +294,22 @@ function TileRenderer.new(map) local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16 self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static") self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static") - -- animated tiles overdraw the static batches each frame - local anim = map.tileset.animation - local animWater = anim == "TILEANIM_WATER" or anim == "TILEANIM_WATER_FLOWER" - local variants = animWater and getWaterVariants(map.tileset.image, perRow) - local flowers = anim == "TILEANIM_WATER_FLOWER" and getFlowerFrames() - -- Gym/Rocket-Hideout spinner-arrow tiles (see SPINNER_ARROW_TILES above); - -- only GYM/FACILITY tilesets carry these dest tile ids - local spinnerIds = TileRenderer.SPINNER_ARROW_TILES[map.tileset.id] - local spinnerSet - if spinnerIds then - spinnerSet = {} - for _, id in ipairs(spinnerIds) do spinnerSet[id] = true end + -- animated tiles overdraw the static batches each frame. Entry order + -- decides which one claims a tile listed twice, so the vanilla defaults + -- keep the old water-then-flower-then-spinner precedence. + local anims, claimedBy = {}, {} + local declared = map.tileset.animatedTiles + or TileRenderer.defaultAnimatedTiles(map.tileset) + for _, spec in ipairs(declared) do + local anim = buildAnim(spec, map.tileset.image, perRow, self.quads) + if anim then + anim.cells = {} + anims[#anims + 1] = anim + for _, tile in ipairs(anim.tiles) do + if claimedBy[tile] == nil then claimedBy[tile] = anim end + end + end end - local water, flower, spinner = {}, {}, {} for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do @@ -222,12 +323,10 @@ function TileRenderer.new(map) if quad then batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8) end - if variants and tile == WATER_TILE then - table.insert(water, { bx * 32 + tx * 8, by * 32 + ty * 8, inside }) - elseif flowers and tile == FLOWER_TILE then - table.insert(flower, { bx * 32 + tx * 8, by * 32 + ty * 8, inside }) - elseif spinnerSet and spinnerSet[tile] then - table.insert(spinner, { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile }) + local anim = claimedBy[tile] + if anim then + local cells = anim.cells + cells[#cells + 1] = { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile } end end end @@ -237,9 +336,9 @@ function TileRenderer.new(map) -- animated overdraw batches: the full set (ring + body) for the -- current map, and a body-only set for connected-map drawing -- -- a neighbor's water ring must never overdraw this map's tiles. - -- `quadFor`, when given, looks up a per-entry quad (used by the spinner - -- batch, whose texture is a full tileset-atlas clone rather than a - -- single-tile image like the water/flower variants). + -- `quadFor`, when given, looks up a per-entry quad (used by toggle + -- entries, whose texture is a full tileset-atlas clone rather than a + -- single-tile image like the hshift/frames variants). local function animBatches(entries, image, quadFor) if #entries == 0 then return nil, nil end local all = love.graphics.newSpriteBatch(image, #entries, "static") @@ -253,23 +352,12 @@ function TileRenderer.new(map) end return all, body end - if variants then - self.waterBatch, self.waterBodyBatch = animBatches(water, variants[1]) - self.waterVariants = self.waterBatch and variants or nil - end - if flowers then - self.flowerBatch, self.flowerBodyBatch = animBatches(flower, flowers[1]) - self.flowerFrames = self.flowerBatch and flowers or nil - end - if spinnerSet then - local blurImage = getSpinnerBlurImage(map.tileset.id, map.tileset.image, perRow) - if blurImage then - local quads = self.quads - self.spinnerBatch, self.spinnerBodyBatch = - animBatches(spinner, blurImage, function(tile) return quads[tile] end) - self.spinnerBlurImage = self.spinnerBatch and blurImage or nil - end + for _, anim in ipairs(anims) do + anim.batch, anim.bodyBatch = + animBatches(anim.cells, anim.textures[1], anim.quadFor) + anim.cells = nil end + self.anims = anims -- a repeating 32x32 image of the border block, tiled behind -- everything the 3-block ring doesn't cover (the survey zoom sees @@ -302,6 +390,7 @@ end -- meshes seamlessly with the ring batch) function TileRenderer:drawBorderFill(camX, camY, vw, vh) if not self.borderFill then return end + if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end local x, y = math.floor(camX), math.floor(camY) local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32) love.graphics.draw(self.borderFill, quad, 0, 0) @@ -349,33 +438,41 @@ function TileRenderer:drawCellBottom(cx, cy, camX, camY) if shader then love.graphics.setShader() end end --- water/flower overdraw at the current animation step; bodyOnly skips --- the ring positions (connected maps draw body-only) +-- animated overdraw at the current step; bodyOnly skips the ring +-- positions (connected maps draw body-only) function TileRenderer:drawAnimated(camX, camY, bodyOnly) - local waterBatch = bodyOnly and self.waterBodyBatch or self.waterBatch - local flowerBatch = bodyOnly and self.flowerBodyBatch or self.flowerBatch - local spinnerBatch = bodyOnly and self.spinnerBodyBatch or self.spinnerBatch - if not (waterBatch or flowerBatch or spinnerBatch) then return end - local i = (math.floor(animFrame / 20) % 8) + 1 + local anims = self.anims + if not anims then return end local x, y = -math.floor(camX), -math.floor(camY) - if waterBatch then - waterBatch:setTexture(self.waterVariants[WATER_OFFSETS[i] + 1]) - love.graphics.draw(waterBatch, x, y) - end - if flowerBatch then - flowerBatch:setTexture(self.flowerFrames[FLOWER_FRAMES[i]]) - love.graphics.draw(flowerBatch, x, y) - end - -- spinner arrow tiles (engine/overworld/spinners.asm): only 2 frames - -- (blur / restore-to-static), gated on spinBlurActive() rather than the - -- free-running water/flower cycle above -- when false, draw nothing so - -- the already-static mapBatch/ringBatch tile shows through unchanged - if spinnerBatch and TileRenderer.spinBlurActive() then - love.graphics.draw(spinnerBatch, x, y) + for _, anim in ipairs(anims) do + local batch = bodyOnly and anim.bodyBatch or anim.batch + if batch then + if anim.gate then + -- a gated entry has only the two frames the asm has (patch / + -- restore-to-static); when the gate is shut draw nothing so the + -- already-static mapBatch/ringBatch tile shows through unchanged + if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end + else + local step = math.floor(animFrame / anim.period) % #anim.sequence + 1 + batch:setTexture(anim.textures[anim.sequence[step]]) + love.graphics.draw(batch, x, y) + end + end end end +-- the drawn extent of one batch in world-canvas pixels; `blocks` is the +-- ring width the batch reaches past the map body on every side +function TileRenderer:markTrueColor(camX, camY, blocks) + local def = self.map.def + PaletteFX.markTrueColor(-math.floor(camX) - blocks * 32, + -math.floor(camY) - blocks * 32, + (def.width + 2 * blocks) * 32, + (def.height + 2 * blocks) * 32) +end + function TileRenderer:draw(camX, camY) + if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY)) love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY)) self:drawAnimated(camX, camY) @@ -383,6 +480,7 @@ end -- body only, for connected-map strips function TileRenderer:drawMapOnly(camX, camY) + if self.trueColor then self:markTrueColor(camX, camY, 0) end love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY)) self:drawAnimated(camX, camY, true) end @@ -392,16 +490,22 @@ function TileRenderer:rebuild() local fresh = TileRenderer.new(self.map) self.ringBatch = fresh.ringBatch self.mapBatch = fresh.mapBatch - self.waterBatch = fresh.waterBatch - self.waterBodyBatch = fresh.waterBodyBatch - self.waterVariants = fresh.waterVariants - self.flowerBatch = fresh.flowerBatch - self.flowerBodyBatch = fresh.flowerBodyBatch - self.flowerFrames = fresh.flowerFrames - self.spinnerBatch = fresh.spinnerBatch - self.spinnerBodyBatch = fresh.spinnerBodyBatch - self.spinnerBlurImage = fresh.spinnerBlurImage + self.anims = fresh.anims self.borderFill = fresh.borderFill end +-- drop every atlas and every derived animation texture so the next +-- TileRenderer.new re-resolves through the asset search path. Live +-- instances keep the batches they already built; MapLoader.invalidateAll +-- is what drops those (14 §cache-invalidation contract). +function TileRenderer.invalidate() + imageCache = {} + shiftVariants = {} + frameImages = {} + toggleImages = {} + stripData = {} +end + +Assets.register(TileRenderer.invalidate) + return TileRenderer diff --git a/src/render/Transition.lua b/src/render/Transition.lua index a5d4a073..c6c06511 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -5,6 +5,29 @@ local Transition = {} Transition.__index = Transition local FRAMES = 12 +local FLASH_FRAMES = 7 + +-- The two fades as transitions records, so a mod retimes a warp fade the +-- same way it retimes a battle wipe. BattleTransition.registerInto pulls +-- these in with its eight wipes -- one registrant owns the registry. +Transition.STYLES = { + warp_fade = { kind = "fade", frames = FRAMES }, + white_flash = { kind = "fade", frames = FLASH_FRAMES }, +} + +function Transition.registerInto(registry, _, owner) + for id, record in pairs(Transition.STYLES) do + registry:register(id, record, owner) + end +end + +-- the merged record, falling back to the built-in when no data is around +-- (headless callers, and any state built before Data:load) +local function styleOf(game, id) + local data = game and game.data + local record = data and data.transitions and data.transitions[id] + return record or Transition.STYLES[id] +end function Transition.new(game, onMidpoint, onDone) local self = setmetatable({}, Transition) @@ -13,12 +36,13 @@ function Transition.new(game, onMidpoint, onDone) self.onDone = onDone self.t = 0 self.phase = "out" + self.frames = styleOf(game, "warp_fade").frames or FRAMES return self end function Transition:update(dt) self.t = self.t + 1 - if self.t >= FRAMES then + if self.t >= self.frames then self.t = 0 if self.phase == "out" then self.phase = "in" @@ -31,7 +55,7 @@ function Transition:update(dt) end function Transition:draw() - local alpha = self.t / FRAMES + local alpha = self.t / self.frames if self.phase == "in" then alpha = 1 - alpha end love.graphics.setColor(0, 0, 0, alpha) love.graphics.rectangle("fill", 0, 0, 160, 144) @@ -48,7 +72,9 @@ WhiteFlash.__index = WhiteFlash WhiteFlash.isOpaque = true function Transition.whiteFlash(game, frames, onDone) - return setmetatable({ game = game, frames = frames or 7, + return setmetatable({ game = game, + frames = frames or styleOf(game, "white_flash").frames + or FLASH_FRAMES, onDone = onDone, t = 0 }, WhiteFlash) end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index a75c58fd..b35b6e5f 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -7,10 +7,61 @@ local Flags = require("src.script.Flags") local Logger = require("src.core.Logger") +local Screens = require("src.ui.Screens") local TextBox = require("src.render.TextBox") local Commands = {} +-- "mod:" keys route to save.modData[owner], the mod-private namespace +-- (09 §4.8); owner comes from the dispatching contribution's source +-- attribution, so an engine-owned script using one is a script error +local function modFieldOwner(ctx) + local owner = ctx.source and ctx.source.modId + if not owner then + error("'mod:' fields need a mod-owned script", 0) + end + return owner +end + +local function flagValue(ctx, name) + local rest = type(name) == "string" and name:match("^mod:(.+)$") + if rest then + local modData = ctx.save.modData + local bucket = modData and modData[modFieldOwner(ctx)] + return (bucket and bucket[rest]) and true or false + end + return Flags.get(ctx.save, name) +end + +-- Parallel-runner move locks (09 §4.6): a background script moving an +-- NPC takes a per-NPC lock; a foreground script requesting the same NPC +-- preempts (kills) the background runner. The player is never movable +-- from a parallel runner. +local function claimMove(ctx, entity) + local ow = ctx.overworld + if not ow then return end + if ctx.runner.parallel then + if entity == ow.player then + error("parallel scripts cannot move the player", 0) + end + ow.npcMoveLocks = ow.npcMoveLocks or {} + ow.npcMoveLocks[entity] = ctx.runner + else + local holder = ow.npcMoveLocks and ow.npcMoveLocks[entity] + if holder and holder ~= ctx.runner and ow.killParallel then + Logger.warn("script: foreground move preempts a parallel runner") + ow:killParallel(holder) + -- the dead runner's queued steps go too; the foreground move owns + -- the entity now + for i = #ow.scriptMoves, 1, -1 do + if ow.scriptMoves[i].entity == entity then + table.remove(ow.scriptMoves, i) + end + end + end + end +end + -- show_text [subs]: textId is looked up in generated -- text (by label like "_PalletTownGirlText" or via the map's TEXT_* -- pointers). subs replaces dynamic tokens, e.g. { RAM = "BULBASAUR" } @@ -53,6 +104,23 @@ function Commands.show_text(ctx, textId, subs) return require("src.core.Sound").playCry(ctx.game.data, species) end, delay = 0 } } -- WaitForSoundToFinish has no trailing Delay3 of its own end + -- text_opts armed the next box: auto = true is the plain no-button-wait + -- form, overlap folds under auto, everything else passes through + if ctx.textOpts then + local armed = ctx.textOpts + ctx.textOpts = nil + opts = opts or {} + for k, v in pairs(armed) do + if k == "auto" then + opts.auto = opts.auto or (v == true and {} or v) + elseif k == "overlap" then + opts.auto = opts.auto or {} + opts.auto.overlap = v + else + opts[k] = v + end + end + end ctx.game.stack:push(TextBox.new(ctx.game, text, function() runner:resume() end, opts)) @@ -91,7 +159,7 @@ function Commands.clear_flag(ctx, name) end function Commands.check_flag(ctx, name) - ctx.lastCheck = Flags.get(ctx.save, name) + ctx.lastCheck = flagValue(ctx, name) end function Commands.check_item(ctx, itemId) @@ -119,7 +187,8 @@ function Commands.give_item(ctx, itemId, count, gotText) -- room and talk again, like the original (pokered's `jr nc, .bag_full` -- skips the received text entirely when AddItemToInventory refuses) if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then - Commands.show_text(ctx, "You can't carry\nany more items!") + Commands.show_text(ctx, ctx.game.data.text + and ctx.game.data.text._BagFullText or "You can't carry\nany more items!") return math.huge end local def = ctx.game.data.items[itemId] @@ -178,6 +247,7 @@ function Commands.wait(ctx, frames) end local function walkEntity(ctx, entity, dir, tiles) + claimMove(ctx, entity) local runner = ctx.runner ctx.overworld:scriptMove(entity, dir, tiles or 1, function() runner:resume() @@ -289,7 +359,25 @@ end -- consumed by a map's onEnter, e.g. save.pendingHallOfFame handed from the -- Champions Room warp to the HALL_OF_FAME room cutscene). Not a flag: it -- lives outside the event-flag namespace and is cleared on consumption. +-- "mod:key" routes to the owning mod's save.modData namespace instead of +-- the save root, so mod state stays attributable. function Commands.set_field(ctx, key, value) + local rest = type(key) == "string" and key:match("^mod:(.+)$") + if rest then + local owner = modFieldOwner(ctx) + local modData = ctx.save.modData + if not modData then + modData = {} + ctx.save.modData = modData + end + local bucket = modData[owner] + if not bucket then + bucket = {} + modData[owner] = bucket + end + bucket[rest] = value + return + end ctx.save[key] = value end @@ -425,11 +513,9 @@ function Commands.record_hall_of_fame(ctx) table.insert(ctx.save.hallOfFame, entry) local runner = ctx.runner local game = ctx.game - local HallOfFame = require("src.ui.HallOfFame") - local Credits = require("src.ui.Credits") - game.stack:push(HallOfFame.new(game, function() + Screens.push(game, "HallOfFame", function() -- the end credits roll after the induction (engine/movie/credits.asm) - game.stack:push(Credits.new(game, function() + Screens.push(game, "Credits", function() runner:resume() end, function() -- THE END is on screen: HallOfFameResetEventsAndSaveScript sets @@ -437,22 +523,24 @@ function Commands.record_hall_of_fame(ctx) -- save keeps the player standing in the HALL_OF_FAME room. (The -- E4 room-script/event resets that precede the save in pokered are -- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.) - ctx.save.lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 } + -- The reset heal point is field.boot's spawn, PALLET_TOWN (5,6) + -- in the vanilla dataset. + local boot = game.data.field and game.data.field.boot or {} + ctx.save.lastHeal = { map = boot.startMap or "PALLET_TOWN", + x = boot.startX or 5, y = boot.startY or 6 } if game.writeSave then game:writeSave() end - end)) - end)) + end) + end) runner:yield() -- after the A/B press on THE END the script does `jp Init`: a soft -- reset through the boot sequence -- copyright card + attract movie, -- then the title screen (the same path Game:load boots through) require("src.core.Music").stop() while game.stack:top() do game.stack:pop() end - local okIntro, IntroMovie = pcall(require, "src.ui.IntroMovie") - if okIntro and IntroMovie then - game.stack:push(IntroMovie.new(game, function() - if game.makeTitleState then game.stack:push(game:makeTitleState()) end - end)) - elseif game.returnToTitle then + local okIntro = pcall(Screens.push, game, "IntroMovie", function() + if game.makeTitleState then game.stack:push(game:makeTitleState()) end + end) + if not okIntro and game.returnToTitle then game:returnToTitle() end end @@ -496,20 +584,27 @@ function Commands.open_mart(ctx, textConst) Logger.warn("open_mart: no mart on %s/%s", ow.map.def.label, tostring(textConst)) return end - local ShopMenu = require("src.ui.ShopMenu") local runner = ctx.runner - ctx.game.stack:push(ShopMenu.new(ctx.game, entry.mart, function() + Screens.push(ctx.game, "ShopMenu", entry.mart, function() runner:resume() - end)) + end) runner:yield() end -- Rival battles pick the party from the player's starter choice -- (parties are ordered by the rival's own starter; see parties.asm): -- player CHARMANDER -> base+0, SQUIRTLE -> base+1, BULBASAUR -> base+2. -function Commands.rival_battle(ctx, oppClass, baseParty) +-- offsets (flag -> party offset) lets a modded roster remap the pick; +-- field.starterCounterpicks is the data-side default when stamped. +function Commands.rival_battle(ctx, oppClass, baseParty, offsets) + offsets = offsets + or (ctx.game.data.field and ctx.game.data.field.starterCounterpicks) local offset = 0 - if Flags.get(ctx.save, "EVENT_CHOSE_SQUIRTLE") then + if offsets then + for flag, mapped in pairs(offsets) do + if Flags.get(ctx.save, flag) then offset = mapped break end + end + elseif Flags.get(ctx.save, "EVENT_CHOSE_SQUIRTLE") then offset = 1 elseif Flags.get(ctx.save, "EVENT_CHOSE_BULBASAUR") then offset = 2 @@ -535,6 +630,9 @@ function Commands.trade(ctx, tradeIndex, doneFlag) local wantName = data.pokemon[trade.give] and data.pokemon[trade.give].name or trade.give local getName = data.pokemon[trade.get] and data.pokemon[trade.get].name or trade.get local dialogset = trade.dialogset or 1 -- older generated data: casual + -- a trade record may carry explicit text-label overrides (texts.wannaTrade + -- and friends); the dialogset families stay the defaults + local texts = trade.texts or {} local subs = { ["RAM:wInGameTradeGiveMonName"] = wantName, ["RAM:wInGameTradeReceiveMonName"] = getName, @@ -543,12 +641,12 @@ function Commands.trade(ctx, tradeIndex, doneFlag) Commands.show_text(ctx, label, subs) end if doneFlag and Flags.get(ctx.save, doneFlag) then - say("_AfterTrade" .. dialogset .. "Text") + say(texts.afterTrade or "_AfterTrade" .. dialogset .. "Text") return end - Commands.ask(ctx, "_WannaTrade" .. dialogset .. "Text", subs) + Commands.ask(ctx, texts.wannaTrade or "_WannaTrade" .. dialogset .. "Text", subs) if not ctx.lastCheck then - say("_NoTrade" .. dialogset .. "Text") + say(texts.noTrade or "_NoTrade" .. dialogset .. "Text") return end -- InGameTrade_DoTrade: DisplayPartyMenu -- the player picks which mon @@ -557,22 +655,21 @@ function Commands.trade(ctx, tradeIndex, doneFlag) local party = ctx.save.party local runner = ctx.runner local picked - local PartyMenu = require("src.ui.PartyMenu") - ctx.game.stack:push(PartyMenu.new(ctx.game, { + Screens.push(ctx.game, "PartyMenu", { pickOnly = true, onCancel = function() runner:resume() end, onSwitch = function(mon) picked = mon runner:resume() end, - })) + }) runner:yield() if not picked then - say("_NoTrade" .. dialogset .. "Text") + say(texts.noTrade or "_NoTrade" .. dialogset .. "Text") return end if picked.species ~= trade.give then - say("_WrongMon" .. dialogset .. "Text") + say(texts.wrongMon or "_WrongMon" .. dialogset .. "Text") return end local slot @@ -581,7 +678,7 @@ function Commands.trade(ctx, tradeIndex, doneFlag) end if not slot then return end -- unreachable: picked came from the party if doneFlag then Flags.set(ctx.save, doneFlag) end - say("_ConnectCableText") + say(texts.connectCable or "_ConnectCableText") local Pokemon = require("src.pokemon.Pokemon") local sent = party[slot] -- the received mon keeps the sent mon's level (wCurEnemyLevel) and, @@ -597,16 +694,334 @@ function Commands.trade(ctx, tradeIndex, doneFlag) dex.owned[trade.get] = true end -- the trade machine animation (engine/movie/trade.asm) - local TradeAnim = require("src.ui.TradeAnim") - ctx.game.stack:push(TradeAnim.new(ctx.game, { + Screens.push(ctx.game, "TradeAnim", { sent = sent, received = newMon, onDone = function() runner:resume() end, - })) + }) runner:yield() -- TradedForText (sound_get_key_item) then the dialogset's thanks require("src.core.Sound").play(data, "Get_Key_Item") - say("_TradedForText") - say("_Thanks" .. dialogset .. "Text") + say(texts.tradedFor or "_TradedForText") + say(texts.thanks or "_Thanks" .. dialogset .. "Text") +end + +-- ------- script v2 verbs: the promoted raw-Lua cutscene vocabulary + +-- label : jump target, pre-scanned by the runner; no-op here +function Commands.label() end + +-- emote [frames]: the emotion-bubble hold +-- (engine/overworld/emotion_bubbles.asm). target is "player", an object +-- index, or nil for the talking NPC; bubble names index +-- data.field.emotionBubbles.bubbles; blocks frames (default 60, the +-- trainer-sight hold). +local EMOTE_BUBBLES = { shock = 1, question = 2, happy = 3 } + +function Commands.emote(ctx, target, bubble, frames) + local ow = ctx.overworld + if not ow then return end + local entity + if target == "player" then + entity = ow.player + elseif type(target) == "number" then + entity = ow:npcByIndex(target) + else + entity = ctx.npc + end + if not entity then return end + local runner = ctx.runner + ow.emote = { + npc = entity, frames = frames or 60, + bubble = EMOTE_BUBBLES[bubble] or (type(bubble) == "number" and bubble) or 1, + onDone = function() runner:resume() end, + } + runner:yield() +end + +-- walk_npc [opts]: chained scriptMove along an +-- explicit direction list; opts.wait = false returns immediately with +-- the movement still running +function Commands.walk_npc(ctx, objIndex, dirs, opts) + local ow = ctx.overworld + if not ow then return end + local entity = objIndex == "player" and ow.player or ow:npcByIndex(objIndex) + if not entity then return end + claimMove(ctx, entity) + local runner = ctx.runner + local wait = not (opts and opts.wait == false) + local yielded, finished = false, false + local i = 0 + local function step() + i = i + 1 + if not dirs[i] then + finished = true + if wait and yielded then runner:resume() end + return + end + ow:scriptMove(entity, dirs[i], 1, step) + end + step() + if wait and not finished then + yielded = true + runner:yield() + end +end + +-- march_in_place : toggle the walk-in-place state +-- (NPC_CHANGE_FACING, movement.asm); the overworld re-arms the cycle +-- while the toggle stays set. Non-blocking, so ambient parallel +-- scripts can leave an NPC fidgeting. +function Commands.march_in_place(ctx, objIndex, on) + local ow = ctx.overworld + if not ow then return end + local npc = ow:npcByIndex(objIndex) + if not npc then return end + ow.marchers = ow.marchers or {} + ow.marchers[npc] = on and true or nil +end + +-- play_music [opts]: switch map music now; opts.keep marks it +-- to survive the next warp (the story files' keepMusic idiom) +function Commands.play_music(ctx, songId, opts) + require("src.core.Music").play(ctx.game.data, songId) + if opts and opts.keep and ctx.overworld then + ctx.overworld.keepMusicOnce = true + end +end + +function Commands.stop_music(ctx) + require("src.core.Music").stop() +end + +-- replace_block : the Cut-tree/card-key-door idiom, +-- on the current map +function Commands.replace_block(ctx, bx, by, blockId) + if ctx.overworld then ctx.overworld:replaceBlock(bx, by, blockId) end +end + +-- set_tile_anim : override the current tileset's animation +-- ("TILEANIM_WATER"; false stops it) until the next map change restores +-- the record (setMap) +function Commands.set_tile_anim(ctx, anim) + local ow = ctx.overworld + if not ow then return end + local tileset = ow.map.tileset + if not ow.tileAnimOverride then + ow.tileAnimOverride = { tileset = tileset, animation = tileset.animation } + end + tileset.animation = anim or nil + if ow.map.renderer then ow.map.renderer:rebuild() end +end + +-- text_opts : TextBox options for the NEXT show_text only; auto = +-- true is the plain no-button-wait box, overlap folds under auto +function Commands.text_opts(ctx, opts) + ctx.textOpts = opts +end + +-- push_screen [args]: instantiate through the screens +-- registry and block until the screen pops itself +function Commands.push_screen(ctx, screenId, args) + local screens = ctx.game.data.screens + if not (screens and screens[screenId]) then + error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0) + end + local runner = ctx.runner + local stack = ctx.game.stack + local state = Screens.push(ctx.game, screenId, args) + runner.waitingCheck = function() + for _, live in ipairs(stack.states) do + if live == state then return false end + end + return true + end + runner:yield() +end + +-- fade "out"|"in" [frames]: screen fade without warping (the Transition +-- ramp startWarpTo uses, split in two). "out" pushes a black overlay +-- that stays up; the held state keeps ticking the runner's frame-waits +-- so a script can wait/replace_block under it. "in" ramps it away. +local FadeOverlay = {} +FadeOverlay.__index = FadeOverlay + +function FadeOverlay.new(game, ow) + return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay) +end + +function FadeOverlay:update() + local ow = self.ow + if ow and ow.runner then ow.runner:update() end + local ramp = self.ramp + if not ramp then return end + ramp.t = ramp.t + 1 + local k = math.min(1, ramp.t / ramp.frames) + self.alpha = ramp.from + (ramp.to - ramp.from) * k + if ramp.t >= ramp.frames then + self.ramp = nil + if ramp.to <= 0 then + -- a box may sit above the overlay; remove in place, not pop + local states = self.game.stack.states + for i = #states, 1, -1 do + if states[i] == self then table.remove(states, i) break end + end + if ow then ow.fadeOverlay = nil end + end + if ramp.onDone then ramp.onDone() end + end +end + +function FadeOverlay:draw() + love.graphics.setColor(0, 0, 0, self.alpha) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(1, 1, 1, 1) +end + +function Commands.fade(ctx, dir, frames) + local ow = ctx.overworld + if not ow then return end + local runner = ctx.runner + frames = frames or 12 -- Transition's ramp length + local overlay = ow.fadeOverlay + if dir == "out" then + if not overlay then + overlay = FadeOverlay.new(ctx.game, ow) + ow.fadeOverlay = overlay + ctx.game.stack:push(overlay) + end + overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0, + onDone = function() runner:resume() end } + runner:yield() + elseif dir == "in" then + if not overlay then return end + overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0, + onDone = function() runner:resume() end } + runner:yield() + end +end + +-- pan_camera | "reset": offset the world camera by +-- cells over frames (blocking); "reset" snaps back to player-centered +function Commands.pan_camera(ctx, dx, dy, frames) + local ow = ctx.overworld + if not ow then return end + if dx == "reset" then + ow.cameraPan = nil + return + end + local runner = ctx.runner + local pan = ow.cameraPan or { ox = 0, oy = 0 } + ow.cameraPan = pan + pan.fromX, pan.fromY = pan.ox, pan.oy + pan.toX, pan.toY = pan.ox + dx * 16, pan.oy + dy * 16 + pan.frames, pan.t = math.max(1, frames or 30), 0 + pan.onDone = function() runner:resume() end + runner:yield() +end + +-- wait_flag [timeoutFrames]: yield until the flag is set, +-- re-checked once per frame; lastCheck = true on the flag, false on +-- timeout. The synchronization primitive for parallel scripts. +function Commands.wait_flag(ctx, flagName, timeoutFrames) + local runner = ctx.runner + local remaining = timeoutFrames + runner.waitingCheck = function() + if flagValue(ctx, flagName) then return true, true end + if remaining then + remaining = remaining - 1 + if remaining <= 0 then return true, false end + end + return false + end + ctx.lastCheck = runner:yield() +end + +-- run_parallel [opts]: start a background script in one of +-- the bounded slots and continue immediately. rowsOrRef is a row array +-- or "MAP_ID/name" naming a map_scripts `scripts` entry. +function Commands.run_parallel(ctx, rowsOrRef, opts) + local ow = ctx.overworld + if not ow or not ow.startParallel then return end + ow:startParallel(rowsOrRef, { source = ctx.source }) +end + +-- choice [opts]: N-way menu (src/ui/Menu); lastChoice = +-- { index, label }, lastCheck = (index == 1). opts.default preselects, +-- opts.cancel is the index B maps to (default: last). +function Commands.choice(ctx, labels, opts) + local Menu = require("src.ui.Menu") + local runner = ctx.runner + local function pick(index) + ctx.lastChoice = { index = index, label = labels[index] } + ctx.lastCheck = index == 1 + runner:resume() + end + local items = {} + for i, label in ipairs(labels) do + items[i] = { label = label, onSelect = function() pick(i) end } + end + local menu = Menu.new(ctx.game, items, { + onCancel = function() pick((opts and opts.cancel) or #labels) end, + }) + if opts and opts.default then menu.index = opts.default end + ctx.game.stack:push(menu) + runner:yield() +end + +-- ------- registry plumbing + +-- foreground commands push UI states or lock input and are illegal in +-- parallel scripts; blocking commands may yield the coroutine +Commands.meta = {} +for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", + "open_mart", "trade", "push_screen", "record_hall_of_fame", + "old_man_demo", "static_battle", "rival_battle", "give_item", + "give_pokemon", "fade", "pan_camera" }) do + Commands.meta[verb] = { foreground = true } +end +for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", + "open_mart", "trade", "push_screen", "record_hall_of_fame", + "old_man_demo", "static_battle", "rival_battle", "give_item", "wait", + "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", + "emote", "fade", "pan_camera" }) do + local meta = Commands.meta[verb] or {} + Commands.meta[verb] = meta + meta.blocking = true +end + +-- module functions that are not script verbs +local NOT_VERBS = { registerInto = true, resolve = true } + +-- what the engine handed the registry, so resolve can tell a mod's +-- override from the untouched self-registration +local registered = {} + +-- Dispatch resolution: a merged record a mod changed or added +-- (Data.commands differing from the engine snapshot) wins; otherwise the +-- live module table stays the dispatch target -- the D6 "merge into the +-- live Commands table" contract, which also keeps test doubles honest. +-- A record is the bare handler (the whole vanilla set) or { fn = ..., +-- foreground = ..., blocking = ... }. +function Commands.resolve(data, name) + if NOT_VERBS[name] then return nil end + local record = data and data.commands and data.commands[name] + if record == nil or record == registered[name] then + record = Commands[name] + end + if type(record) == "table" then return record.fn, record end + if type(record) ~= "function" then return nil end + return record, Commands.meta[name] +end + +-- every verb in this module is the registry's built-in set; a mod adding a +-- verb registers, a mod replacing one has to say override +function Commands.registerInto(registry, _, owner) + for verb, fn in pairs(Commands) do + if type(fn) == "function" and not NOT_VERBS[verb] then + registry:register(verb, fn, owner) + registered[verb] = fn + end + end end return Commands diff --git a/src/script/Flags.lua b/src/script/Flags.lua index db88b3b2..2201f52c 100644 --- a/src/script/Flags.lua +++ b/src/script/Flags.lua @@ -1,14 +1,28 @@ -- Event flags stored in the save table, keyed by pokered event constant -- names (e.g. "EVENT_FOLLOWED_OAK_INTO_LAB"). +-- +-- flag.changed fires through the runtime bus only on an actual +-- transition -- a redundant set of an already-true flag is silent -- and +-- the null bus makes the module usable headless. + +local Runtime = require("src.mods.Runtime") local Flags = {} function Flags.set(save, name) + local changed = save.flags[name] ~= true save.flags[name] = true + if changed and Runtime.wants("flag.changed") then + Runtime.emit("flag.changed", { name = name, value = true }) + end end function Flags.clear(save, name) + local changed = save.flags[name] == true save.flags[name] = nil + if changed and Runtime.wants("flag.changed") then + Runtime.emit("flag.changed", { name = name, value = false }) + end end function Flags.get(save, name) diff --git a/src/script/MapScripts.lua b/src/script/MapScripts.lua new file mode 100644 index 00000000..8e88bbea --- /dev/null +++ b/src/script/MapScripts.lua @@ -0,0 +1,285 @@ +-- The map_scripts compose store (09 §4.4). The engine's hand-ported +-- data/scripts/* modules are the base contribution -- attachBase folds +-- them with the v1 merge rules (talk per TEXT constant, other keys +-- replaced by later files), so a mod-free boot dispatches the exact +-- table it always did. Mod contributions arrive through the merged +-- registry (Data.map_scripts, one ordered chain per map id) and compose: +-- +-- talk / scripts / legacy keys single winner, false suppresses +-- onEnter / onVictory / onBoulderMoved all-run, pcall-guarded +-- onStep / onInteract first truthy return consumes +-- +-- Precedence is priority descending, later registration first at equal +-- priority; base sits at priority 0 behind every default-priority mod. + +local Data = require("src.core.Data") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") + +local MapScripts = {} + +local base = {} -- mapId -> merged engine contribution +local views = {} -- mapId -> { chain = chainRef, value = merged view, sources } + +local HOOK_RULES = { + onEnter = "all", onVictory = "all", onBoulderMoved = "all", + onStep = "first", onInteract = "first", +} + +-- the v1 merge, verbatim: later base files override earlier ones +function MapScripts.attachBase(mapId, contribution) + local existing = base[mapId] + if not existing then + base[mapId] = contribution + else + for k, v in pairs(contribution) do + if k == "talk" and existing.talk then + for textConst, script in pairs(v) do + existing.talk[textConst] = script + end + else + existing[k] = v + end + end + end + views[mapId] = nil +end + +function MapScripts.invalidate(mapId) + if mapId then + views[mapId] = nil + else + views = {} + end +end + +-- Registry:chain hands the mod contributions priority-descending with +-- earlier registrations first; re-rank each equal-priority run so a +-- later registration outranks an earlier one, then slot base in at +-- priority 0 behind the mods that tie it. chain.owners -- stamped by the +-- loader merge, index-aligned with the values -- rides along so every +-- contribution keeps its attribution. +local function contributions(chain, baseEntry) + local owners = chain.owners or {} + local ordered, run, runPriority = {}, {}, nil + local function flush() + for i = #run, 1, -1 do ordered[#ordered + 1] = run[i] end + run = {} + end + for i, entry in ipairs(chain) do + local p = type(entry) == "table" and entry.priority or 0 + if p ~= runPriority then + flush() + runPriority = p + end + run[#run + 1] = { value = entry, owner = owners[i] } + end + flush() + if baseEntry then + local at = #ordered + 1 + for i, entry in ipairs(ordered) do + local p = type(entry.value) == "table" and entry.value.priority or 0 + if (p or 0) < 0 then + at = i + break + end + end + table.insert(ordered, at, { value = baseEntry }) + end + return ordered +end + +-- the runner's ctx.source for one contribution's rows (09 §4.4): errors, +-- mod: field routing and the script events name the owning mod. Base +-- contributions stay unattributed, so a mod-free dispatch hands the runner +-- the exact extra it always did. +local function sourceFor(owner, mapId, hook) + if not (owner and owner.modId) then return nil end + return { modId = owner.modId, strict = owner.strict, + mapId = mapId, hook = hook } +end + +local function blame(mapId, hookName, owner, err) + local modId = owner and owner.modId + Logger.error("map script %s.%s [%s]: %s", mapId, hookName, + tostring(modId or "engine"), tostring(err)) + if modId then Runtime.reportError(modId, tostring(err)) end +end + +local function chainAll(mapId, hookName, handlers) + return function(...) + for _, handler in ipairs(handlers) do + local ok, err = pcall(handler.fn, ...) + if not ok then blame(mapId, hookName, handler.owner, err) end + end + end +end + +local function chainFirst(mapId, hookName, handlers) + return function(...) + local result + for _, handler in ipairs(handlers) do + local ok, value = pcall(handler.fn, ...) + if not ok then + blame(mapId, hookName, handler.owner, value) + elseif value then + return value + else + result = value + end + end + return result + end +end + +local function buildView(mapId, ordered) + local view = { talk = {} } + local sources = { talk = {}, scripts = {} } + local talkDefined, scriptsDefined, otherDefined = {}, {}, {} + local hooks = {} + for _, entry in ipairs(ordered) do + local contribution, owner = entry.value, entry.owner + for key, value in pairs(contribution) do + if key == "talk" then + for textConst, script in pairs(value) do + if not talkDefined[textConst] then + talkDefined[textConst] = true + -- false is explicit suppression: talkTo falls through to its + -- item-ball/trainer/mart branches + if script ~= false then + view.talk[textConst] = script + sources.talk[textConst] = sourceFor(owner, mapId, "talk") + end + end + end + elseif key == "scripts" then + view.scripts = view.scripts or {} + for name, rows in pairs(value) do + if not scriptsDefined[name] then + scriptsDefined[name] = true + if rows ~= false then + view.scripts[name] = rows + sources.scripts[name] = sourceFor(owner, mapId, "scripts." .. name) + end + end + end + elseif HOOK_RULES[key] then + local list = hooks[key] + if not list then + list = {} + hooks[key] = list + end + list[#list + 1] = { fn = value, owner = owner } + elseif key ~= "priority" then + -- legacy ad-hoc keys (snorlaxWake, escort, ...): talk's rule + if not otherDefined[key] then + otherDefined[key] = true + if value ~= false then view[key] = value end + end + end + end + end + for hookName, handlers in pairs(hooks) do + if #handlers == 1 and not (handlers[1].owner and handlers[1].owner.modId) then + -- a lone base handler dispatches bare, exactly as the v1 merge did + view[hookName] = handlers[1].fn + elseif HOOK_RULES[hookName] == "all" then + view[hookName] = chainAll(mapId, hookName, handlers) + else + view[hookName] = chainFirst(mapId, hookName, handlers) + end + end + return view, sources +end + +function MapScripts.get(mapId) + local chains = Data.map_scripts + local chain = chains and chains[mapId] + local baseEntry = base[mapId] + -- map_scripts:override or :remove cleared the chain for this map + -- (09 4.4), so the engine's own contribution is excluded too -- otherwise + -- a total conversion still gets the vanilla onEnter and every TEXT + -- constant it did not redefine, and a removed map is not actually gone. + -- A tombstone arrives as an empty chain and falls through to nil below + if chain and chain.replacesBase then baseEntry = nil end + if not chain or #chain == 0 then return baseEntry end + local hit = views[mapId] + if hit and hit.chain == chain then return hit.value end + local view, sources = buildView(mapId, contributions(chain, baseEntry)) + views[mapId] = { chain = chain, value = view, sources = sources } + return view +end + +-- the cached sources beside a map's merged view; nil when the map has no +-- chain (base fast path) and for base-owned winners +local function viewSources(mapId) + local chains = Data.map_scripts + local chain = chains and chains[mapId] + if not chain or #chain == 0 then return nil end + MapScripts.get(mapId) + local hit = views[mapId] + return hit and hit.sources +end + +-- ctx.source for a talk dispatch, handed to ScriptRunner:run by +-- showMapText so the winning contribution's rows run as their owner +function MapScripts.talkSource(mapId, textConst) + local sources = viewSources(mapId) + return sources and sources.talk[textConst] or nil +end + +-- ctx.source for a named `scripts` entry (run_parallel / queueScript refs) +function MapScripts.namedSource(mapId, name) + local sources = viewSources(mapId) + return sources and sources.scripts[name] or nil +end + +-- script to run when the player talks to an object with this TEXT_ constant +function MapScripts.talkScript(mapId, textConst) + local view = MapScripts.get(mapId) + return view and view.talk and view.talk[textConst] or nil +end + +-- the base (engine) talk handler behind any mod override -- the supported +-- replacement for the old re-wrap idiom +function MapScripts.baseTalk(mapId, textConst) + local entry = base[mapId] + return entry and entry.talk and entry.talk[textConst] or nil +end + +-- "MAP_ID/name" refs used by run_parallel and queueScript +function MapScripts.namedScript(mapId, name) + local view = MapScripts.get(mapId) + return view and view.scripts and view.scripts[name] or nil +end + +-- ------- load-time validation (09 §4.9) + +-- every row list reachable from one contribution; findings name the key +-- they were found under. lookup is the verb resolver handed to +-- ScriptRunner.validate; the loader and modkit share this pass. +function MapScripts.validateContribution(contribution, lookup) + local ScriptRunner = require("src.script.ScriptRunner") + local problems = {} + local function collect(where, rows) + if type(rows) ~= "table" then return end + for _, finding in ipairs(ScriptRunner.validate(rows, lookup)) do + problems[#problems + 1] = where .. ": " .. finding + end + end + if type(contribution) ~= "table" then + return { "contribution is not a table" } + end + for textConst, script in pairs(contribution.talk or {}) do + if type(script) == "table" then collect("talk." .. textConst, script) end + end + for name, rows in pairs(contribution.scripts or {}) do + if type(rows) == "table" then collect("scripts." .. name, rows) end + end + if type(contribution.snorlaxWake) == "table" then + collect("snorlaxWake", contribution.snorlaxWake.script) + end + return problems +end + +return MapScripts diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 74bc323a..43b8a470 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -4,15 +4,91 @@ -- -- Map-specific behavior lives in data/scripts/.lua modules, never in -- engine code. Each hand-ported script references its asm source. +-- +-- Script v2: { "label", "name" } rows are jump targets; jump/jump_if_* +-- may return a label name and exec resolves it through a pre-scanned +-- label map. "end" is reserved shorthand for halt. Numeric targets are +-- untouched, so hand-numbered vanilla ports keep working row for row. local Commands = require("src.script.Commands") local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility local ScriptRunner = {} ScriptRunner.__index = ScriptRunner +-- weak-keyed so a hot-reloaded script table drops its stale scan +local labelCache = setmetatable({}, { __mode = "k" }) + +function ScriptRunner.scanLabels(script) + local hit = labelCache[script] + if hit then return hit end + local labels = {} + for index, row in ipairs(script) do + if type(row) == "table" and row[1] == "label" and type(row[2]) == "string" + and labels[row[2]] == nil then + labels[row[2]] = index + end + end + labelCache[script] = labels + return labels +end + +-- Load-time validation (09 §4.9): every finding names its row. lookup +-- is fn(verb) -> true when the verb resolves; nil uses the built-in set, +-- so the offline validator and the loader share this code path. +function ScriptRunner.validate(script, lookup) + local problems = {} + local function bad(fmt, ...) + problems[#problems + 1] = fmt:format(...) + end + if type(script) ~= "table" then + bad("script is not a row list") + return problems + end + lookup = lookup or function(verb) return Commands[verb] ~= nil end + local labels = {} + for index, row in ipairs(script) do + if type(row) ~= "table" or type(row[1]) ~= "string" then + bad("row %d is not a { \"command\", ... } row", index) + elseif row[1] == "label" then + local name = row[2] + if type(name) ~= "string" then + bad("row %d: label needs a string name", index) + elseif labels[name] then + bad("row %d: duplicate label '%s' (first at row %d)", + index, name, labels[name]) + else + labels[name] = index + end + elseif not lookup(row[1]) then + bad("row %d: unknown command '%s'", index, row[1]) + end + end + for index, row in ipairs(script) do + if type(row) == "table" and (row[1] == "jump" or row[1] == "jump_if_true" + or row[1] == "jump_if_false") then + local target = row[2] + if type(target) == "string" then + if target ~= "end" and not labels[target] then + bad("row %d: jump to missing label '%s'", index, target) + end + elseif type(target) == "number" then + if target ~= math.huge and (target < 1 or target > #script + or target % 1 ~= 0) then + bad("row %d: jump target %s out of range 1..%d", + index, tostring(target), #script) + end + else + bad("row %d: jump needs a label or row number", index) + end + end + end + return problems +end + function ScriptRunner.new(game, overworld) local self = setmetatable({}, ScriptRunner) self.game = game @@ -25,7 +101,9 @@ function ScriptRunner:isRunning() return self.co ~= nil and coroutine.status(self.co) ~= "dead" end --- ctx passed to commands: engine services plus per-run info (npc, map) +-- ctx passed to commands: engine services plus per-run info (npc, map). +-- extra.source = { modId, mapId, hook } attributes errors and the mod: +-- field route to the contribution's owner. function ScriptRunner:makeContext(extra) local ctx = { game = self.game, @@ -40,27 +118,64 @@ end function ScriptRunner:run(script, extra) assert(not self:isRunning(), "script already running") local ctx = self:makeContext(extra) + self.ctx = ctx + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = ctx }) + end self.co = coroutine.create(function() self:exec(script, ctx) if ctx.onDone then ctx.onDone() end + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", { ctx = ctx, completed = true }) + end end) self:resume() end -- Execute a command list. Supports labels via jump commands: a script is --- an array of rows; control commands return a new program counter. +-- an array of rows; control commands return a new program counter, as a +-- row number or a label name. function ScriptRunner:exec(script, ctx) + local labels = ScriptRunner.scanLabels(script) + local data = self.game and self.game.data local pc = 1 while pc <= #script do local row = script[pc] local name = row[1] - local fn = Commands[name] + local fn, meta = Commands.resolve(data, name) if not fn then + -- api 2 owned scripts fail loudly; everything else keeps the v1 + -- skip so old content degrades instead of dying + if ctx.source and ctx.source.strict then + error(("unknown command '%s' at row %d"):format(tostring(name), pc), 0) + end Logger.warn("script: unknown command '%s' (skipped)", tostring(name)) pc = pc + 1 else - local jump = fn(ctx, select(2, unpack(row))) - if type(jump) == "number" then + if self.parallel and meta and meta.foreground then + error(("'%s' is a foreground command; illegal in a parallel script") + :format(name), 0) + end + local jump + if Runtime.wantsHook("script.command") then + local args = { select(2, unpack(row)) } + jump = Runtime.call("script.command", function(hctx, _, hargs) + return fn(hctx, unpack(hargs)) + end, ctx, name, args) + else + jump = fn(ctx, select(2, unpack(row))) + end + if type(jump) == "string" then + if jump == "end" then + pc = math.huge + else + local target = labels[jump] + if not target then + error(("jump to missing label '%s' at row %d"):format(jump, pc), 0) + end + pc = target + end + elseif type(jump) == "number" then pc = jump else pc = pc + 1 @@ -71,15 +186,28 @@ end -- Called by blocking commands from inside the coroutine. function ScriptRunner:yield() - coroutine.yield() + return coroutine.yield() end function ScriptRunner:resume(...) if not self.co then return end local ok, err = coroutine.resume(self.co, ...) if not ok then - Logger.error("script error: %s", tostring(err)) + local source = self.ctx and self.ctx.source + local where = source + and (" [%s %s.%s]"):format(tostring(source.modId or "engine"), + tostring(source.mapId or "?"), tostring(source.hook or "?")) + or "" + Logger.error("script error%s: %s", where, tostring(err)) + if source and source.modId then + Runtime.reportError(source.modId, tostring(err)) + end + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", { ctx = self.ctx, completed = false }) + end self.co = nil + self.waitingFrames = nil + self.waitingCheck = nil elseif coroutine.status(self.co) == "dead" then self.co = nil end @@ -94,6 +222,15 @@ function ScriptRunner:update() self:resume() end end + -- per-frame condition polls (wait_flag, push_screen): the check returns + -- done plus the value the yield should hand back + if self:isRunning() and self.waitingCheck then + local done, result = self.waitingCheck() + if done then + self.waitingCheck = nil + self:resume(result) + end + end end return ScriptRunner diff --git a/src/script/Tokens.lua b/src/script/Tokens.lua new file mode 100644 index 00000000..63fbd7e5 --- /dev/null +++ b/src/script/Tokens.lua @@ -0,0 +1,43 @@ +-- Text token expansion over the merged tokens registry. Grammar: {NAME} +-- or {NAME:arg}; the handler is fn(game, arg) -> string | nil. A nil +-- return drops the token (the RAM handler's contract for unset buffers); +-- an unknown NAME is also dropped -- rendering parity with the old +-- closed whitelist -- but logged once per name so a typo'd token is +-- findable instead of silently invisible. + +local Logger = require("src.core.Logger") + +local Tokens = {} + +local warned = {} + +function Tokens.warnOnce(name) + if warned[name] then return end + warned[name] = true + Logger.warn("unknown text token {%s}", name) +end + +-- handlers defaults to the merged registry (game.data.tokens); a headless +-- caller with no loader passes the engine set explicitly +function Tokens.expand(game, text, handlers) + handlers = handlers or (game.data and game.data.tokens) + if not handlers then return text end + -- the span classes mirror the old {[%w_:]+} catch-all: extractor spans + -- with spaces or pipes ({NUM:hCoins, 2 | LEADING_ZEROES ...}) were never + -- dropped before and must stay in the text byte-for-byte + return (text:gsub("{([%w_]+):?([%w_:]*)}", function(name, arg) + local fn = handlers[name] + if not fn then + Tokens.warnOnce(name) + return "" + end + local ok, out = pcall(fn, game, arg ~= "" and arg or nil) + if not ok then + Logger.error("token {%s}: %s", name, tostring(out)) + return "" + end + return out or "" + end)) +end + +return Tokens diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 4d428b99..b384b184 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -149,10 +149,10 @@ local function useOn(game, battle, id, target, list, moveIndex) game.data.pokemon[target.species].name, mdef.name) }) if result == "learn" then consume(game, id) end else - local MoveLearnMenu = require("src.ui.MoveLearnMenu") - game.stack:push(MoveLearnMenu.new(game, target, moveId, function(learned) - if learned and result == "learn" then consume(game, id) end - end)) + require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId, + function(learned) + if learned and result == "learn" then consume(game, id) end + end) end end list:close() @@ -162,10 +162,10 @@ local function useOn(game, battle, id, target, list, moveIndex) -- the TOWN MAP screen (engine/menus/town_map.asm) if result == "townmap" then - local ok, TownMap = pcall(require, "src.ui.TownMap") - if ok then - game.stack:push(TownMap.new(game)) - else + local ok = pcall(function() + require("src.ui.Screens").push(game, "TownMap") + end) + if not ok then showMessages(game, { "The TOWN MAP is\nunreadable here." }) end return @@ -243,8 +243,11 @@ local function useOn(game, battle, id, target, list, moveIndex) local moveId = moves[i] if not moveId then local Evolution = require("src.pokemon.Evolution") - local evoTo = Evolution.pendingLevelEvo(game.data, target) - if evoTo then Evolution.evolve(game, target, evoTo) end + local evoTo, evo = Evolution.pendingFor(game, target, + { kind = "levelup" }) + if evoTo then + Evolution.evolve(game, target, evoTo, nil, evo and evo.method) + end return end for _, mv in ipairs(target.moves) do @@ -257,8 +260,8 @@ local function useOn(game, battle, id, target, list, moveIndex) showMessages(game, { ("%s learned\n%s!"):format(name, mdef.name) }, nextStep) else - local MoveLearnMenu = require("src.ui.MoveLearnMenu") - game.stack:push(MoveLearnMenu.new(game, target, moveId, nextStep)) + require("src.ui.Screens").push(game, "MoveLearnMenu", + target, moveId, nextStep) end end nextStep() @@ -291,11 +294,10 @@ local function useItem(game, battle, id, list) local def = game.data.items[id] if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then -- pick a target from the party - local PartyMenu = require("src.ui.PartyMenu") -- the ETHERs and PP UP open the move menu after picking a mon -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" - game.stack:push(PartyMenu.new(game, { + require("src.ui.Screens").push(game, "PartyMenu", { pickOnly = true, onSwitch = function(mon) if not wantsMove then @@ -318,7 +320,7 @@ local function useItem(game, battle, id, list) end, })) end, - })) + }) else useOn(game, battle, id, nil, list) end diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua new file mode 100644 index 00000000..1707598a --- /dev/null +++ b/src/ui/BindingsMenu.lua @@ -0,0 +1,99 @@ +-- Rebinding over the logical Game Boy buttons (gap C2's file-12 half, +-- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON" +-- capture and the captured key or pad button lands in +-- save.options.bindings -- the overlay src/core/Bindings.lua +-- (04-mod-api-core) reads back over Input's fixed map. + +local Font = require("src.render.Font") +local ListMenu = require("src.ui.ListMenu") + +local BindingsMenu = setmetatable({}, { __index = ListMenu }) +BindingsMenu.__index = BindingsMenu + +-- Input.lua's map, primary key first where several keys share a button +local BUTTONS = { + { id = "up", label = "UP", key = "up" }, + { id = "down", label = "DOWN", key = "down" }, + { id = "left", label = "LEFT", key = "left" }, + { id = "right", label = "RIGHT", key = "right" }, + { id = "a", label = "A", key = "z" }, + { id = "b", label = "B", key = "x" }, + { id = "start", label = "START", key = "escape" }, + { id = "select", label = "SELECT", key = "rshift" }, +} + +-- a binding is a plain key string or { key, pad }; absent = the fixed +-- map, so a vanilla save renders today's keys byte-identically +local function boundKey(overlay, def) + local b = overlay and overlay[def.id] + if type(b) == "table" then return b.key or def.key end + if type(b) == "string" then return b end + return def.key +end + +function BindingsMenu.new(game) + local overlay = game.save and game.save.options + and game.save.options.bindings + local items = {} + for i, def in ipairs(BUTTONS) do + items[i] = { label = def.label, + right = boundKey(overlay, def):upper(), button = def } + end + local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}), + BindingsMenu) + self.onChoose = function(item) self:beginCapture(item) end + return self +end + +-- the capture handlers are per-instance slots, so Game's raw-input +-- routing only ever sees this screen while a capture is armed +function BindingsMenu:beginCapture(item) + self.capture = item + self.onKeyPressed = BindingsMenu.captureKey + self.onGamepadPressed = BindingsMenu.capturePad +end + +function BindingsMenu:captureKey(key) + self:storeBinding("key", key) +end + +function BindingsMenu:capturePad(button) + self:storeBinding("pad", button) +end + +function BindingsMenu:storeBinding(slot, value) + local item = self.capture + self.capture = nil + self.onKeyPressed = nil + self.onGamepadPressed = nil + local game = self.game + if not (item and value and game.save and game.save.options) then return end + local opts = game.save.options + opts.bindings = opts.bindings or {} + local b = opts.bindings[item.button.id] + if type(b) ~= "table" then + -- keep a direct-edited plain key string when only the pad changes + b = { key = type(b) == "string" and b or nil } + end + b[slot] = value + opts.bindings[item.button.id] = b + item.right = boundKey(opts.bindings, item.button):upper() + if game.writeOptions then game:writeOptions() end +end + +function BindingsMenu:update(dt) + if self.capture then return end -- the raw capture owns the input + ListMenu.update(self, dt) +end + +function BindingsMenu:draw() + ListMenu.draw(self) + if self.capture then + Font.drawBox(1, 6, 18, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("PRESS A BUTTON", 24, 60) + love.graphics.setColor(1, 1, 1, 1) + end +end + +return BindingsMenu diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 074bc9a1..2e5ca380 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -24,8 +24,7 @@ local function monSubmenu(game, action, mon, onAction) label = "STATS", keepOpen = true, onSelect = function() - local SummaryMenu = require("src.ui.SummaryMenu") - game.stack:push(SummaryMenu.new(game, mon)) + require("src.ui.Screens").push(game, "SummaryMenu", mon) end, }, { label = "CANCEL" }, diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 63e3996e..60299fde 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -1,12 +1,11 @@ -- YES/NO choice box (top-left of the text box area, like the original). local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") local ChoiceBox = {} ChoiceBox.__index = ChoiceBox -local CURSOR = 0xED - function ChoiceBox.new(game, onChoose, opts) local self = setmetatable({}, ChoiceBox) self.game = game @@ -39,11 +38,13 @@ function ChoiceBox:update(dt) end function ChoiceBox:draw() - Font.drawBox(0, 7, 6, 5) + local box = Theme.choiceBox + Font.drawBox(box.tx, box.ty, box.tw, box.th) love.graphics.setColor(0, 0, 0, 1) - Font.draw("YES", 16, 8 * 8) - Font.draw("NO", 16, 10 * 8) - Font.drawCode(CURSOR, 8, (self.index == 1 and 8 or 10) * 8) + Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8) + Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8) + Font.drawCode(Theme.cursor, (box.tx + 1) * 8, + (box.ty + (self.index == 1 and 1 or 3)) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index 18b7266f..05bb4ada 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -100,6 +100,7 @@ function Credits.new(game, onDone, onTheEnd) local credits = game.data.field and game.data.field.credits or {} self.screens = credits.screens or {} self.theEnd = credits.theEnd + self.music = credits.music or "Music_Credits" self.index = 0 self.screen = nil self.phase = "white" @@ -190,8 +191,8 @@ function Credits:update(dt) self.phase = "intro" self.timer = 128 local data = self.game.data - if data.audio and data.audio.songs and data.audio.songs.Music_Credits then - pcall(Music.play, data, "Music_Credits") + if data.audio and data.audio.songs and data.audio.songs[self.music] then + pcall(Music.play, data, self.music) end elseif self.phase == "intro" then self:nextScreen() diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 41140d0a..eb7f3b3c 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -43,7 +43,10 @@ function DexEntryMenu:draw() Font.draw(def.name, 72, 8) local e = def.dexEntry or {} Font.draw((e.kind or "?") .. " POKéMON", 72, 20) - Font.draw(("No.%03d"):format(def.dex or 0), 72, 32) + -- same number width as the list (constants.dexDigits), so a dex past 999 + -- prints the extra digit everywhere at once + local digits = (self.game.data.constants or {}).dexDigits or 3 + Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32) local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id] -- height/weight print only once owned, like the description -- (pokedex.asm: "if the pokemon has not been owned, don't print the diff --git a/src/ui/FlyMenu.lua b/src/ui/FlyMenu.lua index 3ab4d694..632a8c2b 100644 --- a/src/ui/FlyMenu.lua +++ b/src/ui/FlyMenu.lua @@ -2,16 +2,19 @@ -- spots from data/maps/special_warps.asm. local ListMenu = require("src.ui.ListMenu") +local Map = require("src.world.Map") local FlyMenu = {} function FlyMenu.new(game) local items = {} local visited = game.save.visited or {} - for _, mapId in ipairs(game.data.field.flyOrder) do - -- towns only (dungeon escape spots share the table) - if visited[mapId] and game.data.maps[mapId] - and game.data.maps[mapId].tileset == "OVERWORLD" then + local seen = {} + for _, mapId in ipairs(game.data.field.flyOrder or {}) do + -- towns only (dungeon escape spots share the table), each listed once + local def = game.data.maps[mapId] + if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then + seen[mapId] = true table.insert(items, { value = mapId, label = mapId:gsub("_", " "), diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index 3cab4224..d77d29dc 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -138,6 +138,11 @@ function IntroMovie.new(game, onDone) self.finished = false local intro = game.data.field and game.data.field.intro or {} + self.introCfg = intro + -- brand-level knobs (12 4.7): studio strings and the skip a total + -- conversion or dev profile sets to jump straight to the title + self.studio = intro.studio or {} + self.skipAll = intro.skip and true or false local function img(e) return tryImage(e and e.path) end self.copyright = tryImage("assets/generated/title/copyright.png") self.logo = img(intro.gamefreakLogo) @@ -177,8 +182,9 @@ function IntroMovie:startPhase(phase) -- intro.asm:333-338 local data = self.game.data local songs = data.audio and data.audio.songs - if songs and songs.Music_IntroBattle then - pcall(Music.play, data, "Music_IntroBattle", false) + local song = self.introCfg.music or "Music_IntroBattle" + if songs and songs[song] then + pcall(Music.play, data, song, false) end end end @@ -233,6 +239,10 @@ function IntroMovie:fightStep() end function IntroMovie:update(dt) + if self.skipAll then + self:finish() + return + end local input = self.game.input if input:wasPressed("a") or input:wasPressed("b") or input:wasPressed("start") then @@ -277,7 +287,8 @@ function IntroMovie:drawSplash() end -- custom studio name (replaces the GAME FREAK splash text) love.graphics.setColor(0, 0, 0, dim and 0.35 or 1) - Font.draw("bois club games", (160 - 15 * 8) / 2, TEXT_Y) + local card = self.studio.card or "bois club games" + Font.draw(card, (160 - #card * 8) / 2, TEXT_Y) love.graphics.setColor(1, 1, 1, 1) end if t >= STAR_START and t < FLASH_START then @@ -355,8 +366,9 @@ function IntroMovie:draw() -- custom boot card (replaces the Nintendo / GAME FREAK copyright -- card; no (c) glyph in the charmap, keep it ASCII-safe) love.graphics.setColor(0, 0, 0, 1) + local credit = self.studio.credit or "bois club" Font.draw("2026", (160 - 4 * 8) / 2, 48) - Font.draw("bois club", (160 - 9 * 8) / 2, 64) + Font.draw(credit, (160 - #credit * 8) / 2, 64) Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80) elseif self.phase == 2 then self:drawSplash() diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 476298a8..8f26df84 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 Theme = require("src.ui.Theme") local ListMenu = {} ListMenu.__index = ListMenu @@ -13,7 +14,6 @@ function ListMenu:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end -local CURSOR = 0xED local ROWS = 7 function ListMenu.new(game, title, items, opts) @@ -124,10 +124,10 @@ function ListMenu:draw() -- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's -- auto A-press, home/list_menu.asm:89-91) Font.drawCode((self.swapIndex == i or self.hollowIndex == i) - and 0xEC or CURSOR, 8, y) + and Theme.cursorHollow or Theme.cursor, 8, y) end if self.swapIndex == i and i ~= self.index then - Font.drawCode(0xEC, 8, y) -- ▷ marks the item being moved + Font.drawCode(Theme.cursorHollow, 8, y) -- ▷ marks the item being moved end end if self.dialogue then diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 6c01fae3..feb578f7 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -5,12 +5,11 @@ -- menu and only the start menu's adds PAD_START. local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") local Menu = {} Menu.__index = Menu -local CURSOR = 0xED -- "▶" glyph (right arrow) in font.png - function Menu.new(game, items, opts) local self = setmetatable({}, Menu) opts = opts or {} @@ -70,7 +69,7 @@ function Menu:draw() for i, item in ipairs(self.items) do Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8) end - Font.drawCode(CURSOR, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8) + Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/ModUI.lua b/src/ui/ModUI.lua new file mode 100644 index 00000000..381fa0e4 --- /dev/null +++ b/src/ui/ModUI.lua @@ -0,0 +1,60 @@ +-- The widget toolkit as the stable mod-facing surface (mod.ui): the six +-- widgets plus TextBox, Font and Theme, the screen push, and the +-- descriptor-list helpers for the menu-injection hooks. Widgets load on +-- first touch so a headless loader never drags the render stack in. + +local ModUI = {} + +local MODULES = { + Menu = "src.ui.Menu", + ListMenu = "src.ui.ListMenu", + ChoiceBox = "src.ui.ChoiceBox", + QuantityBox = "src.ui.QuantityBox", + NamingScreen = "src.ui.NamingScreen", + PicBox = "src.ui.PicBox", + TextBox = "src.render.TextBox", + Font = "src.render.Font", + Theme = "src.ui.Theme", +} + +setmetatable(ModUI, { __index = function(t, key) + local path = MODULES[key] + if not path then return nil end + local module = require(path) + rawset(t, key, module) + return module +end }) + +function ModUI.push(game, id, ...) + return require("src.ui.Screens").push(game, id, ...) +end + +local function indexOf(items, label) + for i, item in ipairs(items) do + if item.label == label then return i end + end + return nil +end + +-- anchored on stable labels so mods place entries without counting rows; +-- a missing anchor appends, which keeps the entry reachable either way +function ModUI.insertBefore(items, anchorLabel, item) + local i = indexOf(items, anchorLabel) + table.insert(items, i or (#items + 1), item) + return items +end + +function ModUI.insertAfter(items, anchorLabel, item) + local i = indexOf(items, anchorLabel) + table.insert(items, i and (i + 1) or (#items + 1), item) + return items +end + +function ModUI.removeLabel(items, label) + for i = #items, 1, -1 do + if items[i].label == label then table.remove(items, i) end + end + return items +end + +return ModUI diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 7ce415b1..c2ef9dc7 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -8,6 +8,7 @@ local Font = require("src.render.Font") local Sound = require("src.core.Sound") +local Theme = require("src.ui.Theme") local NamingScreen = {} NamingScreen.__index = NamingScreen @@ -18,8 +19,6 @@ function NamingScreen:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end -local CURSOR = 0xED - -- both letter pages (wAlphabetCase, data/text/alphabets.asm): row 6 is -- the case-switch cell, labelled with the page it flips to local GRID_UPPER = { @@ -155,7 +154,7 @@ function NamingScreen:draw() Font.draw(cell, c * 16, 32 + r * 16) end end - Font.drawCode(CURSOR, self.col * 16 - 8, 32 + self.row * 16) + Font.drawCode(Theme.cursor, self.col * 16 - 8, 32 + self.row * 16) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 9fd182f2..b9d04074 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -16,6 +16,15 @@ local OakSpeech = {} OakSpeech.__index = OakSpeech OakSpeech.isOpaque = true +-- naming presets are boot config (field.boot.namePresets), which a total +-- conversion replaces; the Red/Blue lists remain the fallback +local function namePresets(game, who, fallback) + local boot = game.data.field and game.data.field.boot + local presets = boot and boot.namePresets and boot.namePresets[who] + if type(presets) == "table" and #presets > 0 then return presets end + return fallback +end + -- SGB: generic whole-screen palette (SET_PAL_GENERIC) function OakSpeech:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") @@ -50,15 +59,21 @@ function OakSpeech.new(game, onDone) 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) - local nido = game.data.pokemon and game.data.pokemon.NIDORINO - self.nidorinoPic = tryImage(nido and nido.spriteFront) + local oakGfx = (game.data.field and game.data.field.oakSpeech) or {} + self.cfg = oakGfx + -- 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 constants = game.data.constants or {} + self.nameLen = constants.playerNameLength or 7 -- RedPicFront (gfx/player/red.png, shared with the trainer card) and -- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png) self.playerPic = tryImage("assets/generated/trainer_card/red.png") - local oakGfx = game.data.field and game.data.field.oakSpeech - self.shrinkPic1 = tryImage(oakGfx and oakGfx.shrink1 + self.shrinkPic1 = tryImage(oakGfx.shrink1 or "assets/generated/intro/shrink1.png") - self.shrinkPic2 = tryImage(oakGfx and oakGfx.shrink2 + self.shrinkPic2 = tryImage(oakGfx.shrink2 or "assets/generated/intro/shrink2.png") -- RedSprite: the walking sprite the pic shrinks into (frame 0 = -- standing, facing down) @@ -69,7 +84,7 @@ end function OakSpeech:enter() -- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48) - Music.play(self.game.data, "Music_Routes2") + Music.play(self.game.data, self.cfg.music or "Music_Routes2") self:advance() end @@ -85,8 +100,8 @@ local STEPS = { end, -- 2. NIDORINO show-off, with its cry function(self) - self.pic = self.nidorinoPic - Sound.playCry(self.game.data, "NIDORINO") + self.pic = self.demoPic + Sound.playCry(self.game.data, self.demoSpecies) self:say("_OakSpeechText2A", function() self:advance() end) end, -- 3. the rest of the world-of-POKéMON spiel @@ -100,16 +115,15 @@ local STEPS = { self:say("_IntroducePlayerText", function() self:advance() end) end, function(self) - local NamingScreen = require("src.ui.NamingScreen") - self.game.stack:push(NamingScreen.new(self.game, { + require("src.ui.Screens").push(self.game, "NamingScreen", { title = "YOUR NAME?", - presets = { "RED", "ASH", "JACK" }, - maxLen = 7, + presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }), + maxLen = self.nameLen, onDone = function(name) self.game.save.player.name = name self:advance() end, - })) + }) end, -- 6. the rival introduction and naming function(self) @@ -117,16 +131,15 @@ local STEPS = { self:say("_IntroduceRivalText", function() self:advance() end) end, function(self) - local NamingScreen = require("src.ui.NamingScreen") - self.game.stack:push(NamingScreen.new(self.game, { + require("src.ui.Screens").push(self.game, "NamingScreen", { title = "HIS NAME?", - presets = { "BLUE", "GARY", "JOHN" }, - maxLen = 7, + 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) diff --git a/src/ui/OptionRows.lua b/src/ui/OptionRows.lua new file mode 100644 index 00000000..1a39b71c --- /dev/null +++ b/src/ui/OptionRows.lua @@ -0,0 +1,59 @@ +-- The four-box options viewport, extracted from OptionsMenu so the mod +-- manager's per-mod options auto-UI renders schemas in the same idiom. +-- Rows are descriptors: +-- { id, label, value = fn(game) -> string, +-- step = fn(game, dir) -> changed, activate = fn(game) } +-- step handles Left/Right/A cyclers; activate is the A-press action for +-- rows that open something instead (MODS, CANCEL stays the caller's). + +local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") + +local OptionRows = {} + +OptionRows.VISIBLE = 4 -- option boxes on screen at once (4 tiles each) + +-- keep the cursor's box inside the viewport; the fixed bottom row shows +-- the tail of the list +function OptionRows.clampScroll(index, scroll, total, bottomRow) + if bottomRow and index >= bottomRow then + return math.max(0, total - OptionRows.VISIBLE) + elseif index <= scroll then + return index - 1 + elseif index > scroll + OptionRows.VISIBLE then + return index - OptionRows.VISIBLE + end + return scroll +end + +-- one bordered box per row, label line + value line, with the fixed +-- bottom line below (CANCEL in the options menu, the manager's footer) +function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + for slot = 1, OptionRows.VISIBLE do + local i = scroll + slot + local row = rows[i] + if not row then break end + Font.drawBox(0, (slot - 1) * 4, 20, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(row.label, 16, ((slot - 1) * 4 + 1) * 8) + Font.draw(row.value and row.value(game) or "", 24, ((slot - 1) * 4 + 2) * 8) + if i == index then + Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8) + end + end + if scroll + OptionRows.VISIBLE < #rows then + Font.drawCode(Theme.moreArrow, 144, 128) + end + if bottomLabel then + love.graphics.setColor(0, 0, 0, 1) + Font.draw(bottomLabel, 16, 136) + if bottomRow and index == bottomRow then + Font.drawCode(Theme.cursor, 8, 136) + end + end + love.graphics.setColor(1, 1, 1, 1) +end + +return OptionRows diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 8384fa95..f526963c 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -1,37 +1,32 @@ -- Options: text speed, battle animation on/off, battle style SHIFT/SET -- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset --- (gen1_faithful keeps the original quirks; modern_clean removes the --- 1/256 miss etc), plus the port's audio rows and display rows: music/SFX +-- (cycles the merged rulesets registry; gen1_faithful keeps the original +-- quirks), plus the port's audio rows and display rows: music/SFX -- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / --- GBC FX. --- Option boxes scroll through a four-box viewport; CANCEL stays fixed on --- the bottom line like pokered's. +-- GBC FX, and the MODS row that opens the mod manager. +-- Rows are descriptors fed through the ui.options.rows hook, so mods can +-- add their own; CANCEL is appended after the hook and stays fixed on the +-- bottom line like pokered's. -local Font = require("src.render.Font") local PaletteFX = require("src.render.PaletteFX") local Tilt = require("src.render.Tilt") local GBCFX = require("src.render.GBCFX") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local OptionRows = require("src.ui.OptionRows") local OptionsMenu = {} OptionsMenu.__index = OptionsMenu OptionsMenu.isOpaque = true -local CURSOR = 0xED -- "▶" (charmap.asm $ED) -local DOWN_ARROW = 0xEE -- "▼" (charmap.asm $EE): more rows below -- TextSpeedOptionData frame delays with the original labels local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } -local RULES = { "gen1_faithful", "modern_clean" } +-- no-loader fallback for the ruleset row, same pair BattleState keeps +local Rulesets = { + gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + modern_clean = require("src.battle.rulesets.modern_clean"), +} local FILTERS = { "OFF", "1X", "2X", "3X" } --- 3 original options + OG GLITCHES / MUSIC VOL / SFX VOL / MUSIC FILTER --- + COLORS / TILT / GBC FX + CANCEL -local OPTION_ROWS = 10 -local ROWS = 11 -local CANCEL_ROW = 11 -local VISIBLE = 4 -- option boxes on screen at once (4 tiles each) - -function OptionsMenu.new(game) - return setmetatable({ game = game, index = 1, scroll = 0 }, OptionsMenu) -end local function speedIndex(game) -- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions @@ -42,6 +37,37 @@ local function speedIndex(game) return 2 -- MEDIUM end +-- the ruleset row cycles the sorted non-hidden ids of the merged +-- registry (07-battle-extensibility.md 4.6), so mod-registered +-- rulesets are selectable; hidden marks a total conversion's exclusions +local function rulesetIds(game) + local rulesets = game.data and game.data.rulesets or Rulesets + local ids = {} + for id, record in pairs(rulesets) do + if not record.hidden then ids[#ids + 1] = id end + end + table.sort(ids) + return ids +end + +local function rulesetIndex(game, ids) + local constants = game.data and game.data.constants + local cur = game.save.options.ruleset + or (constants and constants.defaultRuleset) or "gen1_faithful" + for i, id in ipairs(ids) do + if id == cur then return i end + end + return 1 +end + +local function rulesetName(game) + local rulesets = game.data and game.data.rulesets or Rulesets + local ids = rulesetIds(game) + local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset + local record = id and rulesets[id] + return record and record.name or id or "----" +end + -- 0-7 volume level display (0 = OFF) local function volLabel(v) v = v or 7 @@ -68,68 +94,152 @@ local function wrapIndex(i, n) return i end -local function stepColors(opts, dir) - local i = colorIndex(opts) - i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 - opts.colors = PaletteFX.MODES[i] - PaletteFX.setMode(opts.colors) +local function sameRows(_, rows) return rows end + +-- the vanilla rows as descriptors; each step body is the old per-index +-- ladder's, so the save.options mutations are unchanged +local function buildRows(game) + return { + { id = "textSpeed", label = "TEXT SPEED", + value = function(g) return SPEEDS[speedIndex(g)][2] end, + step = function(g) + local i = speedIndex(g) % #SPEEDS + 1 + g.save.options.textSpeed = SPEEDS[i][1] + return true + end }, + { id = "animations", label = "BATTLE ANIMATION", + value = function(g) + return g.save.options.animations == false and "OFF" or "ON" + end, + step = function(g) + local o = g.save.options + o.animations = o.animations == false and true or false + return true + end }, + { id = "battleStyle", label = "BATTLE STYLE", + value = function(g) + return g.save.options.battleStyle == "set" and "SET" or "SHIFT" + end, + step = function(g) + local o = g.save.options + o.battleStyle = o.battleStyle == "set" and "shift" or "set" + return true + end }, + { id = "ruleset", label = "RULESET", + value = function(g) return rulesetName(g) end, + step = function(g, dir) + local ids = rulesetIds(g) + if #ids == 0 then return false end + local i = rulesetIndex(g, ids) + g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1] + return true + end }, + { id = "musicVol", label = "MUSIC VOL", + value = function(g) return volLabel(g.save.options.musicVol) end, + step = function(g, dir) + local o = g.save.options + o.musicVol = stepVolume(o.musicVol, dir) + require("src.core.Music").setVolumeLevel(o.musicVol) + return true + end }, + { id = "sfxVol", label = "SFX VOL", + value = function(g) return volLabel(g.save.options.sfxVol) end, + step = function(g, dir) + local o = g.save.options + o.sfxVol = stepVolume(o.sfxVol, dir) + require("src.core.Sound").setVolumeLevel(o.sfxVol) + return true + end }, + { id = "musicFilter", label = "MUSIC FILTER", + value = function(g) + return FILTERS[(g.save.options.musicFilter or 0) + 1] + end, + step = function(g, dir) + local o = g.save.options + o.musicFilter = ((o.musicFilter or 0) + dir) % #FILTERS + require("src.core.Music").setFilterLevel(o.musicFilter) + return true + end }, + { id = "colors", label = "COLORS", + value = function(g) + return PaletteFX.modeLabel(g.save.options.colors or "gbc") + end, + step = function(g, dir) + local o = g.save.options + local i = colorIndex(o) + i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 + o.colors = PaletteFX.MODES[i] + PaletteFX.setMode(o.colors) + return true + end }, + { id = "tilt", label = "TILT", + value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end, + step = function(g, dir) + local o = g.save.options + o.tilt = wrapIndex((o.tilt or 0) + dir, 4) + Tilt.setLevel(o.tilt) + return true + end }, + { id = "gbcfx", label = "GBC FX", + value = function(g) + return GBCFX.levelLabel(g.save.options.gbcfx or 0) + end, + step = function(g, dir) + local o = g.save.options + o.gbcfx = wrapIndex((o.gbcfx or 0) + dir, 5) + GBCFX.setLevel(o.gbcfx) + return true + end }, + -- the manager's discoverable home (18-mod-manager-ux); inert until + -- opened, so the row costs a vanilla install nothing + { id = "mods", label = "MODS", + value = function(g) + local status = g.modStatus or {} + return ("%d INSTALLED"):format(#(status.available or {})) + end, + activate = function(g) + require("src.ui.Screens").push(g, "ManagerState") + end }, + -- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs + -- live in options.bindings, so the row costs a vanilla install nothing + { id = "controls", label = "CONTROLS", + activate = function(g) + require("src.ui.Screens").push(g, "BindingsMenu") + end }, + } end -local function stepTilt(opts, dir) - opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4) - Tilt.setLevel(opts.tilt) -end - -local function stepGbcfx(opts, dir) - opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5) - GBCFX.setLevel(opts.gbcfx) +function OptionsMenu.new(game) + local rows = buildRows(game) + local hooked = Runtime.call("ui.options.rows", sameRows, game, rows) + if type(hooked) == "table" then + rows = hooked + else + Logger.error("ui.options.rows returned %s; keeping the vanilla rows", + type(hooked)) + end + return setmetatable({ game = game, rows = rows, index = 1, scroll = 0 }, + OptionsMenu) end function OptionsMenu:update(dt) local input = self.game.input - local opts = self.game.save.options + local rows = self.rows + -- CANCEL sits below the hook-built rows so a mod cannot orphan the exit + local cancelRow = #rows + 1 local changed = false if input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or ROWS + self.index = self.index > 1 and self.index - 1 or cancelRow elseif input:wasPressed("down") then - self.index = self.index < ROWS and self.index + 1 or 1 + self.index = self.index < cancelRow and self.index + 1 or 1 elseif input:wasPressed("left") or input:wasPressed("right") or input:wasPressed("a") then local dir = input:wasPressed("left") and -1 or 1 - if self.index == 1 then - local i = speedIndex(self.game) % #SPEEDS + 1 - opts.textSpeed = SPEEDS[i][1] - changed = true - elseif self.index == 2 then - opts.animations = opts.animations == false and true or false - changed = true - elseif self.index == 3 then - opts.battleStyle = opts.battleStyle == "set" and "shift" or "set" - changed = true - elseif self.index == 4 then - opts.ruleset = opts.ruleset == RULES[1] and RULES[2] or RULES[1] - changed = true - elseif self.index == 5 then - opts.musicVol = stepVolume(opts.musicVol, dir) - require("src.core.Music").setVolumeLevel(opts.musicVol) - changed = true - elseif self.index == 6 then - opts.sfxVol = stepVolume(opts.sfxVol, dir) - require("src.core.Sound").setVolumeLevel(opts.sfxVol) - changed = true - elseif self.index == 7 then - opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS - require("src.core.Music").setFilterLevel(opts.musicFilter) - changed = true - elseif self.index == 8 then - stepColors(opts, dir) - changed = true - elseif self.index == 9 then - stepTilt(opts, dir) - changed = true - elseif self.index == 10 then - stepGbcfx(opts, dir) - changed = true + local row = rows[self.index] + if row and row.activate then + if input:wasPressed("a") then row.activate(self.game) end + elseif row and row.step then + changed = row.step(self.game, dir) and true or false elseif input:wasPressed("a") then -- CANCEL self.game.stack:pop() end @@ -139,53 +249,13 @@ function OptionsMenu:update(dt) if changed and self.game.writeOptions then self.game:writeOptions() end - -- keep the cursor's box inside the viewport; CANCEL shows the tail - if self.index >= CANCEL_ROW then - self.scroll = OPTION_ROWS - VISIBLE - elseif self.index <= self.scroll then - self.scroll = self.index - 1 - elseif self.index > self.scroll + VISIBLE then - self.scroll = self.index - VISIBLE - end + self.scroll = OptionRows.clampScroll(self.index, self.scroll or 0, + #rows, cancelRow) end function OptionsMenu:draw() - local opts = self.game.save.options - -- one bordered box per option, label line + value line, with CANCEL - -- below (main_menu.asm DisplayOptionMenu layout, extended with the - -- port's rows; a ▼ marks option boxes scrolled off below) - local rows = { - { "TEXT SPEED", SPEEDS[speedIndex(self.game)][2] }, - { "BATTLE ANIMATION", opts.animations == false and "OFF" or "ON" }, - { "BATTLE STYLE", opts.battleStyle == "set" and "SET" or "SHIFT" }, - { "OG GLITCHES", opts.ruleset == "modern_clean" and "OFF" or "ON" }, - { "MUSIC VOL", volLabel(opts.musicVol) }, - { "SFX VOL", volLabel(opts.sfxVol) }, - { "MUSIC FILTER", FILTERS[(opts.musicFilter or 0) + 1] }, - { "COLORS", PaletteFX.modeLabel(opts.colors or "gbc") }, - { "TILT", Tilt.levelLabel(opts.tilt or 0) }, - { "GBC FX", GBCFX.levelLabel(opts.gbcfx or 0) }, - } - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", 0, 0, 160, 144) - local scroll = self.scroll or 0 - for slot = 1, VISIBLE do - local i = scroll + slot - local row = rows[i] - Font.drawBox(0, (slot - 1) * 4, 20, 4) - love.graphics.setColor(0, 0, 0, 1) - Font.draw(row[1], 16, ((slot - 1) * 4 + 1) * 8) - Font.draw(row[2], 24, ((slot - 1) * 4 + 2) * 8) - if i == self.index then - Font.drawCode(CURSOR, 8, ((slot - 1) * 4 + 1) * 8) - end - end - if scroll + VISIBLE < #rows then - Font.drawCode(DOWN_ARROW, 144, 128) - end - Font.draw("CANCEL", 16, 136) - if self.index == CANCEL_ROW then Font.drawCode(CURSOR, 8, 136) end - love.graphics.setColor(1, 1, 1, 1) + OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, + "CANCEL", #self.rows + 1) end return OptionsMenu diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 11f46b2d..4cfde96e 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -7,6 +7,10 @@ -- Pops itself on B. local Font = require("src.render.Font") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") +local Theme = require("src.ui.Theme") local PartyMenu = {} PartyMenu.__index = PartyMenu @@ -17,7 +21,7 @@ function PartyMenu:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end -local CURSOR = 0xED +local function sameItems(_, items) return items end -- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is -- excluded by map id in ItemUseEscapeRope) @@ -65,7 +69,9 @@ local function drawIcon(game, mon, x, y, selected, counter) local icons = game.data.icons if not icons then return end local def = game.data.pokemon[mon.species] - local name = def and def.dex and icons.byDex[def.dex] + -- byDex is the vanilla lookup, but the icons registry can bring the table + -- into existence on its own, so it may be the only key present + local name = def and def.dex and icons.byDex and icons.byDex[def.dex] local path = name and icons.icons[name] if not path then return end if iconImages[path] == nil then @@ -126,16 +132,18 @@ function PartyMenu:update(dt) self.submenu = nil elseif input:wasPressed("a") then local mon = party[self.index] - local action = self.subItems[self.subIndex].action - if action == "stats" then - local SummaryMenu = require("src.ui.SummaryMenu") - self.game.stack:push(SummaryMenu.new(self.game, mon)) + local entry = self.subItems[self.subIndex] + local action = entry.action + if not action and entry.onSelect then + -- hook-injected entries carry a callback instead of an action id + entry.onSelect(mon, self.game) + elseif action == "stats" then + Screens.push(self.game, "SummaryMenu", mon) elseif action == "switch" then self.swapFrom = self.index elseif action == "fly" then - local FlyMenu = require("src.ui.FlyMenu") self.game.stack:pop() -- close the party menu - self.game.stack:push(FlyMenu.new(self.game)) + Screens.push(self.game, "FlyMenu") return elseif action == "flash" then -- FLASH lights dark tunnels -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then @@ -320,45 +328,55 @@ function PartyMenu:update(dt) self.subIndex = 1 -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm -- builds the same dynamic list) - self.subItems = { { label = "STATS", action = "stats" }, - { label = "SWITCH", action = "switch" } } + local items = { { label = "STATS", action = "stats" }, + { label = "SWITCH", action = "switch" } } local ow = self.game.overworld if not self.battle and ow and mon.hp > 0 then for _, mv in ipairs(mon.moves) do if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD" and self.game.save.inventory.THUNDERBADGE then - table.insert(self.subItems, { label = "FLY", action = "fly" }) + table.insert(items, { label = "FLY", action = "fly" }) elseif mv.id == "FLASH" and ow.dark and self.game.save.inventory.BOULDERBADGE then - table.insert(self.subItems, { label = "FLASH", action = "flash" }) + table.insert(items, { label = "FLASH", action = "flash" }) elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then -- CUT/SURF/STRENGTH are party-menu field moves too -- (start_sub_menus.asm .outOfBattleMovePointers); listed here -- with the same list-time badge filter this file already uses -- for FLY/FLASH. The facing-tile/activation check happens on -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(self.subItems, { label = "CUT", action = "cut" }) + table.insert(items, { label = "CUT", action = "cut" }) elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then - table.insert(self.subItems, { label = "SURF", action = "surf" }) + table.insert(items, { label = "SURF", action = "surf" }) elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then - table.insert(self.subItems, { label = "STRENGTH", action = "strength" }) + table.insert(items, { label = "STRENGTH", action = "strength" }) elseif mv.id == "SOFTBOILED" then - table.insert(self.subItems, { label = "SOFTBOILED", action = "softboiled" }) + table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then -- TELEPORT works only OUTDOORS (start_sub_menus.asm -- .teleport -> CheckIfInOutsideMap); dark maps don't -- block it - table.insert(self.subItems, { label = "TELEPORT", action = "escape" }) + table.insert(items, { label = "TELEPORT", action = "escape" }) elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM" then -- DIG runs ItemUseEscapeRope (.dig sets wCurItem = -- ESCAPE_ROPE): usable in the dungeon tilesets of -- escape_rope_tilesets.asm minus Agatha's room, even in -- the dark (Rock Tunnel) - table.insert(self.subItems, { label = "DIG", action = "escape" }) + table.insert(items, { label = "DIG", action = "escape" }) end end end + local ctx = { battle = self.battle, overworld = ow } + local hooked = Runtime.call("ui.party.submenu", sameItems, + self.game, items, mon, ctx) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.party.submenu returned %s; keeping the vanilla list", + type(hooked)) + end + self.subItems = items end end end @@ -400,10 +418,10 @@ function PartyMenu:draw() love.graphics.setColor(0, 0, 0, 1) Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8) if i == self.index then - Font.drawCode(CURSOR, 0, y) + Font.drawCode(Theme.cursor, 0, y) end if i == self.swapFrom or i == self.softboiledFrom then - Font.drawCode(0xEC, 0, y) -- the unfilled swap arrow + Font.drawCode(Theme.cursorHollow, 0, y) -- the unfilled swap arrow end end if self.swapFrom then @@ -420,7 +438,7 @@ function PartyMenu:draw() for si, entry in ipairs(self.subItems) do Font.draw(entry.label, 88, y0 + (si - 1) * 16) end - Font.drawCode(CURSOR, 80, y0 + (self.subIndex - 1) * 16) + Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index b3bf8988..b5bceb2d 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -17,19 +17,23 @@ function PokedexMenu.new(game) end local items = {} local seen, owned = 0, 0 - for n = 1, 151 do + -- dex bound and number width come from constants; the fallbacks keep a + -- cache imported before those keys existed on the Kanto numbering + local constants = game.data.constants or {} + local numFmt = ("%%0%dd"):format(constants.dexDigits or 3) + for n = 1, constants.dexSize or 151 do local def = byDex[n] if def then local label if dex.owned[def.id] then - label = ("%03d %s"):format(n, def.name) + label = (numFmt .. " %s"):format(n, def.name) owned = owned + 1 seen = seen + 1 elseif dex.seen[def.id] then - label = ("%03d %s"):format(n, def.name) + label = (numFmt .. " %s"):format(n, def.name) seen = seen + 1 else - label = ("%03d -----"):format(n) + label = (numFmt .. " -----"):format(n) end table.insert(items, { label = label, @@ -49,17 +53,16 @@ function PokedexMenu.new(game) -- PokedexMenuItemsText); CRY keeps the side menu open like the -- original, QUIT returns to the list local Menu = require("src.ui.Menu") + local Screens = require("src.ui.Screens") game.stack:push(Menu.new(game, { { label = "DATA", onSelect = function() - local DexEntryMenu = require("src.ui.DexEntryMenu") - game.stack:push(DexEntryMenu.new(game, item.value)) + Screens.push(game, "DexEntryMenu", item.value) end }, { label = "CRY", keepOpen = true, onSelect = function() require("src.core.Sound").playCry(game.data, item.value) end }, { label = "AREA", onSelect = function() - local TownMap = require("src.ui.TownMap") - game.stack:push(TownMap.new(game, { nestSpecies = item.value })) + Screens.push(game, "TownMap", { nestSpecies = item.value }) end }, { label = "QUIT" }, }, { tx = 12, ty = 8, tw = 8, th = 10 })) diff --git a/src/ui/QuarantineReport.lua b/src/ui/QuarantineReport.lua new file mode 100644 index 00000000..5e6d45d7 --- /dev/null +++ b/src/ui/QuarantineReport.lua @@ -0,0 +1,128 @@ +-- Load-report screen (15-save-data D7.4): what the validation pass moved, +-- removed or remapped, shown once before the overworld. Game:restoreSave +-- pushes it (Screens id "QuarantineReport") only when the report is +-- non-empty, so a vanilla load never constructs it. Nothing here mutates +-- the save -- save.orphaned persists and the report stays re-derivable. + +local Font = require("src.render.Font") +local SaveData = require("src.core.SaveData") + +local QuarantineReport = {} +QuarantineReport.__index = QuarantineReport +QuarantineReport.isOpaque = true + +local VISIBLE = 13 -- report rows on screen at once +local WIDTH = 18 -- text columns inside the border + +local function clip(text) + text = tostring(text) + if #text > WIDTH then return text:sub(1, WIDTH) end + return text +end + +local function section(lines, header, rows) + if #rows == 0 then return end + if #lines > 0 then lines[#lines + 1] = "" end + lines[#lines + 1] = header + for _, row in ipairs(rows) do lines[#lines + 1] = clip(" " .. row) end +end + +-- report shape: { lostMons = {{species, from}}, lostItems = {{id, count, +-- from}}, remappedMaps = {{id, to, field}}, restoredMons, restoredItems, +-- recovered, modsDiff } +local function buildLines(report, meta) + local lines = {} + if report.recovered then + lines[#lines + 1] = "Save recovered from" + lines[#lines + 1] = clip(" the ." .. tostring(report.recovered) .. " backup copy") + end + local rows = {} + for _, mon in ipairs(report.lostMons or {}) do + rows[#rows + 1] = ("%s (%s)"):format(mon.species or "?", mon.from or "?") + end + section(lines, "Moved to LOST box:", rows) + rows = {} + for _, item in ipairs(report.lostItems or {}) do + rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1) + end + section(lines, "Items removed:", rows) + rows = {} + for _, map in ipairs(report.remappedMaps or {}) do + if map.to then + rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to) + else + rows[#rows + 1] = ("%s (%s)"):format(map.id or "?", map.field or "?") + end + end + section(lines, "Location reset:", rows) + rows = {} + for _, mon in ipairs(report.restoredMons or {}) do + rows[#rows + 1] = ("%s to box %d"):format(mon.species or "?", mon.box or 0) + end + for _, item in ipairs(report.restoredItems or {}) do + rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1) + end + section(lines, "Restored:", rows) + local notice = SaveData.modsDiffNotice(report.modsDiff, meta) + if notice then + if #lines > 0 then lines[#lines + 1] = "" end + -- wrap the one-line notice to the box width + for word in notice:gmatch("%S+") do + local last = lines[#lines] + if last and last ~= "" and #last + #word + 1 <= WIDTH then + lines[#lines] = last .. " " .. word + else + lines[#lines + 1] = word + end + end + end + return lines +end + +function QuarantineReport.new(game, report) + local self = setmetatable({ + game = game, + report = report or {}, + offset = 0, + }, QuarantineReport) + self.lines = buildLines(self.report, + game and game.save and game.save.meta) + return self +end + +function QuarantineReport:maxOffset() + return math.max(0, #self.lines - VISIBLE) +end + +function QuarantineReport:update() + local input = self.game and self.game.input + if not input then return end + if input:wasPressed("up") then + self.offset = math.max(0, self.offset - 1) + elseif input:wasPressed("down") then + self.offset = math.min(self:maxOffset(), self.offset + 1) + elseif input:wasPressed("a") or input:wasPressed("start") + or input:wasPressed("b") then + -- CONTINUE: the overworld is already beneath this screen + self.game.stack:pop() + end +end + +function QuarantineReport:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + Font.drawBox(0, 0, 20, 18) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("LOAD REPORT", 8, 8) + for row = 1, VISIBLE do + local line = self.lines[self.offset + row] + if line then Font.draw(line, 8, 12 + row * 8) end + end + if self.offset < self:maxOffset() then + Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124) + end + Font.draw("A:CONTINUE", 8, 130) + love.graphics.setColor(1, 1, 1, 1) +end + +return QuarantineReport diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua new file mode 100644 index 00000000..73045b12 --- /dev/null +++ b/src/ui/Screens.lua @@ -0,0 +1,71 @@ +-- Screen id -> factory resolution. The screens registry (Data.screens) +-- wins; engine screens are the require fallback, so a mod-free boot +-- resolves every id to the exact module it required before. One cache, +-- dropped with the rest of the asset caches on dev-mode hot reload. + +local Assets = require("src.render.Assets") +local Logger = require("src.core.Logger") + +local Screens = {} + +-- ids whose builtin module is not under src/ui/ +local BUILTIN = { + ManagerState = "src.mods.ManagerState", +} + +local cache = {} + +local function builtinFor(id) + return require(BUILTIN[id] or ("src.ui." .. id)) +end + +local function resolve(game, id) + local hit = cache[id] + if hit then return hit end + local screens = game and game.data and game.data.screens + local record = screens and screens[id] + local factory + if record then + -- registry record: { new = fn } or a bare function (05-registry-system) + factory = (type(record) == "function") and { new = record } or record + factory.__modOwned = true + else + factory = builtinFor(id) + end + cache[id] = factory + return factory +end + +function Screens.get(game, id) + return resolve(game, id) +end + +function Screens.push(game, id, ...) + local factory = resolve(game, id) + local inst + if factory.__modOwned then + -- a broken mod screen degrades to the builtin, never a dead end + local ok, result = pcall(factory.new, game, ...) + if ok and result then + inst = result + else + Logger.error("mod screen '%s' failed: %s -- using builtin", + id, tostring(result)) + cache[id] = nil + inst = builtinFor(id).new(game, ...) + end + else + inst = factory.new(game, ...) + end + inst.screenId = inst.screenId or id + game.stack:push(inst) + return inst +end + +function Screens.invalidate() + cache = {} +end + +Assets.register(Screens.invalidate) + +return Screens diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 4e614cb6..36210813 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -1,11 +1,18 @@ -- The START menu (engine/menus/start_menu.asm): entries appear as they -- become usable -- POKéDEX once Oak gives it, POKéMON once you have any, --- SAVE with a confirmation, plus ITEM / OPTION / LINK / QUIT. +-- SAVE with a confirmation, plus ITEM / OPTION / LINK / QUIT. The built +-- item list runs through the ui.start_menu.items hook before the menu +-- opens, so mods insert or remove rows without patching this file. +local Logger = require("src.core.Logger") local Menu = require("src.ui.Menu") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") local StartMenu = {} +local function sameItems(_, items) return items end + function StartMenu.new(game) local flags = game.save.flags or {} local items = {} @@ -13,8 +20,7 @@ function StartMenu.new(game) -- POKéDEX: only after Oak hands it over if flags.EVENT_GOT_POKEDEX then table.insert(items, { label = "POKéDEX", onSelect = function() - local PokedexMenu = require("src.ui.PokedexMenu") - game.stack:push(PokedexMenu.new(game)) + Screens.push(game, "PokedexMenu") end }) end @@ -22,20 +28,17 @@ function StartMenu.new(game) -- an empty party; selecting it then just no-ops) table.insert(items, { label = "POKéMON", onSelect = function() if #game.save.party == 0 then return end - local PartyMenu = require("src.ui.PartyMenu") - game.stack:push(PartyMenu.new(game)) + Screens.push(game, "PartyMenu") end }) table.insert(items, { label = "ITEM", onSelect = function() - local BagMenu = require("src.ui.BagMenu") - game.stack:push(BagMenu.new(game)) + Screens.push(game, "BagMenu") end }) -- the player's name opens the trainer card (StartMenu_TrainerInfo) table.insert(items, { label = game.save.player.name or "RED", onSelect = function() - local TrainerCard = require("src.ui.TrainerCard") - game.stack:push(TrainerCard.new(game)) + Screens.push(game, "TrainerCard") end }) -- SAVE shows the player/badges/dex/time panel then asks to confirm @@ -43,12 +46,7 @@ function StartMenu.new(game) table.insert(items, { label = "SAVE", onSelect = function() local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") - local badges = 0 - for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", - "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", - "VOLCANOBADGE", "EARTHBADGE" }) do - if game.save.inventory[b] then badges = badges + 1 end - end + local badges = require("src.inventory.Badges").count(game.data, game.save) local owned = 0 for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do owned = owned + 1 @@ -74,8 +72,7 @@ function StartMenu.new(game) end }) table.insert(items, { label = "OPTION", onSelect = function() - local OptionsMenu = require("src.ui.OptionsMenu") - game.stack:push(OptionsMenu.new(game)) + Screens.push(game, "OptionsMenu") end }) -- LINK needs a party @@ -86,6 +83,15 @@ function StartMenu.new(game) end }) end + -- the manager's pause-menu entry (18-mod-manager-ux): gated on at least + -- one discovered mod so a vanilla install's menu is unchanged + local status = game.modStatus + if status and #(status.available or {}) > 0 then + table.insert(items, { label = "MODS", onSelect = function() + Screens.push(game, "ManagerState") + end }) + end + -- the original's EXIT just closed the menu (CloseStartMenu); with a -- window close button covering that, QUIT instead power-cycles back -- to the title after a confirm (defaultNo guards accidental quits) @@ -98,6 +104,15 @@ function StartMenu.new(game) end, { defaultNo = true })) end)) end }) + + local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.start_menu.items returned %s; keeping the vanilla items", + type(hooked)) + end + -- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A -- (engine/menus/draw_start_menu.asm), so START closes it back to the -- overworld -- unlike most menus, whose masks omit PAD_START. diff --git a/src/ui/Theme.lua b/src/ui/Theme.lua new file mode 100644 index 00000000..c38181f2 --- /dev/null +++ b/src/ui/Theme.lua @@ -0,0 +1,32 @@ +-- The cursor/border/geometry constants every menu used to redeclare +-- locally, centralized so field.theme can restyle all of them at once. +-- Defaults are the current literals; the merge never runs without a mod, +-- so a vanilla boot draws byte-identically. + +local Font = require("src.render.Font") +local Merge = require("src.mods.Merge") +local Renderer = require("src.render.Renderer") + +local Theme = { + cursor = 0xED, -- the filled arrow (charmap.asm $ED) + cursorHollow = 0xEC, -- the unfilled arrow left on chosen rows + moreArrow = 0xEE, -- more-below marker (charmap.asm $EE) + tile = 8, + cols = Renderer.WIDTH / 8, + rows = Renderer.HEIGHT / 8, + textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 }, + choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 }, +} + +function Theme.load(data) + -- Font.load rebuilds its border table, so pick it up here rather than at + -- require time + Theme.border = Font.BORDER + local t = data and data.field and data.field.theme + if t then + Merge.deepMerge(Theme, t) + Font.BORDER = Theme.border + end +end + +return Theme diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 9923dbf4..f1d8f9aa 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -23,7 +23,8 @@ function TitleState:sgbPalettes(game) end -- the Red-version TitleMons list (data/pokemon/title_mons.asm): --- TitleScreenPickNewMon draws a random, never-repeating pick from it +-- TitleScreenPickNewMon draws a random, never-repeating pick from it; +-- field.title.cycleSpecies replaces it wholesale local CYCLE_SPECIES = { "CHARMANDER", "SQUIRTLE", "BULBASAUR", "WEEDLE", "NIDORAN_M", "SCYTHER", "PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO", @@ -37,15 +38,32 @@ local function tryImage(path) return ok and img or nil end +-- the importer seeds field.title with {path,width,height} descriptors +-- (the shape IntroMovie unwraps); mod patches may use plain path strings +local function imagePath(entry) + if type(entry) == "table" then return entry.path end + return entry +end + function TitleState.new(game, opts) opts = opts or {} local self = setmetatable({}, TitleState) self.game = game self.onNewGame = opts.onNewGame self.onContinue = opts.onContinue - self.logo = tryImage("assets/logo/pokemon_logo.png") - self.version = tryImage("assets/generated/title/red_version.png") + -- branding comes from field.title with the shipped art as fallback, so + -- a total conversion rebrands the title without replacing the screen + local title = (game.data.field and game.data.field.title) or {} + self.title = title + self.logo = tryImage(imagePath(title.logo) + or "assets/logo/pokemon_logo.png") + -- versionRibbon is the file-12 key; version is the importer's + self.version = tryImage(imagePath(title.versionRibbon or title.version) + or "assets/generated/title/red_version.png") self.player = tryImage("assets/generated/title/player.png") + self.cycleSpecies = (type(title.cycleSpecies) == "table" + and #title.cycleSpecies > 0) + and title.cycleSpecies or CYCLE_SPECIES self.sprites = {} -- species -> image or false (load failed) self.cycleIndex = 1 self.timer = 0 @@ -55,13 +73,14 @@ end function TitleState:enter() local data = self.game.data - if data.audio and data.audio.songs and data.audio.songs.Music_TitleScreen then - pcall(Music.play, data, "Music_TitleScreen") + local song = self.title.music or "Music_TitleScreen" + if data.audio and data.audio.songs and data.audio.songs[song] then + pcall(Music.play, data, song) end end function TitleState:currentSprite() - local species = CYCLE_SPECIES[self.cycleIndex] + local species = self.cycleSpecies[self.cycleIndex] local cached = self.sprites[species] if cached == nil then local def = self.game.data.pokemon[species] @@ -108,12 +127,7 @@ function ContinueInfo:draw() love.graphics.setColor(0, 0, 0, 1) Font.draw("PLAYER", 40, 72) Font.draw((save.player and save.player.name) or "RED", 96, 72) - local badges = 0 - for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", - "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", - "VOLCANOBADGE", "EARTHBADGE" }) do - if save.inventory and save.inventory[b] then badges = badges + 1 end - end + local badges = require("src.inventory.Badges").count(self.game.data, save) Font.draw("BADGES", 40, 88) Font.draw(("%2d"):format(badges), 128, 88) local owned = 0 @@ -149,7 +163,7 @@ function TitleState:openMenu() if self.onNewGame then self.onNewGame() end end }) table.insert(items, { label = "OPTION", onSelect = function() - game.stack:push(require("src.ui.OptionsMenu").new(game)) + require("src.ui.Screens").push(game, "OptionsMenu") end }) game.stack:push(Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 })) @@ -161,11 +175,13 @@ function TitleState:update(dt) if self.timer >= CYCLE_FRAMES then self.timer = 0 -- random pick that never repeats the current one - local pick = self.cycleIndex - while pick == self.cycleIndex do - pick = love.math.random(1, #CYCLE_SPECIES) + if #self.cycleSpecies > 1 then + local pick = self.cycleIndex + while pick == self.cycleIndex do + pick = love.math.random(1, #self.cycleSpecies) + end + self.cycleIndex = pick end - self.cycleIndex = pick self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in end if self.slideIn and self.slideIn > 0 then @@ -175,7 +191,7 @@ function TitleState:update(dt) if input:wasPressed("start") or input:wasPressed("a") then -- the title mon cries when you leave the title (.finishedWaiting) require("src.core.Sound").playCry(self.game.data, - CYCLE_SPECIES[self.cycleIndex]) + self.cycleSpecies[self.cycleIndex]) self:openMenu() end end @@ -215,8 +231,9 @@ function TitleState:draw() love.graphics.draw(self.player, 82, 80) end love.graphics.setColor(0, 0, 0, 1) - -- the copyright row (tile 2,17); - Font.draw("2026 bois club games", 1, 136) + -- the copyright row (tile 2,17); copyrightText because field.title's + -- copyright key already names the extracted image strip + Font.draw(self.title.copyrightText or "2026 bois club games", 1, 136) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 30e0b9a4..3d78be58 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -76,10 +76,11 @@ local function buildLocations(game) end end -- fallback: towns from the fly order (deduped, outdoor maps only) + local Map = require("src.world.Map") local seen = {} for _, mapId in ipairs(field.flyOrder or {}) do local def = game.data.maps and game.data.maps[mapId] - if not seen[mapId] and def and def.tileset == "OVERWORLD" then + if not seen[mapId] and def and Map.isOutdoor(def) then seen[mapId] = true local loc = { name = mapId:gsub("_", " ") } table.insert(locs, loc) @@ -144,8 +145,11 @@ function TownMap.new(game, opts) table.insert(self.nests, loc) end end + -- field.townMap.nest lifts the icon path out of the engine + local nest = ((game.data.field or {}).townMap or {}).nest local ok, img = pcall(love.graphics.newImage, - "assets/generated/townmap/nest.png") + (nest and nest.path) + or "assets/generated/townmap/nest.png") self.nestIcon = ok and img or nil end -- the player's current location (guard: overworld may not be running) diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index 0bbd4bda..7f7f78a8 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -4,6 +4,7 @@ -- are built from the real trainer_info.png frame tiles (the patterned -- band + line style). +local Badges = require("src.inventory.Badges") local Font = require("src.render.Font") local TrainerCard = {} @@ -15,12 +16,6 @@ function TrainerCard:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end --- gym order (data/scripts/victories.lua badge order) -local BADGES = { - "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", - "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", -} - local function tryImage(path) local ok, img = pcall(love.graphics.newImage, path) return ok and img or nil @@ -131,14 +126,18 @@ function TrainerCard:draw() -- numbered badge grid (rows 11-17): earned solid, unearned dimmed self:frameBox(0, 11, 20, 7) - for i = 1, 8 do + local badges = Badges.list(self.game.data) + for i = 1, #badges do local col, row = (i - 1) % 4, math.floor((i - 1) / 4) local tx, ty = 16 + col * 32, 94 + row * 24 - if self.nums then + -- the extracted sheets cover the eight Kanto slots; a longer badge + -- list draws its extra entries unnumbered rather than crashing + if self.nums and self.nums.quads[i - 1] then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty) end - if self.badges and save.inventory[BADGES[i]] then + if self.badges and self.badges.quads[i - 1] + and save.inventory[Badges.itemFor(badges[i])] then -- unearned badge slots stay blank (DrawBadges) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.badges.img, self.badges.quads[i - 1], diff --git a/src/world/Collision.lua b/src/world/Collision.lua index 74331328..a61458c2 100644 --- a/src/world/Collision.lua +++ b/src/world/Collision.lua @@ -1,6 +1,8 @@ -- Movement permission checks: tile passability (from generated collision -- data), map bounds, and entity occupancy. +local Runtime = require("src.mods.Runtime") + local Collision = {} local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } @@ -49,11 +51,7 @@ local function pairBlocked(map, mover, sx, sy, tx, ty) return false end --- Returns true when the mover may step from (cx,cy) toward dir. --- Out-of-bounds is blocked here; the OverworldController handles map --- connections and edge warps before asking. -function Collision.canMove(map, entities, mover, dir) - local tx, ty = Collision.target(mover.cellX, mover.cellY, dir) +local function verdict(map, entities, mover, dir, tx, ty) if not map:inBounds(tx, ty) then return false, "bounds" end @@ -72,4 +70,27 @@ function Collision.canMove(map, entities, mover, dir) return true end +-- the movement.collision chain sees the boolean; a wrapper that flips it +-- rewrites ctx.reason to say why (the engine's own reasons are bounds / +-- tile / entity), so the hook stays a single-value middleware +local function passthrough(allowed) return allowed end + +-- Returns true when the mover may step from (cx,cy) toward dir. +-- Out-of-bounds is blocked here; the OverworldController handles map +-- connections and edge warps before asking. Per-step hot path: with an +-- empty chain this costs one table lookup and no ctx allocation. +function Collision.canMove(map, entities, mover, dir) + local tx, ty = Collision.target(mover.cellX, mover.cellY, dir) + local allowed, why = verdict(map, entities, mover, dir, tx, ty) + if Runtime.wantsHook("movement.collision") then + local ctx = { map = map, mover = mover, dir = dir, + fromX = mover.cellX, fromY = mover.cellY, + toX = tx, toY = ty, reason = why } + allowed = Runtime.call("movement.collision", passthrough, allowed, ctx) + why = ctx.reason + end + if allowed then return true end + return false, why +end + return Collision diff --git a/src/world/Encounter.lua b/src/world/Encounter.lua index a9666601..5bd573e0 100644 --- a/src/world/Encounter.lua +++ b/src/world/Encounter.lua @@ -3,10 +3,21 @@ -- rand(0..255) < map encounter rate; the slot is picked with the original -- probability buckets. +local FieldDefaults = require("src.world.FieldDefaults") + local Encounter = {} --- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm) -local SLOT_BUCKETS = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 } +-- Cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm), +-- now constants.encounterBuckets. An encounter def may also carry its own +-- `buckets` of any length, as long as the last entry is 256 and there are +-- as many slots as buckets. +local buckets = FieldDefaults.CONSTANTS.encounterBuckets + +-- Collision.load's idiom: the overworld hands the dataset over on entry so +-- the pure roll stays free of a Data reference. +function Encounter.load(data) + buckets = FieldDefaults.constant(data, "encounterBuckets") +end function Encounter.roll(encounterDef, rng) rng = rng or love.math.random @@ -15,7 +26,7 @@ function Encounter.roll(encounterDef, rng) if not grass or grass.rate == 0 then return nil end if rng(0, 255) >= grass.rate then return nil end local pick = rng(0, 255) - for i, threshold in ipairs(SLOT_BUCKETS) do + for i, threshold in ipairs(grass.buckets or buckets) do if pick < threshold then local slot = grass.slots[i] if slot then diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua new file mode 100644 index 00000000..a763fe29 --- /dev/null +++ b/src/world/FieldDefaults.lua @@ -0,0 +1,219 @@ +-- Vanilla values for the data.field / Data.constants keys this milestone +-- lifts out of src/world/ literals. The importer does not stamp them yet, +-- so every read site folds its own table over these and behaves exactly as +-- the literal did on a stale cache; seed() fills the gaps in Data before +-- the mod merge so a mod's patch has a vanilla base to merge over instead +-- of replacing Kanto wholesale. +-- Pure Lua, no love.*, so the headless loader and offline tools can require it. + +local FieldDefaults = {} + +-- ------- data.field + +-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld): towns +-- own theirs, routes PAL_ROUTE, interiors inherit the last outdoor map, +-- with the Pokemon Tower / cave tileset and Elite Four cases on top. +local PALETTES = { + byMap = { + PALLET_TOWN = "PALLET", VIRIDIAN_CITY = "VIRIDIAN", + PEWTER_CITY = "PEWTER", CERULEAN_CITY = "CERULEAN", + LAVENDER_TOWN = "LAVENDER", VERMILION_CITY = "VERMILION", + CELADON_CITY = "CELADON", FUCHSIA_CITY = "FUCHSIA", + CINNABAR_ISLAND = "CINNABAR", INDIGO_PLATEAU = "INDIGO", + SAFFRON_CITY = "SAFFRON", + LORELEIS_ROOM = "PALLET", BRUNOS_ROOM = "CAVE", + }, + -- Pokemon Tower / Agatha, then the caves + byTileset = { CEMETERY = "GRAYMON", CAVERN = "CAVE" }, + byPrefix = { { prefix = "ROUTE_", palette = "ROUTE" } }, + default = "ROUTE", +} + +-- data/tilesets/bookshelf_tile_ids.asm: tileset id + collision tile -> +-- what facing up into it prints. `kind` names an engine flavor (the +-- vanilla five); mods author `text` (a data.text key) or `screen` +-- (a screens-registry id) instead. +local BOOKSHELVES = { + PLATEAU = { [0x30] = { kind = "statues" } }, + HOUSE = { [0x3D] = { screen = "TownMap" }, [0x1E] = { kind = "books" } }, + MANSION = { [0x32] = { kind = "books" } }, + REDS_HOUSE_1 = { [0x32] = { kind = "books" } }, + LAB = { [0x28] = { kind = "books" } }, + LOBBY = { [0x16] = { kind = "elevator" }, [0x50] = { kind = "stuff" }, + [0x52] = { kind = "stuff" } }, + GYM = { [0x1D] = { kind = "books" } }, + DOJO = { [0x1D] = { kind = "books" } }, + GATE = { [0x22] = { kind = "books" } }, + MART = { [0x54] = { kind = "stuff" }, [0x55] = { kind = "stuff" } }, + POKECENTER = { [0x54] = { kind = "stuff" }, [0x55] = { kind = "stuff" } }, + SHIP = { [0x36] = { kind = "books" } }, +} + +-- Rod tables (item_effects.asm ItemUseOldRod/GoodRod, data/wild/good_rod.asm). +-- The rejection-loop odds stay engine behavior: they are Gen-1 mechanics, +-- not content. perMap names the field key holding the per-map groups. +local FISHING = { + OLD_ROD = { always = { species = "MAGIKARP", level = 5 } }, + GOOD_ROD = { pool = { { species = "GOLDEEN", level = 10 }, + { species = "POLIWAG", level = 10 } } }, + SUPER_ROD = { perMap = "superRod" }, +} + +-- The step counter gates on EVENT_IN_SAFARI_ZONE, not the map, so every +-- interior counts and the gate itself never does (home/overworld.asm). +local SAFARI = { + stepMaps = { + "SAFARI_ZONE_CENTER", "SAFARI_ZONE_EAST", + "SAFARI_ZONE_NORTH", "SAFARI_ZONE_WEST", + "SAFARI_ZONE_CENTER_REST_HOUSE", "SAFARI_ZONE_EAST_REST_HOUSE", + "SAFARI_ZONE_NORTH_REST_HOUSE", "SAFARI_ZONE_WEST_REST_HOUSE", + "SAFARI_ZONE_SECRET_HOUSE", + }, + exitWarp = { map = "SAFARI_ZONE_GATE", x = 4, y = 3, facing = "down" }, +} + +-- home/overworld.asm LoadPlayerSpriteGraphics / LoadSurfingPlayerSprite- +-- Graphics / player_animations.asm LoadBirdSpriteGraphics +local PLAYER_SPRITES = { + walk = "SPRITE_RED", surf = "SPRITE_SEEL", + bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD", +} + +-- Route22Gate_Script rewrites wLastMap from the player's Y every frame, so +-- the north exit leaves onto Route 23 and the south onto Route 22. Rules +-- are ordered, first match wins, the last row is the default. +local LAST_MAP_REWRITES = { + ROUTE_22_GATE = { axis = "y", rules = { { below = 4, map = "ROUTE_23" }, + { map = "ROUTE_22" } } }, +} + +FieldDefaults.FIELD = { + palettes = PALETTES, + bookshelves = BOOKSHELVES, + fishing = FISHING, + safari = SAFARI, + playerSprites = PLAYER_SPRITES, + lastMapRewrites = LAST_MAP_REWRITES, + -- CheckIfInOutsideMap: what counts as "outside" for the wLastMap memory + outsideTilesets = { "OVERWORLD", "PLATEAU" }, + -- the Route 16/18 gate scripts `res BIT_ALWAYS_ON_BIKE` every frame + forcedMovement = { clearMaps = { "ROUTE_16_GATE_1F", "ROUTE_18_GATE_1F" } }, + -- the one-shot flag the gate's pass text is gated on; a gate a mod adds + -- gets "PASSED_" instead of this pre-v2 spelling + badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } }, + -- VermilionGymSetDoorTile opens the motorized door once both locks are hit + hiddenExtras = { + trashCans = { map = "VERMILION_GYM", + doorBlock = { bx = 2, by = 2, block = 5 } }, + }, + -- IsSurfingAllowed refuses SURF on the B4F stairs square until both + -- plug boulders are down (engine/overworld/field_move_messages.asm) + seafoam = { + SEAFOAM_ISLANDS_B4F = { + surfBlocked = { { x = 7, y = 11, untilEvents = { + "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", + "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" } } }, + }, + }, +} + +-- ------- Data.constants + +FieldDefaults.CONSTANTS = { + world = { + poisonStepInterval = 4, -- ApplyOutOfBattlePoisonDamage + poisonDamage = 1, + blackoutMoneyDivisor = 2, + daycareExpPerStep = 1, + neighborHops = 2, -- connection hops drawn around the current map + stepFrames = 16, -- 1px per frame, 16 frames per tile + bikeStepFrames = 8, -- the bicycle doubles walking speed + turnFrames = 2, -- the extra OverworldLoop pass after a turn + }, + -- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm) + encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 }, + -- the badge each HM's field move is gated on; distinct from + -- constants.hmMoves, which is the forget-gate move set + hmBadges = { + CUT = { badge = "CASCADEBADGE" }, SURF = { badge = "SOULBADGE" }, + STRENGTH = { badge = "RAINBOWBADGE" }, FLY = { badge = "THUNDERBADGE" }, + FLASH = { badge = "BOULDERBADGE" }, + }, +} + +-- ------- accessors + +-- data.field[key] with the vanilla table as the stale-cache fallback +function FieldDefaults.field(data, key) + local field = data and data.field + local value = field and field[key] + if value ~= nil then return value end + return FieldDefaults.FIELD[key] +end + +local function walk(node, n, ...) + for i = 1, n do + if type(node) ~= "table" then return nil end + node = node[(select(i, ...))] + end + return node +end + +-- one leaf inside a field sub-table, falling back per path so a cache that +-- stamps the record but not this key still resolves the vanilla value +function FieldDefaults.fieldValue(data, key, ...) + local n = select("#", ...) + local value = walk(data and data.field and data.field[key], n, ...) + if value ~= nil then return value end + return walk(FieldDefaults.FIELD[key], n, ...) +end + +function FieldDefaults.constant(data, key) + local constants = data and data.constants + local value = constants and constants[key] + if value ~= nil then return value end + return FieldDefaults.CONSTANTS[key] +end + +-- one world constant, falling back per key so a cache that stamps half of +-- constants.world still resolves the other half +function FieldDefaults.world(data, key) + local world = data and data.constants and data.constants.world + local value = world and world[key] + if value ~= nil then return value end + return FieldDefaults.CONSTANTS.world[key] +end + +-- ------- seeding + +-- fill-if-absent, never overwrite: an importer that learns to stamp one of +-- these silently takes over, and re-running is a no-op. Lists are leaves. +local function fill(dst, src) + for key, value in pairs(src) do + if dst[key] == nil then + if type(value) == "table" then + local copy = {} + fill(copy, value) + dst[key] = copy + else + dst[key] = value + end + elseif type(value) == "table" and type(dst[key]) == "table" + and #value == 0 then + fill(dst[key], value) + end + end +end + +-- Called before the mod merge (Data:seedDefaults): puts the vanilla values +-- in data.field / data.constants so mod.content.field:patch("palettes", ...) +-- deep-merges over Kanto instead of replacing it. +function FieldDefaults.seed(data) + data.field = data.field or {} + data.constants = data.constants or {} + fill(data.field, FieldDefaults.FIELD) + fill(data.constants, FieldDefaults.CONSTANTS) + return data +end + +return FieldDefaults diff --git a/src/world/Map.lua b/src/world/Map.lua index 13a98a4d..d13910ee 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -10,6 +10,25 @@ local Map = {} Map.__index = Map +-- Stale-cache fallbacks for the tileset properties the importer does not +-- stamp yet (item_effects.asm IsNextTileShoreOrWater, home/overworld.asm +-- CollisionCheckOnWater): $14 is water everywhere; the shore tiles $32 and +-- $48 (Safari Zone) everywhere EXCEPT SHIP_PORT, where $32 is the dock's +-- boarding platform -- a land tile. A tileset record that carries +-- waterTiles/shoreTiles wins outright, which is how a new tileset gets +-- surfable water without naming Kanto's. +local WATER_TILES = { 0x14 } +local SHORE_TILES = { 0x32, 0x48 } +local NO_SHORE_TILESETS = { SHIP_PORT = true } + +-- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap) +local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" } + +local function hashSet(list, into) + for _, t in ipairs(list) do into[t] = true end + return into +end + function Map.new(def, tilesetDef) local self = setmetatable({}, Map) self.def = def @@ -24,18 +43,65 @@ function Map.new(def, tilesetDef) for _, t in ipairs(tilesetDef.doorTiles or {}) do self.doorTiles[t] = true end self.warpTiles = {} for _, t in ipairs(tilesetDef.warpTiles or {}) do self.warpTiles[t] = true end + -- water and shore share one lookup: both are surfable, only the caller's + -- water_tilesets.asm membership check separates them + self.waterTiles = hashSet(tilesetDef.waterTiles or WATER_TILES, {}) + local shore = tilesetDef.shoreTiles + if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end + hashSet(shore or {}, self.waterTiles) self.warpAt = {} - for i, w in ipairs(def.warps) do + for i, w in ipairs(def.warps or {}) do self.warpAt[w.y * self.widthCells + w.x] = { index = i, def = w } end self.signAt = {} - for _, s in ipairs(def.signs) do + for _, s in ipairs(def.signs or {}) do self.signAt[s.y * self.widthCells + s.x] = s end return self end +-- ------- map record properties (authored maps set them; vanilla falls back) + +-- town/route surface: door SFX, the walk-out step, the Fly menu and the +-- town map all mean this one +function Map.isOutdoor(def) + if def.outdoor ~= nil then return def.outdoor end + return def.tileset == "OVERWORLD" +end + +-- CheckIfInOutsideMap, a strictly wider set: Route 23 / Indigo Plateau are +-- outside for the wLastMap memory without being outdoor for the door SFX +function Map.isOutside(def, tilesets) + if Map.isOutdoor(def) then return true end + for _, ts in ipairs(tilesets or OUTSIDE_TILESETS) do + if ts == def.tileset then return true end + end + return false +end + +-- region groups maps a rule applies to without naming them; the id prefix +-- is the fallback for caches that predate the property +function Map.inRegion(def, region, prefix) + if def.region ~= nil then return def.region == region end + return prefix ~= nil and def.id:find(prefix, 1, true) == 1 +end + +-- unidentifiable wild battles on this map unless the player holds an item +function Map.ghostBattles(def) + if def.ghostBattles ~= nil then return def.ghostBattles end + if def.id:find("POKEMON_TOWER", 1, true) == 1 then + return { unlessItem = "SILPH_SCOPE" } + end + return nil +end + +-- strength-pushable map objects (engine/overworld/push_boulder.asm) +function Map.isPushable(objDef) + if objDef.pushable ~= nil then return objDef.pushable end + return objDef.sprite == "SPRITE_BOULDER" +end + function Map:blockAt(bx, by) if bx < 0 or by < 0 or bx >= self.def.width or by >= self.def.height then return self.def.borderBlock @@ -69,16 +135,11 @@ function Map:isGrassCell(cx, cy) return grass ~= nil and self:cellTile(cx, cy) == grass end --- Water and eastern-shore tiles (item_effects.asm IsNextTileShoreOrWater, --- home/overworld.asm CollisionCheckOnWater): $14 everywhere; the shore --- tiles $32 and $48 (Safari Zone) everywhere EXCEPT the SHIP_PORT --- tileset, where $32 is the dock's boarding platform (a land tile). --- Tileset membership in water_tilesets.asm is checked by the caller. +-- Water and eastern-shore tiles, from the tileset's waterTiles/shoreTiles +-- (hash sets built in Map.new). Tileset membership in water_tilesets.asm +-- is checked by the caller. function Map:isWaterCell(cx, cy) - local t = self:cellTile(cx, cy) - if t == 0x14 then return true end - if self.def.tileset == "SHIP_PORT" then return false end - return t == 0x32 or t == 0x48 + return self.waterTiles[self:cellTile(cx, cy)] or false end -- Replace a block (Cut trees); the caller rebuilds the renderer. diff --git a/src/world/MapLoader.lua b/src/world/MapLoader.lua index dbeb331d..49760805 100644 --- a/src/world/MapLoader.lua +++ b/src/world/MapLoader.lua @@ -1,6 +1,9 @@ -- Builds runtime Map objects (and their tile SpriteBatches) from generated --- data, cached by map id. +-- data, cached by map id. The cache is keyed so a mod that patches one +-- map record after boot (or the dev-mode hot reload) can drop just that +-- entry instead of every map's SpriteBatches. +local Assets = require("src.render.Assets") local Map = require("src.world.Map") local TileRenderer = require("src.render.TileRenderer") @@ -11,9 +14,11 @@ local cache = {} function MapLoader.load(data, mapId) if cache[mapId] then return cache[mapId] end local def = data.maps[mapId] - assert(def, "unknown map: " .. tostring(mapId)) + assert(def, "unknown map: " .. tostring(mapId) .. + " (not in the maps registry)") local tilesetDef = data.tilesets[def.tileset] - assert(tilesetDef, "unknown tileset: " .. tostring(def.tileset)) + assert(tilesetDef, ("map %s wants unknown tileset: %s (not in the " .. + "tilesets registry)"):format(tostring(mapId), tostring(def.tileset))) -- warp tiles are stored per tileset macro name; the generated tilesets -- module carries them in the tileset entry itself @@ -23,8 +28,31 @@ function MapLoader.load(data, mapId) return map end -function MapLoader.clearCache() +-- the live instance for a map id, or nil when it has not been loaded; +-- callers that must not build a map (invalidation, tests) use this +function MapLoader.cached(mapId) + return cache[mapId] +end + +-- drop one map so the next load re-reads its record and rebuilds its +-- renderer. Callers holding the old instance keep it -- OverworldState +-- re-points self.map itself (WorldAPI:invalidateMap). +function MapLoader.invalidate(mapId) + local had = cache[mapId] ~= nil + cache[mapId] = nil + return had +end + +function MapLoader.invalidateAll() cache = {} end +-- kept as the pre-v2 name +MapLoader.clearCache = MapLoader.invalidateAll + +-- the cached Map objects own the per-map TileRenderer instances, so a flush +-- that skipped this one would leave live SpriteBatches built from the old +-- search path (14 cache-invalidation contract, rows 1 and 3) +Assets.register(MapLoader.invalidateAll) + return MapLoader diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1bba3b03..594e2f25 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -6,18 +6,23 @@ local Camera = require("src.render.Camera") local Collision = require("src.world.Collision") local Encounter = require("src.world.Encounter") +local FieldDefaults = require("src.world.FieldDefaults") local Logger = require("src.core.Logger") +local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") local NPC = require("src.world.NPC") local PaletteFX = require("src.render.PaletteFX") local Player = require("src.world.Player") +local Runtime = require("src.mods.Runtime") +local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") local Warp = require("src.world.Warp") -local OverworldState = { isOpaque = true } +-- isOverworld marks the live world state for WorldAPI's stack scan +local OverworldState = { isOpaque = true, isOverworld = true } local Game -- set on enter (avoids circular require at load time) @@ -65,6 +70,10 @@ local function pooledNPC(pool, data, mapId, obj) if not npc then npc = NPC.new(data, mapId, obj) pool[key] = npc + if Runtime.wants("world.npc_spawned") then + Runtime.emit("world.npc_spawned", + { mapId = mapId, npcId = key, runtime = obj.runtime == true }) + end end return npc end @@ -72,7 +81,7 @@ OverworldState.pooledNPC = pooledNPC -- exposed for tests -- connection hops rendered around the current map: two, so -- corner-adjacent maps (connections of connections) don't pop in and --- out of the survey zoom at the seams +-- out of the survey zoom at the seams (constants.world.neighborHops) local NEIGHBOR_HOPS = 2 -- Neighbor placement (pure; exposed for tests): walk the connection @@ -121,10 +130,16 @@ function OverworldState:enter(mapId, x, y, facing) Game = require("src.core.Game") Game.overworld = self Collision.load(Game.data) -- tile-pair (elevation) collisions + Encounter.load(Game.data) -- constants.encounterBuckets mapScripts = require("data.scripts.init") self.camera = Camera.new() self.runner = ScriptRunner.new(Game, self) self.scriptMoves = {} + self.pendingScripts = {} + self.parallelRunners = {} + self.parallelQueue = {} + self.npcMoveLocks = {} + self.marchers = {} -- one-shot trainer-engagement state: must not survive a save/load or -- a fresh entry, or a stale flag can freeze player input forever self.engaging = false @@ -133,10 +148,39 @@ function OverworldState:enter(mapId, x, y, facing) -- exit mat is a LAST_MAP warp self.lastOutdoor = Game.save.lastOutdoor self.justWarped = false - self:setMap(mapId, x, y, facing) + self:setMap(mapId, x, y, facing, { via = "boot" }) end function OverworldState:setMap(mapId, x, y, facing, opts) + local fromMapId = self.map and self.map.id + if fromMapId then + Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId }) + end + -- ambient choreography is per-map: parallel runners die here, and the + -- departing map's queued scripts go with them unless the enqueuer + -- asked to persist across the warp + if self.parallelRunners then + for i = #self.parallelRunners, 1, -1 do + self:killParallel(self.parallelRunners[i]) + end + self.parallelQueue = {} + end + self.marchers = {} + local queue = self.pendingScripts + if queue then + for i = #queue, 1, -1 do + local entry = queue[i] + if entry.mapId ~= mapId + and not (entry.extra and entry.extra.persistAcrossWarp) then + table.remove(queue, i) + end + end + end + -- a scripted tile-anim override lasts until map change + if self.tileAnimOverride then + self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation + self.tileAnimOverride = nil + end self.map = MapLoader.load(Game.data, mapId) -- STRENGTH deactivates on every real map load (home/overworld.asm -- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE @@ -166,11 +210,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- BIT_ALWAYS_ON_BIKE every frame (scripts/Route16Gate1F.asm / -- Route18Gate1F.asm `res BIT_ALWAYS_ON_BIKE`); entering the gate is -- the walking exit from the forced-bike stretch - if mapId == "ROUTE_16_GATE_1F" or mapId == "ROUTE_18_GATE_1F" then - Game.save.forcedBike = nil + for _, m in ipairs(FieldDefaults.fieldValue(Game.data, "forcedMovement", + "clearMaps") or {}) do + if m == mapId then Game.save.forcedBike = nil break end end -- leaving the Safari Zone maps ends any running Safari game - if Game.save.safari and mapId:find("SAFARI_ZONE", 1, true) ~= 1 then + if Game.save.safari and not Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then Game.save.safari = nil end -- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark @@ -204,7 +249,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.npcPool = {} end self.npcs = {} - for _, obj in ipairs(self.map.def.objects) do + for _, obj in ipairs(self.map.def.objects or {}) do if objectVisible(Game.save, mapId, obj) then local npc = pooledNPC(self.npcPool, Game.data, mapId, obj) npc.frozen = false @@ -224,8 +269,11 @@ function OverworldState:setMap(mapId, x, y, facing, opts) for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end -- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK - -- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7) - if not (opts and opts.keepMusic) then + -- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7); + -- keepMusicOnce is the play_music opts.keep one-shot of the same bit + local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce + self.keepMusicOnce = nil + if not keepMusic then require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike, self.player.surfing) end @@ -236,6 +284,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.camera:follow(self.player.px, self.player.py, Game.renderer:worldViewSize()) + -- fires before the onEnter chain so a listener sees the map in the same + -- state the map script does + Runtime.emit("map.entered", { + mapId = mapId, map = self.map, fromMapId = fromMapId, + via = (opts and opts.via) + or (opts and opts.seamless and "connection") + or (fromMapId and "warp" or "boot"), + }) + -- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers) local hooks = mapScripts.get(mapId) if hooks and hooks.onEnter then @@ -246,8 +303,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- out (the GB only ever streamed a 32px strip of the single -- directly connected map -- home/overworld.asm .loadNewMap) self.neighbors = {} + local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, - mapId, NEIGHBOR_HOPS)) do + mapId, hops)) do table.insert(self.neighbors, { map = MapLoader.load(Game.data, n.id), ox = n.ox, oy = n.oy }) @@ -260,7 +318,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.ghosts = {} for _, nb in ipairs(self.neighbors) do local peers = {} - for _, obj in ipairs(nb.map.def.objects) do + for _, obj in ipairs(nb.map.def.objects or {}) do if objectVisible(Game.save, nb.map.id, obj) then local npc = pooledNPC(self.npcPool, Game.data, nb.map.id, obj) table.insert(peers, npc) @@ -273,38 +331,45 @@ function OverworldState:setMap(mapId, x, y, facing, opts) Logger.info("map: %s at (%d,%d)", mapId, x, y) -- Route22Gate_Script rewrites wLastMap from the player's Y on entry -- too (not only on step), so a save/load mid-gate keeps exits correct - self:syncRoute22GateLastMap() + self:syncLastMapRewrite() end -- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld): -- towns use their own palette, routes PAL_ROUTE, interiors the town or -- route they are in (wLastMap = our lastOutdoor), with tileset and --- Elite Four special cases. -local TOWN_PALS = { - PALLET_TOWN = "PALLET", VIRIDIAN_CITY = "VIRIDIAN", - PEWTER_CITY = "PEWTER", CERULEAN_CITY = "CERULEAN", - LAVENDER_TOWN = "LAVENDER", VERMILION_CITY = "VERMILION", - CELADON_CITY = "CELADON", FUCHSIA_CITY = "FUCHSIA", - CINNABAR_ISLAND = "CINNABAR", INDIGO_PLATEAU = "INDIGO", - SAFFRON_CITY = "SAFFRON", -} +-- Elite Four special cases -- all of it field.palettes now. + +-- one rung of the cascade: byMap, then byTileset, then byPrefix. Returns +-- nil when the map matches nothing, which is what sends the lookup on to +-- the last-outdoor memory. +local function paletteLookup(palettes, mapId, tileset) + local byMap = palettes.byMap + if byMap and byMap[mapId] then return byMap[mapId] end + local byTileset = palettes.byTileset + if byTileset and tileset and byTileset[tileset] then return byTileset[tileset] end + for _, row in ipairs(palettes.byPrefix or {}) do + if row.prefix and mapId:find(row.prefix, 1, true) == 1 then return row.palette end + end + return nil +end + +-- name -> name so the map.palette chain has a vanilla link to wrap +local function samePalette(name) return name end function OverworldState:paletteNameFor(map) - local ts = map.def.tileset - local id = map.id - if ts == "CEMETERY" then - return "GRAYMON" -- Pokemon Tower / Agatha - elseif ts == "CAVERN" then - return "CAVE" - elseif id == "LORELEIS_ROOM" then - return "PALLET" - elseif id == "BRUNOS_ROOM" then - return "CAVE" - elseif TOWN_PALS[id] or id:match("^ROUTE_") then - return TOWN_PALS[id] or "ROUTE" + local palettes = FieldDefaults.field(Game.data, "palettes") + local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset) + if not name then + -- interiors inherit the outdoor map they sit in; before the player has + -- been outdoors at all that is wherever the game starts + local last = self.lastOutdoor and self.lastOutdoor.id + or FieldDefaults.fieldValue(Game.data, "boot", "startMap") + local lastDef = last and Game.data.maps[last] + name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset)) + or palettes.default end - local last = self.lastOutdoor and self.lastOutdoor.id or "PALLET_TOWN" - return TOWN_PALS[last] or "ROUTE" + if not Runtime.wantsHook("map.palette") then return name end + return Runtime.call("map.palette", samePalette, name, map) end -- UI-pass palette (text boxes and menus tint with the current map) @@ -350,7 +415,7 @@ end -- the same table. function OverworldState:bikeAllowed(mapId) local br = Game.data.field.bikeRiding - if not br then return self.map.def.tileset == "OVERWORLD" end + if not br then return Map.isOutdoor(self.map.def) end for _, m in ipairs(br.maps) do if m == mapId then return true end end @@ -412,9 +477,101 @@ end -- map load (setMap -> onEnter) can happen mid-warp, while the triggering -- warp command's runner is still suspended-alive; starting a runner there -- would trip ScriptRunner:run's assert(not isRunning()). So onEnter stashes --- the script here and update() drains it once the world is idle. +-- the script here and update() drains the FIFO head once the world is +-- idle, one script per idle frame. function OverworldState:queueScript(script, extra) - self.pendingScript = { script = script, extra = extra } + local queue = self.pendingScripts + if not queue then + queue = {} + self.pendingScripts = queue + end + queue[#queue + 1] = { script = script, extra = extra, + mapId = self.map and self.map.id } + -- a runaway-loop tripwire, not a hard cap + if #queue > 16 then + Logger.warn("queueScript: %d scripts pending on %s", + #queue, tostring(self.map and self.map.id)) + end +end + +function OverworldState:drainPendingScripts() + local queue = self.pendingScripts + if queue and queue[1] and not self.transitioning + and not self.runner:isRunning() and #self.scriptMoves == 0 then + local pending = table.remove(queue, 1) + self.runner:run(pending.script, pending.extra) + end +end + +-- Start a background script in one of the bounded parallel slots (09 +-- §4.6); overflow waits FIFO-style behind the slots. rowsOrRef is a row +-- array or "MAP_ID/name" naming a map_scripts `scripts` entry. +local PARALLEL_SLOTS = 4 + +function OverworldState:startParallel(rowsOrRef, extra) + local rows = rowsOrRef + if type(rowsOrRef) == "string" then + local MapScripts = require("src.script.MapScripts") + local mapId, name = rowsOrRef:match("^([^/]+)/(.+)$") + rows = mapId and MapScripts.namedScript(mapId, name) + if not rows then + Logger.warn("run_parallel: no script '%s'", tostring(rowsOrRef)) + return + end + -- a named entry belongs to its contribution: a caller with no + -- attribution of its own runs it as the owner + if not (extra and extra.source) then + local source = MapScripts.namedSource(mapId, name) + if source then + extra = extra or {} + extra.source = source + end + end + end + local queue = self.parallelQueue + if not queue then + queue = {} + self.parallelQueue = queue + end + queue[#queue + 1] = { rows = rows, extra = extra } + if #queue > 16 then + Logger.warn("run_parallel: %d scripts waiting for a slot", #queue) + end +end + +function OverworldState:killParallel(runner) + runner.co = nil + for i, live in ipairs(self.parallelRunners or {}) do + if live == runner then + table.remove(self.parallelRunners, i) + break + end + end + for entity, holder in pairs(self.npcMoveLocks or {}) do + if holder == runner then self.npcMoveLocks[entity] = nil end + end +end + +-- Parallel runners tick after the main runner and never touch the input +-- lockout: isRunning() checks consult only self.runner, exactly as +-- before. Dead runners free their slot and their NPC move locks. +function OverworldState:updateParallel() + local pool = self.parallelRunners + if not pool then return end + for i = #pool, 1, -1 do + if not pool[i]:isRunning() then + self:killParallel(pool[i]) + end + end + local queue = self.parallelQueue + while queue and queue[1] and #pool < PARALLEL_SLOTS do + local next_ = table.remove(queue, 1) + local runner = ScriptRunner.new(Game, self) + runner.parallel = true + pool[#pool + 1] = runner + runner:run(next_.rows, next_.extra) + end + for _, runner in ipairs(pool) do runner:update() end end function OverworldState:update(dt) @@ -422,13 +579,9 @@ function OverworldState:update(dt) -- once the triggering warp's transition has finished, its runner has gone -- dead, and no scripted walk is mid-step. This is how the HALL_OF_FAME -- room cutscene starts a frame after the Champions Room warp completes. - if self.pendingScript and not self.transitioning - and not self.runner:isRunning() and #self.scriptMoves == 0 then - local pending = self.pendingScript - self.pendingScript = nil - self.runner:run(pending.script, pending.extra) - end + self:drainPendingScripts() self.runner:update() + self:updateParallel() -- keep the player sprite in sync with the bike state (the drawer -- picks the red_bike sheet while riding) self.player.onBike = Game.save.onBike @@ -470,7 +623,7 @@ function OverworldState:update(dt) -- the bird carries the player in on landing, with its own -- SFX_FLY (EnterMapAnim .flyAnimation) self.arriveWarp = "fly" - self:startWarpTo(d.map, d.x, d.y, "down") + self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" }) end return end @@ -535,6 +688,26 @@ function OverworldState:update(dt) self.camera:follow(self.player.px, self.player.py, Game.renderer:worldViewSize()) + + -- pan_camera offset rides on top of the follow; the ramp resumes its + -- runner when it lands + local pan = self.cameraPan + if pan then + if pan.frames then + pan.t = pan.t + 1 + local k = math.min(1, pan.t / pan.frames) + pan.ox = pan.fromX + (pan.toX - pan.fromX) * k + pan.oy = pan.fromY + (pan.toY - pan.fromY) * k + if pan.t >= pan.frames then + pan.frames = nil + local done = pan.onDone + pan.onDone = nil + if done then done() end + end + end + self.camera.x = self.camera.x + pan.ox + self.camera.y = self.camera.y + pan.oy + end end -- any direction currently held (hJoyHeld & PAD_CTRL_PAD) @@ -553,8 +726,7 @@ function OverworldState:handleInput() end if input:wasPressed("start") then require("src.core.Sound").play(Game.data, "Start_Menu") - local StartMenu = require("src.ui.StartMenu") - Game.stack:push(StartMenu.new(Game)) + Screens.push(Game, "StartMenu") return end @@ -610,7 +782,7 @@ function OverworldState:checkBoulderPush(dir) local p = self.player local fx, fy = Collision.target(p.cellX, p.cellY, dir) local npc = self:npcAtCell(fx, fy) - if not npc or npc.def.sprite ~= "SPRITE_BOULDER" or npc.moving then + if not npc or not Map.isPushable(npc.def) or npc.moving then self.boulderTried = nil -- pokered resets when no boulder is in front return false end @@ -646,6 +818,8 @@ function OverworldState:checkBoulderPush(dir) require("src.core.Sound").play(Game.data, "Cut") end) if self:boulderIntoHole(npc) then return end + Runtime.emit("world.boulder_moved", { mapId = self.map.id, npcId = npc.id, + x = npc.cellX, y = npc.cellY }) local hooks = mapScripts.get(self.map.id) if hooks and hooks.onBoulderMoved then hooks.onBoulderMoved(Game, self, npc) @@ -664,14 +838,17 @@ end -- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile -- in front + matching input direction -> jump two cells. function OverworldState:checkLedgeHop(dir) - if self.map.def.tileset ~= "OVERWORLD" then return false end local p = self.player + local tileset = self.map.def.tileset local standing = self.map:cellTile(p.cellX, p.cellY) local fx, fy = Collision.target(p.cellX, p.cellY, dir) if not self.map:inBounds(fx, fy) then return false end local front = self.map:cellTile(fx, fy) + -- a row without a tileset applies everywhere; the vanilla rows are all + -- OVERWORLD, which is what the deleted hard gate used to say for _, ledge in ipairs(Game.data.field.ledges) do - if ledge.facing == dir and ledge.input == dir + if (ledge.tileset or "OVERWORLD") == tileset + and ledge.facing == dir and ledge.input == dir and ledge.standingTile == standing and ledge.ledgeTile == front then local lx, ly = Collision.target(fx, fy, dir) if self.map:inBounds(lx, ly) @@ -746,7 +923,9 @@ function OverworldState:crossConnection(dir, conn) p.targetX, p.targetY = x, y p.moving = true p.progress = 0 - p.stepFramesCur = (Game.save.onBike) and 8 or 16 + p.stepFramesCur = Game.save.onBike + and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8) + or (FieldDefaults.world(Game.data, "stepFrames") or 16) end -- ------------------------------------------------------------------------- @@ -754,13 +933,10 @@ end -- ------------------------------------------------------------------------- -- HM field moves are gated by badges like the original -local HM_BADGE = { - CUT = "CASCADEBADGE", SURF = "SOULBADGE", STRENGTH = "RAINBOWBADGE", - FLY = "THUNDERBADGE", FLASH = "BOULDERBADGE", -} - +-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate) function OverworldState:partyKnows(moveId) - local badge = HM_BADGE[moveId] + local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId] + local badge = gate and gate.badge if badge and not Game.save.inventory[badge] then return nil end @@ -791,26 +967,40 @@ local function rollFishingGroup(group) end end -local GOOD_ROD_MONS = { -- data/wild/good_rod.asm - { species = "GOLDEEN", level = 10 }, - { species = "POLIWAG", level = 10 }, -} +-- field.fishing: `always` hooks that catch every time (the Old Rod), +-- `pool` a fixed candidate list, `perMap` the field key holding per-map +-- groups. The rejection-loop odds above stay engine behavior. +local function fishingPool(data, rod, mapId) + local def = (FieldDefaults.field(data, "fishing") or {})[rod] + if not def then return nil end + if def.pool then return def.pool end + if def.perMap then + local groups = data.field[def.perMap] + return groups and groups[mapId] + end + return nil, def.always +end + +local function catchFrom(pool, always) + if always then return { species = always.species, level = always.level } end + if pool and #pool > 0 then return rollFishingGroup(pool) end + return nil +end -- Fishing (engine/items/item_effects.asm FishingInit + engine/overworld): -- Old Rod always hooks a L5 Magikarp; Good Rod bites ~1/3 for -- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group -- (no group means "Not even a nibble!"). function OverworldState:goFishing(rod) + local pool, always = fishingPool(Game.data, rod, self.map.id) local enc - if rod == "OLD_ROD" then - enc = { species = "MAGIKARP", level = 5 } - elseif rod == "GOOD_ROD" then - enc = rollFishingGroup(GOOD_ROD_MONS) + if Runtime.wantsHook("encounter.fishing") then + -- the chain may inspect or replace the candidate list before the roll + enc = Runtime.call("encounter.fishing", function(_, _, candidates) + return catchFrom(candidates, always) + end, rod, self.map.id, pool) else - local group = Game.data.field.superRod[self.map.id] - if group and #group > 0 then - enc = rollFishingGroup(group) - end + enc = catchFrom(pool, always) end -- the bobber waits a beat before the verdict (the original's -- FishingInit dot animation); the rod pose draws in the meantime @@ -824,7 +1014,7 @@ function OverworldState:goFishing(rod) Game.stack:push(TextBox.new(Game, "Oh!\nIt's a bite!", function() local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true }) - if Game.save.safari and self.map.id:find("SAFARI_ZONE", 1, true) == 1 then + if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then battle:makeSafari(Game.save.safari) end battle.onFinish = function(result) self:afterBattle(result) end @@ -858,6 +1048,12 @@ function OverworldState:npcAtCell(cx, cy) return nil end +-- what the A press resolved to, for world.interacted's listeners +local function interacted(self, fx, fy, kind, target) + Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy, + kind = kind, target = target }) +end + function OverworldState:interact() local p = self.player local fx, fy = p:facingCell() @@ -873,21 +1069,29 @@ function OverworldState:interact() if not npc.moving then self:talkTo(npc) end + interacted(self, fx, fy, "npc", npc) return end local sign = self.map:signAtCell(fx, fy) if sign then self:showMapText(sign.text, nil) + interacted(self, fx, fy, "sign", sign) return end -- Silph Co card key doors (engine/events/card_key.asm) - if self:tryCardKeyDoor(fx, fy) then return end + if self:tryCardKeyDoor(fx, fy) then + interacted(self, fx, fy, "door") + return + end -- hidden items / coins / slot machines / PC tiles / bench guys / -- gym statues / trash cans (data/events/hidden_events.asm) - if self:tryHiddenObject(fx, fy) then return end + if self:tryHiddenObject(fx, fy) then + interacted(self, fx, fy, "hidden") + return + end -- No overworld A-press hook for field moves: pokered has no such hook -- anywhere -- CUT and SURF (like FLY/FLASH/DIG/TELEPORT/STRENGTH) are @@ -901,39 +1105,42 @@ function OverworldState:interact() -- museum fossil exhibits) local hooks = mapScripts.get(self.map.id) if hooks and hooks.onInteract and hooks.onInteract(Game, self, fx, fy) then + interacted(self, fx, fy, "script") return end -- tileset-generic reads (PrintBookshelfText): facing up into a -- bookshelf/statue/shelf tile prints its stock line - if self:tryBookshelf(fx, fy) then return end + if self:tryBookshelf(fx, fy) then + interacted(self, fx, fy, "bookshelf") + return + end + interacted(self, fx, fy, "none") end --- data/tilesets/bookshelf_tile_ids.asm: tileset id + collision tile -> --- text. Only fires facing up, like the original. -local BOOKSHELVES = { - PLATEAU = { [0x30] = "statues" }, - HOUSE = { [0x3D] = "townmap", [0x1E] = "books" }, - MANSION = { [0x32] = "books" }, - REDS_HOUSE_1 = { [0x32] = "books" }, - LAB = { [0x28] = "books" }, - LOBBY = { [0x16] = "elevator", [0x50] = "stuff", [0x52] = "stuff" }, - GYM = { [0x1D] = "books" }, - DOJO = { [0x1D] = "books" }, - GATE = { [0x22] = "books" }, - MART = { [0x54] = "stuff", [0x55] = "stuff" }, - POKECENTER = { [0x54] = "stuff", [0x55] = "stuff" }, - SHIP = { [0x36] = "books" }, -} - +-- field.bookshelves (data/tilesets/bookshelf_tile_ids.asm): tileset id + +-- collision tile -> what to show. Only fires facing up, like the +-- original. An entry carries `kind` (one of the five vanilla flavors), +-- `text` (a data.text key) or `screen` (a state module to push). function OverworldState:tryBookshelf(fx, fy) if self.player.facing ~= "up" then return false end if not self.map:inBounds(fx, fy) then return false end - local table_ = BOOKSHELVES[self.map.def.tileset] + local shelves = FieldDefaults.field(Game.data, "bookshelves") + local table_ = shelves and shelves[self.map.def.tileset] if not table_ then return false end - local kind = table_[self.map:cellTile(fx, fy)] - if not kind then return false end + local entry = table_[self.map:cellTile(fx, fy)] + if not entry then return false end local t = Game.data.text + if entry.text then + Game.stack:push(TextBox.new(Game, t[entry.text] or entry.text)) + return true + end + if entry.screen then + -- Blue's house shelf opens the TOWN MAP (TownMapText) + pcall(Screens.push, Game, entry.screen) + return true + end + local kind = entry.kind if kind == "books" then -- Celadon Mansion's Diglett sculpture (book_or_sculpture.asm): -- MANSION tileset + faced cell's top-left tile $38 @@ -959,12 +1166,6 @@ function OverworldState:tryBookshelf(fx, fy) Game.stack:push(TextBox.new(Game, (t._IndigoPlateauStatuesText1 or "INDIGO PLATEAU") .. "\f" .. (line or "POKéMON LEAGUE HQ"))) - elseif kind == "townmap" then - -- Blue's house shelf opens the TOWN MAP (TownMapText) - local ok, TownMap = pcall(require, "src.ui.TownMap") - if ok then - Game.stack:push(TownMap.new(Game)) - end end return true end @@ -1034,8 +1235,7 @@ function OverworldState:tryHiddenObject(fx, fy) else -- one machine per visit is secretly lucky -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm) - local SlotMachine = require("src.ui.SlotMachine") - Game.stack:push(SlotMachine.new(Game, seatIndex == self.luckySlot)) + Screens.push(Game, "SlotMachine", seatIndex == self.luckySlot) end return true end @@ -1200,7 +1400,10 @@ function OverworldState:trashCanSwitch(canIndex) -- .openSecondLock: only VermilionGymTrashSuccessText3 prints -- (SuccessText2 is unused in pokered) save.flags.EVENT_2ND_LOCK_OPENED = true - self:replaceBlock(2, 2, 5) -- clear floor block opens the doors + -- the clear floor block opens the doors (VermilionGymSetDoorTile) + local door = FieldDefaults.fieldValue(Game.data, "hiddenExtras", + "trashCans", "doorBlock") + self:replaceBlock(door.bx, door.by, door.block) -- SuccessText3's text_asm tail plays SFX_GO_INSIDE after the text -- prints, so the beep fires as the box closes, not as it opens. Game.stack:push(TextBox.new(Game, @@ -1250,6 +1453,25 @@ function OverworldState:tilesetHasWater() return false end +-- field.seafoam[map].surfBlocked: cells where SURF is refused until the +-- listed events fire (IsSurfingAllowed's SEAFOAM_ISLANDS_B4F stairs case) +function OverworldState:surfBlockedHere() + local blocked = FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id, + "surfBlocked") + if not blocked then return false end + local p = self.player + for _, cell in ipairs(blocked) do + if p.cellX == cell.x and p.cellY == cell.y then + local cleared = true + for _, e in ipairs(cell.untilEvents or {}) do + if not Game.save.flags[e] then cleared = false break end + end + if not cleared then return true end + end + end + return false +end + -- Gen 1 has no confirmation prompt: using SURF gets straight on -- (_SurfingGotOnText, item_effects.asm .surf). Called from the party -- menu's SURF action (via useSurfFieldMove) once the facing tile has been @@ -1331,12 +1553,7 @@ function OverworldState:useSurfFieldMove() -- SEAFOAM_ISLANDS_B4F standing on the stairs square (dbmapcoord 7,11) -- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set. if Game.save.forcedBike then return "forced_bike" end - if self.map.id == "SEAFOAM_ISLANDS_B4F" - and not (Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] - and Game.save.flags["EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE"]) - and p.cellX == 7 and p.cellY == 11 then - return "current" - end + if self:surfBlockedHere() then return "current" end local fx, fy = p:facingCell() if p.surfing then -- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front @@ -1461,9 +1678,8 @@ function OverworldState:talkTo(npc) if entry then if entry.mart then npc:facePlayer(self.player) - local ShopMenu = require("src.ui.ShopMenu") Game.stack:push(TextBox.new(Game, "Hi there!\nMay I help you?", function() - Game.stack:push(ShopMenu.new(Game, entry.mart)) + Screens.push(Game, "ShopMenu", entry.mart) unfreeze() end)) return @@ -1487,9 +1703,13 @@ function OverworldState:talkTo(npc) self:showMapText(d.text, npc, unfreeze) end +local function sameItems(_, items) return items end + -- The Pokémon Center PC: BILL's PC (boxes), the player's item storage, -- and PROF.OAK's dex rating (engine/menus/players_pc.asm, --- engine/events/pokedex_rating.asm). +-- engine/events/pokedex_rating.asm). The assembled entries run through +-- the ui.pc.items hook; LOG OFF is appended after it so a mod cannot +-- orphan the exit. function OverworldState:openPC(onDone) require("src.core.Sound").play(Game.data, "Turn_On_PC") local Menu = require("src.ui.Menu") @@ -1505,8 +1725,7 @@ function OverworldState:openPC(onDone) label = metBill and "BILL'S PC" or "SOMEONE'S PC", onSelect = function() require("src.core.Sound").play(Game.data, "Enter_PC") - local BoxMenu = require("src.ui.BoxMenu") - Game.stack:push(BoxMenu.new(Game)) + Screens.push(Game, "BoxMenu") done() end, }) @@ -1515,8 +1734,7 @@ function OverworldState:openPC(onDone) table.insert(items, { label = (Game.save.player.name or "RED") .. "'s PC", onSelect = function() - local PlayerPC = require("src.ui.PlayerPC") - Game.stack:push(PlayerPC.new(Game)) + Screens.push(Game, "PlayerPC") done() end, }) @@ -1532,6 +1750,14 @@ function OverworldState:openPC(onDone) }) end + local hooked = Runtime.call("ui.pc.items", sameItems, Game, items) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.pc.items returned %s; keeping the vanilla items", + type(hooked)) + end + local logOff = function() require("src.core.Sound").play(Game.data, "Turn_Off_PC") done() @@ -1720,6 +1946,8 @@ end -- Run the pre-battle text -> battle -> won text -> flags sequence. function OverworldState:engageTrainer(npc, onDone) local d = npc.def + Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, + partyIndex = d.trainerParty }) local header = Game.data:trainerHeader(self.map.def.label, d.index) local battleText = header and header.battle and Game.data.text[header.battle] if not battleText then @@ -1886,7 +2114,10 @@ function OverworldState:showMapText(textConst, npc, onDone) script(Game, self, npc, onDone or function() end) return end - self.runner:run(script, { npc = npc, onDone = onDone }) + -- the winning contribution's rows run as their owner (09 §4.4): mod: + -- field routing, strict dispatch and error reports all read the source + self.runner:run(script, { npc = npc, onDone = onDone, + source = mapScripts.talkSource(self.map.id, textConst) }) return end local text, needsAsm = Game.data:resolveText(mapLabel, textConst) @@ -1912,13 +2143,15 @@ end -- stop (a text box is up). function OverworldState:applyFieldPoison() local save = Game.save - save.poisonSteps = ((save.poisonSteps or 0) + 1) % 4 + local interval = FieldDefaults.world(Game.data, "poisonStepInterval") or 4 + save.poisonSteps = ((save.poisonSteps or 0) + 1) % interval if save.poisonSteps ~= 0 then return false end + local damage = FieldDefaults.world(Game.data, "poisonDamage") or 1 local anyPoisoned, fainted = false, {} for _, mon in ipairs(save.party) do if mon.status == "PSN" and mon.hp > 0 then anyPoisoned = true - mon.hp = mon.hp - 1 + mon.hp = mon.hp - damage if mon.hp <= 0 then mon.hp = 0 mon.status = nil -- the original clears status on the faint @@ -1949,7 +2182,10 @@ function OverworldState:applyFieldPoison() ("%s blacked\nout!"):format(save.player.name), function() local Pokemon = require("src.pokemon.Pokemon") for _, mon in ipairs(save.party) do Pokemon.heal(mon) end - save.money = math.floor(save.money / 2) + save.money = math.floor(save.money + / (FieldDefaults.world(Game.data, "blackoutMoneyDivisor") or 2)) + Runtime.emit("world.blacked_out", + { save = save, healTarget = self:healPoint() }) self:warpToHealPoint() end)) end @@ -1963,9 +2199,37 @@ end -- ------------------------------------------------------------------------- +-- the two vanilla links the encounter chains wrap, hoisted so an empty +-- chain allocates no closure +local function rollVanilla(encDef, ctx) return Encounter.roll(encDef, ctx.rng) end +local function sameEncounter(enc) return enc end + +-- The wild pick, wrapped in encounter.roll (returns nil to suppress, a +-- table without calling next to force) and then encounter.species (which +-- transforms a non-nil roll before repel filtering). With no wrapper on +-- either name this is the bare Encounter.roll, same RNG draws and all. +function OverworldState:rollEncounter(encDef, terrain) + if not (Runtime.wantsHook("encounter.roll") + or Runtime.wantsHook("encounter.species")) then + return Encounter.roll(encDef) + end + local ctx = { mapId = self.map.id, terrain = terrain, rng = love.math.random } + local enc = Runtime.call("encounter.roll", rollVanilla, encDef, ctx) + if enc then + enc = Runtime.call("encounter.species", sameEncounter, enc, ctx) + end + return enc +end + function OverworldState:onStepComplete() local p = self.player + -- 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) }) + end + -- dismounting a surf: landing on a walkable cell ends it if p.surfing and self.map:isWalkableCell(p.cellX, p.cellY) then p.surfing = false @@ -1973,7 +2237,7 @@ function OverworldState:onStepComplete() end -- Route 22 Gate rewrites LAST_MAP by Y before warps/guards fire - self:syncRoute22GateLastMap() + self:syncLastMapRewrite() -- hand-ported step triggers (Pallet intro, Saffron gate guards, ...) local hooks = mapScripts.get(self.map.id) @@ -1998,7 +2262,8 @@ function OverworldState:onStepComplete() -- day-care: the boarded Pokémon gains 1 exp per step (like the original) if Game.save.daycare and Game.save.daycare.mon then - Game.save.daycare.steps = (Game.save.daycare.steps or 0) + 1 + Game.save.daycare.steps = (Game.save.daycare.steps or 0) + + (FieldDefaults.world(Game.data, "daycareExpPerStep") or 1) end -- out-of-battle poison (engine/events/poison.asm): every 4th step @@ -2041,12 +2306,12 @@ function OverworldState:onStepComplete() local enc local indoor = Game.data.field.indoorEncounters if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then - enc = Encounter.roll({ grass = encDef.water }) + enc = self:rollEncounter({ grass = encDef.water }, "water") elseif self.map:isGrassCell(p.cellX, p.cellY) then - enc = Encounter.roll(encDef) + enc = self:rollEncounter(encDef, "grass") elseif indoor and self.map.def.index >= indoor.firstIndoorMap and self.map.def.tileset ~= indoor.excludedTileset then - enc = Encounter.roll(encDef) + enc = self:rollEncounter(encDef, "indoor") end if enc then -- REPEL blocks wild mons weaker than the lead @@ -2057,13 +2322,14 @@ function OverworldState:onStepComplete() end local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(Game, enc.species, enc.level) - -- Pokémon Tower ghosts are unidentifiable without the Silph Scope - if self.map.id:find("POKEMON_TOWER", 1, true) == 1 - and not Game.save.inventory.SILPH_SCOPE then + -- map.ghostBattles: unidentifiable without the named item (the + -- Pokemon Tower's Silph Scope) + local ghost = Map.ghostBattles(self.map.def) + if ghost and not (ghost.unlessItem and Game.save.inventory[ghost.unlessItem]) then battle:makeGhost() end -- Safari game encounters use the BALL/BAIT/ROCK/RUN menu - if Game.save.safari and self.map.id:find("SAFARI_ZONE", 1, true) == 1 then + if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then battle:makeSafari(Game.save.safari) end battle.onFinish = function(result) self:afterBattle(result) end @@ -2109,35 +2375,54 @@ end -- field.badgeGates): stepping on a guard row without the badge turns -- you back; with it, the guard waves you through once. --- pokered Route22Gate_Script: every frame, Y < 4 -> wLastMap = ROUTE_23, --- else ROUTE_22. All four gate door warps are LAST_MAP, so this is what --- makes the north exit leave onto Route 23 (and the south onto Route 22). -function OverworldState.route22GateOutdoor(cellY) - return cellY < 4 and "ROUTE_23" or "ROUTE_22" +-- field.lastMapRewrites: maps that rewrite wLastMap from the player's +-- position every frame. Rules are ordered, first match wins, the last row +-- is the default -- pokered Route22Gate_Script is Y < 4 -> ROUTE_23, else +-- ROUTE_22, which is what makes the gate's north exit leave onto Route 23. +-- All four of its door warps are LAST_MAP. +function OverworldState.rewrittenLastMap(rewrite, cellX, cellY) + local value = rewrite.axis == "x" and cellX or cellY + for _, rule in ipairs(rewrite.rules or {}) do + if (rule.below == nil or value < rule.below) + and (rule.atLeast == nil or value >= rule.atLeast) then + return rule.map + end + end + return nil end -function OverworldState:syncRoute22GateLastMap() - if not self.map or self.map.id ~= "ROUTE_22_GATE" then return end - local id = OverworldState.route22GateOutdoor(self.player.cellY) - if self.lastOutdoor and self.lastOutdoor.id == id then return end +function OverworldState:syncLastMapRewrite() + if not self.map then return end + local rewrites = FieldDefaults.field(Game.data, "lastMapRewrites") + local rewrite = rewrites and rewrites[self.map.id] + if not rewrite then return end + local id = OverworldState.rewrittenLastMap(rewrite, self.player.cellX, + self.player.cellY) + if not id or (self.lastOutdoor and self.lastOutdoor.id == id) then return end local warps = Game.data.maps[id] and Game.data.maps[id].warps local w = warps and warps[1] self:rememberOutdoor(id, w and w.x or 0, w and w.y or 0) end +-- field.badgeGates is keyed by map; the record's shape picks the rule. +-- `coords` is the Route 22 gate's single checkpoint (one-shot pass text), +-- `guards` the Route 23 ladder of per-row guards. function OverworldState:checkBadgeGate() local gates = Game.data.field.badgeGates - if not gates then return false end + local g = gates and gates[self.map.id] + if not g then return false end local p = self.player local t = Game.data.text - if self.map.id == "ROUTE_22_GATE" then - local g = gates.ROUTE_22_GATE + if g.coords then + local passedFlag = FieldDefaults.fieldValue(Game.data, "badgeGates", + self.map.id, "passedFlag") + or ("PASSED_" .. self.map.id) for _, c in ipairs(g.coords) do if p.cellX == c.x and p.cellY == c.y then if Game.save.inventory[g.badge] then - if not Game.save.flags.PASSED_ROUTE22_GATE then - Game.save.flags.PASSED_ROUTE22_GATE = true + if not Game.save.flags[passedFlag] then + Game.save.flags[passedFlag] = true -- Route22GateGuardGoRightAheadText plays sound_get_item_1 require("src.core.Sound").play(Game.data, "Get_Item1") Game.stack:push(TextBox.new(Game, @@ -2158,24 +2443,24 @@ function OverworldState:checkBadgeGate() return false end - if self.map.id == "ROUTE_23" then - for _, g in ipairs(gates.ROUTE_23.guards) do - if p.cellY == g.y and (not g.maxX or p.cellX <= g.maxX) - and not Game.save.flags[g.event] then - local badgeName = Game.data.items[g.badge] and Game.data.items[g.badge].name - or g.badge - if Game.save.inventory[g.badge] then - Game.save.flags[g.event] = true + if g.guards then + for _, guard in ipairs(g.guards) do + if p.cellY == guard.y and (not guard.maxX or p.cellX <= guard.maxX) + and not Game.save.flags[guard.event] then + local badgeName = Game.data.items[guard.badge] + and Game.data.items[guard.badge].name or guard.badge + if Game.save.inventory[guard.badge] then + Game.save.flags[guard.event] = true -- Route23OhThatIsTheBadgeText plays sound_get_item_1 require("src.core.Sound").play(Game.data, "Get_Item1") - local text = (t["_" .. gates.ROUTE_23.passText] or + local text = (t["_" .. g.passText] or "Oh! That is the\n{RAM}!"):gsub("{RAM:wNameBuffer}", badgeName) Game.stack:push(TextBox.new(Game, text)) return false end -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED require("src.core.Sound").play(Game.data, "Denied") - local text = (t["_" .. gates.ROUTE_23.failText] or + local text = (t["_" .. g.failText] or "You don't have the\n{RAM} yet!"):gsub("{RAM:wNameBuffer}", badgeName) Game.stack:push(TextBox.new(Game, text, function() self:scriptMove(p, "down", 1) @@ -2183,7 +2468,6 @@ function OverworldState:checkBadgeGate() return true end end - return false end return false end @@ -2351,17 +2635,17 @@ end -- interior Safari Zone map -- the 4 zone quadrants plus the 4 rest -- houses plus the secret house -- counts, and the gate itself never -- does. -local SAFARI_STEP_MAPS = { - SAFARI_ZONE_CENTER = true, SAFARI_ZONE_EAST = true, - SAFARI_ZONE_NORTH = true, SAFARI_ZONE_WEST = true, - SAFARI_ZONE_CENTER_REST_HOUSE = true, SAFARI_ZONE_EAST_REST_HOUSE = true, - SAFARI_ZONE_NORTH_REST_HOUSE = true, SAFARI_ZONE_WEST_REST_HOUSE = true, - SAFARI_ZONE_SECRET_HOUSE = true, -} +-- field.safari.stepMaps +function OverworldState:inSafariStepZone() + for _, m in ipairs(FieldDefaults.fieldValue(Game.data, "safari", "stepMaps") or {}) do + if m == self.map.id then return true end + end + return false +end function OverworldState:safariStep() local st = Game.save.safari - if not st or not SAFARI_STEP_MAPS[self.map.id] then return false end + if not st or not self:inSafariStepZone() then return false end st.steps = st.steps - 1 if st.steps > 0 then return false end self:safariGameOver("PA: Ding-dong!\nTime's up!") @@ -2375,7 +2659,8 @@ function OverworldState:safariGameOver(text) Game.stack:push(TextBox.new(Game, (text or "") .. "\f" .. (t._GameOverText or "PA: Your SAFARI\nGAME is over!"), function() - self:startWarpTo("SAFARI_ZONE_GATE", 4, 3, "down") + local exit_ = FieldDefaults.fieldValue(Game.data, "safari", "exitWarp") + self:startWarpTo(exit_.map, exit_.x, exit_.y, exit_.facing or "down") end)) end @@ -2396,7 +2681,10 @@ function OverworldState:afterBattle(result) for _, mon in ipairs(Game.save.party) do Pokemon.heal(mon) end - Game.save.money = math.floor(Game.save.money / 2) + Game.save.money = math.floor(Game.save.money + / (FieldDefaults.world(Game.data, "blackoutMoneyDivisor") or 2)) + Runtime.emit("world.blacked_out", + { save = Game.save, healTarget = self:healPoint() }) self:warpToHealPoint(evolutions) else -- throwing the last SAFARI BALL ends the game @@ -2411,16 +2699,27 @@ end -- warps -- ------------------------------------------------------------------------- +-- field.boot: where a save with no heal point of its own returns to. The +-- lastHeal record wins; otherwise the new game's own spawn cell. +function OverworldState:healPoint() + local boot = Game.data.field.boot or {} + return Game.save.lastHeal or boot.lastHeal + or { map = boot.startMap, x = boot.startX, y = boot.startY } +end + function OverworldState:takeWarp(warpDef) local last = self.lastOutdoor if warpDef.destMap == "LAST_MAP" and not last then -- old saves / unexpected states: never crash on an exit mat, fall - -- back to the heal point's town door (or Pallet) + -- back to the heal point's town door (or the boot spawn) Logger.warn("LAST_MAP warp with no remembered outdoor map; using heal point") - local heal = Game.save.lastHeal - last = heal and heal.outdoor or { id = "PALLET_TOWN", x = 5, y = 6 } + local heal = self:healPoint() + last = heal.outdoor or { id = heal.map, x = heal.x, y = heal.y } end + local fromMap = self.map.id local destMap, x, y = Warp.destination(Game.data, warpDef, last) + Runtime.emit("player.warped", { fromMap = fromMap, toMap = destMap, + x = x, y = y, warp = warpDef }) -- facing carries across the warp (leaving a gate sideways keeps you -- walking sideways; house exit mats are stepped onto facing down) local facing = self.player.facing @@ -2438,7 +2737,7 @@ end -- The heal point is usually an interior, so LAST_MAP exits are re-pointed -- at its remembered town door rather than wherever the player left from. function OverworldState:warpToHealPoint(onDone) - local heal = Game.save.lastHeal or { map = "PALLET_TOWN", x = 5, y = 6 } + local heal = self:healPoint() self.player.surfing = false -- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear -- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm) @@ -2466,8 +2765,8 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- LAST_MAP exits taken off Route 23/Indigo Plateau (the Route 22 Gate -- back door, the Indigo Plateau lobby doors) resolve against a stale -- remembered map instead. - local outsideTilesets = { OVERWORLD = true, PLATEAU = true } - if outsideTilesets[self.map.def.tileset] and mapId ~= self.map.id then + if Map.isOutside(self.map.def, FieldDefaults.field(Game.data, "outsideTilesets")) + and mapId ~= self.map.id then self:rememberOutdoor(self.map.id, self.player.cellX, self.player.cellY) end self.transitioning = true @@ -2489,7 +2788,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.delaySfx = { frames = 40, key = "Teleport_Enter2" } end if doorWarp then - local outdoor = self.map.def.tileset == "OVERWORLD" + local outdoor = Map.isOutdoor(self.map.def) require("src.core.Sound").play(Game.data, outdoor and "Go_Outside" or "Go_Inside") -- stepping out of an outdoor door mat (the original's walk-out) @@ -2504,10 +2803,83 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) end)) end +-- Re-read a map record after its data changed (WorldAPI:invalidateMap, +-- dev-mode hot reload). The neighbors go too: their strips render the +-- same tileset. When the active map is the one that changed, the player +-- is clamped back in bounds, the NPC pool is reused so runtime handles +-- survive, and the tile-pair table is re-read. +function OverworldState:reloadMap(mapId, reason) + MapLoader.invalidate(mapId) + for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end + if self.map and self.map.id == mapId then + local p = self.player + local x, y, facing = p.cellX, p.cellY, p.facing + Collision.load(Game.data) + self:setMap(mapId, x, y, facing, { seamless = true, via = "reload" }) + if not self.map:inBounds(x, y) then + local heal = self:healPoint() + Logger.warn("map %s reloaded out from under the player; sending to %s", + mapId, tostring(heal.map)) + self:setMap(heal.map, heal.x, heal.y, "down", { via = "reload" }) + end + end + Runtime.emit("map.reloaded", { mapId = mapId, reason = reason or "invalidate" }) +end + +-- Append a runtime object to a map record and, when that map is live, +-- instantiate it through the shared pool so it crosses seams like an +-- imported object. Runtime objects are never serialized into map data. +function OverworldState:addRuntimeObject(mapId, objDef, owner) + local def = Game.data.maps[mapId] + if not def then return nil, "unknown map: " .. tostring(mapId) end + def.objects = def.objects or {} + local index = 0 + for _, obj in ipairs(def.objects) do + if (obj.index or 0) > index then index = obj.index end + end + objDef.index = index + 1 + objDef.runtime = true + objDef.owner = owner + table.insert(def.objects, objDef) + local npcId = mapId .. "_obj_" .. objDef.index + if self.map and self.map.id == mapId and self.npcPool then + local npc = pooledNPC(self.npcPool, Game.data, mapId, objDef) + npc.frozen = false + table.insert(self.npcs, npc) + table.insert(self.entities, npc) + end + return npcId +end + +-- Drop a runtime object again; imported objects are refused, and so is +-- another mod's. +function OverworldState:removeRuntimeObject(npcId, owner) + for mapId, def in pairs(Game.data.maps) do + for i, obj in ipairs(def.objects or {}) do + if obj.runtime and mapId .. "_obj_" .. obj.index == npcId then + if owner ~= nil and obj.owner ~= owner then + return nil, "not owned by " .. tostring(owner) + end + table.remove(def.objects, i) + if self.npcPool then self.npcPool[npcId] = nil end + for _, list in ipairs({ self.npcs or {}, self.entities or {} }) do + for j = #list, 1, -1 do + if list[j].id == npcId then table.remove(list, j) end + end + end + return true + end + end + end + return nil, "no runtime object " .. tostring(npcId) +end + -- Replace a map block (Victory Road barriers, Cut trees) and redraw. function OverworldState:replaceBlock(bx, by, block) self.map:setBlock(bx, by, block) self.map.renderer:rebuild() + Runtime.emit("world.block_replaced", + { mapId = self.map.id, bx = bx, by = by, block = block }) end -- ------------------------------------------------------------------------- @@ -2566,6 +2938,15 @@ function OverworldState:updateScriptMoves() mv.remaining = mv.remaining - 1 end end + -- march_in_place toggles: re-arm the in-place cycle each time it ends. + -- Not a scriptMove, so an ambient marcher never trips the input lockout. + for entity in pairs(self.marchers or {}) do + if not entity.moving then + entity.moving = true + entity.marching = true + entity.progress = 0 + end + end end -- ------------------------------------------------------------------------- @@ -2750,8 +3131,9 @@ function OverworldState:drawWorld() self.emoteImg = self.emoteImg or love.graphics.newImage(bubble.path) return self.emoteImg end) - -- EXCLAMATION_BUBBLE is index 0 -> first crop - local rect = bubble.bubbles and bubble.bubbles[1] + -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command + -- picks question/happy crops instead + local rect = bubble.bubbles and bubble.bubbles[self.emote.bubble or 1] if ok and img and rect then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(img, love.graphics.newQuad(rect.x, rect.y, @@ -2789,9 +3171,10 @@ function OverworldState:drawWorld() -- the FLY bird sweeping off with the player local function fxBird() if not self.flyAnim then return end - if not self.birdSprite and Game.data.sprites.SPRITE_BIRD then + local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") + if not self.birdSprite and birdId and Game.data.sprites[birdId] then local SR = require("src.render.SpriteRenderer") - self.birdSprite = SR.new(Game.data.sprites.SPRITE_BIRD) + self.birdSprite = SR.new(Game.data.sprites[birdId]) end if self.birdSprite then local t = 48 - self.flyAnim.frames diff --git a/src/world/Player.lua b/src/world/Player.lua index 9f225ded..0fe5093c 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -3,6 +3,7 @@ -- at 1px per frame (16 frames per step), input locked while stepping. local Collision = require("src.world.Collision") +local FieldDefaults = require("src.world.FieldDefaults") local SpriteRenderer = require("src.render.SpriteRenderer") local Player = {} @@ -16,15 +17,21 @@ local TURN_FRAMES = 2 function Player.new(data, cx, cy, facing) local self = setmetatable({}, Player) - self.sprite = SpriteRenderer.new(data.sprites.SPRITE_RED) - -- the original surfs on the Seel sprite - -- (LoadSurfingPlayerSpriteGraphics, home/overworld.asm) - if data.sprites.SPRITE_SEEL then - self.surfSprite = SpriteRenderer.new(data.sprites.SPRITE_SEEL) + self.stepFrames = FieldDefaults.world(data, "stepFrames") or STEP_FRAMES + self.bikeStepFrames = FieldDefaults.world(data, "bikeStepFrames") + self.turnFrames = FieldDefaults.world(data, "turnFrames") or TURN_FRAMES + -- field.playerSprites: which sprite ids the player wears on foot, on the + -- water and on the bicycle (LoadPlayerSpriteGraphics / + -- LoadSurfingPlayerSpriteGraphics, home/overworld.asm) + local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk") + local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf") + local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike") + self.sprite = SpriteRenderer.new(data.sprites[walkId]) + if surfId and data.sprites[surfId] then + self.surfSprite = SpriteRenderer.new(data.sprites[surfId]) end - -- and cycles on the red_bike sheet (LoadPlayerSpriteGraphics) - if data.sprites.SPRITE_RED_BIKE then - self.bikeSprite = SpriteRenderer.new(data.sprites.SPRITE_RED_BIKE) + if bikeId and data.sprites[bikeId] then + self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId]) end -- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png, -- LedgeHoppingShadow, engine/overworld/ledges.asm) @@ -53,7 +60,7 @@ function Player:tryMove(dir, map, entities) if self.moving or self.inputLocked then return nil end if self.facing ~= dir then self.facing = dir - self.turnTimer = TURN_FRAMES + self.turnTimer = self.turnFrames or TURN_FRAMES return "turned" end if self.turnTimer > 0 then return nil end @@ -67,7 +74,8 @@ function Player:tryMove(dir, map, entities) 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 8 or STEP_FRAMES + self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames + or self.stepFrames or STEP_FRAMES return "moved" end @@ -77,7 +85,7 @@ function Player:update() self.turnTimer = self.turnTimer - 1 end if not self.moving then return false end - local stepLen = self.stepFramesCur or STEP_FRAMES + local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES self.progress = self.progress + 1 local d = Collision.DELTA[self.facing] local px = math.floor(self.progress * 16 / stepLen) diff --git a/src/world/Warp.lua b/src/world/Warp.lua index 2902bc97..513b3ec6 100644 --- a/src/world/Warp.lua +++ b/src/world/Warp.lua @@ -9,6 +9,8 @@ -- This mirrors pokered's CheckWarpsNoCollision / CheckWarpsCollision / -- ExtraWarpCheck (home/overworld.asm). +local Runtime = require("src.mods.Runtime") + local Warp = {} -- Returns the warp entry to take when arriving at (cx,cy), or nil. @@ -88,7 +90,7 @@ end -- map; the landing cell is that map's warp entry named by the warp id -- (wDestinationWarpID placement -- two-sided route gates land you on -- the side you exit, not where you entered). -function Warp.destination(data, warpDef, lastMap) +local function resolve(data, warpDef, lastMap) local destMap = warpDef.destMap if destMap == "LAST_MAP" then assert(lastMap, "LAST_MAP warp with no remembered outdoor map") @@ -108,4 +110,16 @@ function Warp.destination(data, warpDef, lastMap) return destMap, dw.x, dw.y end +-- the resolved destination passes through warp.destination, so a mod can +-- reroute one door without owning the warp table (ctx carries the warp +-- record and the remembered outdoor side the resolution used) +local function warped(mapId, x, y) return mapId, x, y end + +function Warp.destination(data, warpDef, lastMap) + local destMap, x, y = resolve(data, warpDef, lastMap) + if not Runtime.wantsHook("warp.destination") then return destMap, x, y end + return Runtime.call("warp.destination", warped, destMap, x, y, + { warp = warpDef, lastMap = lastMap, data = data }) +end + return Warp diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua new file mode 100644 index 00000000..6adadc51 --- /dev/null +++ b/src/world/WorldAPI.lua @@ -0,0 +1,185 @@ +-- mod.world: the supported way for mod code to act on the running +-- overworld. Every method resolves the live OverworldState by scanning +-- the state stack for the isOverworld marker and returns nil, "no +-- overworld" when none is up -- called from the title screen this is a +-- quiet no-op, never a crash. Reaching into OverworldState internals +-- stays unsupported; anything a mod legitimately needs belongs here. + +local Logger = require("src.core.Logger") +local MapLoader = require("src.world.MapLoader") +local Runtime = require("src.mods.Runtime") + +local WorldAPI = {} +WorldAPI.__index = WorldAPI + +local NO_OVERWORLD = "no overworld" + +function WorldAPI.new(game, modId) + return setmetatable({ game = game, modId = modId }, WorldAPI) +end + +-- the live overworld, or nil. Game.overworld is the fast path; the stack +-- scan is the authority, so a state pushed over the world (a battle, a +-- menu) still resolves to the world underneath it. +function WorldAPI:overworld() + local game = self.game + local stack = game and game.stack + local states = stack and stack.states + if states then + for i = #states, 1, -1 do + if states[i].isOverworld then return states[i] end + end + end + local ow = game and game.overworld + if ow and ow.isOverworld and ow.map then return ow end + return nil +end + +function WorldAPI:current() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + local p = ow.player + return { mapId = ow.map.id, x = p and p.cellX, y = p and p.cellY, + facing = p and p.facing } +end + +-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else +-- lands the player without one, like a scripted warp. +function WorldAPI:warpTo(mapId, x, y, facing, opts) + local ow = self:overworld() + if not ow then return nil, NO_OVERWORLD end + if not self.game.data.maps[mapId] then + return nil, "unknown map: " .. tostring(mapId) + end + if opts and (opts.arrive == "fly" or opts.arrive == "teleport") then + ow.arriveWarp = opts.arrive + end + ow:startWarpTo(mapId, x, y, facing or "down", opts and opts.onDone, + { via = "warp", keepMusic = opts and opts.keepMusic }) + return true +end + +-- save.objectToggles is the same store the spawn filter reads, so a toggle +-- on an inactive map takes effect the next time it is entered. +function WorldAPI:toggleObject(mapId, objName, visible) + local save = self.game and self.game.save + if not save then return nil, "no save" end + save.objectToggles = save.objectToggles or {} + save.objectToggles[mapId] = save.objectToggles[mapId] or {} + save.objectToggles[mapId][objName] = visible and true or false + Runtime.emit("world.object_toggled", + { mapId = mapId, objName = objName, visible = visible and true or false }) + local ow = self:overworld() + if ow and ow.map and ow.map.id == mapId then + ow:setMap(mapId, ow.player.cellX, ow.player.cellY, ow.player.facing, + { seamless = true, via = "reload", keepMusic = true }) + end + return true +end + +function WorldAPI:setFlag(name, value) + local save = self.game and self.game.save + if not save or not save.flags then return nil, "no save" end + save.flags[name] = value + return true +end + +function WorldAPI:getFlag(name) + local save = self.game and self.game.save + return save and save.flags and save.flags[name] +end + +-- active map only: this mutates the runtime Map and rebuilds the renderer. +-- A layout change that must survive a reload belongs in a maps patch. +function WorldAPI:replaceBlock(bx, by, block) + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + ow:replaceBlock(bx, by, block) + return true +end + +-- objDef uses the same shape as maps[].objects. Runtime objects are not +-- serialized: a permanent NPC belongs in a maps patch, this is for +-- scripted and dynamic actors the mod re-spawns on map.entered. +function WorldAPI:spawnNpc(mapId, objDef) + local ow = self:overworld() + if not ow then return nil, NO_OVERWORLD end + if type(objDef) ~= "table" then return nil, "objDef must be a table" end + local copy = {} + for k, v in pairs(objDef) do copy[k] = v end + return ow:addRuntimeObject(mapId, copy, self.modId) +end + +function WorldAPI:removeNpc(npcId) + local ow = self:overworld() + if not ow then return nil, NO_OVERWORLD end + return ow:removeRuntimeObject(npcId, self.modId) +end + +-- a handle onto a live NPC: scriptMove / marchInPlace / face, which is +-- everything the scripted-movement queue exposes +local Handle = {} +Handle.__index = Handle + +function Handle:scriptMove(dir, tiles, onDone) + self.ow:scriptMove(self.npc, dir, tiles or 1, onDone) + return true +end + +function Handle:marchInPlace(onDone) + self.ow:marchInPlace(self.npc, onDone) + return true +end + +function Handle:face(dir) + self.npc.facing = dir + return true +end + +function Handle:position() + return self.npc.cellX, self.npc.cellY +end + +function WorldAPI:npc(mapId, indexOrName) + local ow = self:overworld() + if not ow then return nil, NO_OVERWORLD end + if ow.map and ow.map.id ~= mapId then return nil, "map is not active" end + for _, npc in ipairs(ow.npcs or {}) do + if npc.def.index == indexOrName or npc.def.name == indexOrName + or npc.id == indexOrName then + return setmetatable({ ow = ow, npc = npc, id = npc.id }, Handle) + end + end + return nil, "no such object: " .. tostring(indexOrName) +end + +-- FIFO queueing is owned by the script runner; until it lands this runs +-- the rows when nothing else is running and refuses otherwise, so a mod +-- never silently loses a script. +function WorldAPI:queueScript(rows, extra) + local ow = self:overworld() + if not ow or not ow.runner then return nil, NO_OVERWORLD end + if ow.runner:isRunning() then return nil, "a script is already running" end + ow.runner:run(rows, extra) + return true +end + +-- drop a map's cached instance so the next load re-reads its record; when +-- it is the active map the world reloads around the player in place +function WorldAPI:invalidateMap(mapId) + local ow = self:overworld() + if not ow then + local had = MapLoader.invalidate(mapId) + Runtime.emit("map.reloaded", { mapId = mapId, reason = "invalidate" }) + return had + end + local ok, err = pcall(ow.reloadMap, ow, mapId, "invalidate") + if not ok then + Logger.warn("[%s] invalidateMap %s failed: %s", tostring(self.modId), + tostring(mapId), tostring(err)) + return nil, tostring(err) + end + return true +end + +return WorldAPI diff --git a/tests/bless_fingerprints.lua b/tests/bless_fingerprints.lua new file mode 100644 index 00000000..ff15bcd1 --- /dev/null +++ b/tests/bless_fingerprints.lua @@ -0,0 +1,53 @@ +-- Re-pin the fingerprint goldens (21-testing-and-ci "the fingerprint +-- gate": "regenerated only by an explicit scripts/test.sh --bless after a +-- documented, intended parity change"). +-- +-- Blessing is deliberate. The fingerprint is the number two builds must +-- agree on to link, so moving it breaks linking between every existing +-- build and every new one -- that is a parity change, and the tri-ledger +-- (docs/known-differences.md / docs/new-features.md) is where it gets +-- recorded before this is run. +-- +-- luajit tests/bless_fingerprints.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Fingerprint = require("src.link.Fingerprint") + +local function write(path, value) + local handle = io.open(path, "w") + if not handle then + io.stderr:write("cannot write " .. path .. "\n") + os.exit(1) + end + handle:write(value, "\n") + handle:close() + print(("blessed %s -> %s"):format(path, value)) +end + +os.execute("mkdir -p tests/goldens") + +-- the fixture golden always exists: it needs no ROM +do + local data = T.fixtures.fresh() + local run = T.sdk.loadNone({ data = data }) + write("tests/goldens/fixture_fingerprint.txt", Fingerprint.compute(data, {})) + run.release() +end + +-- the vanilla golden only when a ROM has been imported +do + local probe = io.open("data/generated/maps.lua", "r") + if not probe then + print("skipped tests/goldens/vanilla_fingerprint.txt (no data/generated/)") + return + end + probe:close() + + local Data = require("src.core.Data") + Data:load() + local run = T.sdk.loadNone({ data = Data }) + write("tests/goldens/vanilla_fingerprint.txt", Fingerprint.compute(Data, {})) + run.release() +end diff --git a/tests/content_red/content_facts.lua b/tests/content_red/content_facts.lua new file mode 100644 index 00000000..791cb896 --- /dev/null +++ b/tests/content_red/content_facts.lua @@ -0,0 +1,115 @@ +-- T3: the pinned Red facts, asserted from tests/content_red/facts.lua +-- (21-testing-and-ci "test taxonomy"). +-- +-- Every value here is Red-specific and would be wrong for a total +-- conversion, which is exactly why it lives in this tier and not in +-- tests/engine/. The numbers come out of facts.lua so a conversion can +-- point the same assertions at its own table. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local facts = require("tests.content_red.facts") + +local Data = require("src.core.Data") +Data:load() + +-- ------- the dataset the ROM import is supposed to produce + +for _, name in ipairs(facts.requiredModules) do + T.check(Data[name] ~= nil, "generated data module loaded: " .. name) +end + +do + local dex = 0 + for _ in pairs(Data.pokemon) do dex = dex + 1 end + T.eq(dex, facts.dexSize, "the full dex is imported") + T.eq(Data.constants.dexSize, facts.dexSize, "constants.dexSize matches the roster") +end + +-- the type records are engine built-ins that arrive with the merge, so +-- the zero-mod load has to run before they can be counted +local run = T.sdk.loadNone({ data = Data }) +T.eq(#run.errors, 0, "the generated dataset loads with no mods and no errors") + +do + local types = 0 + for _ in pairs(Data.type_chart.types or {}) do types = types + 1 end + T.eq(types, facts.typeCount, "the Gen 1 type list is complete") +end + +-- ------- maps + +local MapLoader = require("src.world.MapLoader") + +for id, size in pairs(facts.maps) do + local map = MapLoader.load(Data, id) + T.check(map ~= nil, "map loads: " .. id) + if map then + T.eq(map.widthCells, size.widthCells, id .. " width in cells") + T.eq(map.heightCells, size.heightCells, id .. " height in cells") + end +end + +do + local pallet = MapLoader.load(Data, "PALLET_TOWN") + for _, cell in ipairs(facts.pallet.walkable) do + T.check(pallet:isWalkableCell(cell[1], cell[2]), + ("Pallet cell (%d,%d) is walkable"):format(cell[1], cell[2])) + end + for _, cell in ipairs(facts.pallet.blocked) do + T.check(not pallet:isWalkableCell(cell[1], cell[2]), + ("Pallet cell (%d,%d) is blocked"):format(cell[1], cell[2])) + end + + local door = facts.pallet.doorWarp + T.check(pallet:isWarpTileCell(door.x, door.y), "Red's house door is a door tile") + local warp = pallet:warpAtCell(door.x, door.y) + T.eq(warp and warp.def.destMap, door.destMap, "the door warp leads to Red's house") + + local sign = pallet:signAtCell(facts.pallet.oakSign.x, facts.pallet.oakSign.y) + T.eq(sign and sign.text, facts.pallet.oakSign.text, "Oak's lab sign text constant") +end + +-- ------- species + +local Stats = require("src.pokemon.Stats") +local ZERO_DVS = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } + +for id, want in pairs(facts.starters) do + local def = Data.pokemon[id] + T.check(def ~= nil, "starter present: " .. id) + if def then + T.eq(def.dex, want.dex, id .. " dex number") + T.eq(#def.types, #want.types, id .. " type count") + for i, typeId in ipairs(want.types) do + T.eq(def.types[i], typeId, ("%s type %d"):format(id, i)) + end + local stats = Stats.calc(def, 5, ZERO_DVS) + for stat, value in pairs(want.statsAt5) do + T.eq(stats[stat], value, ("L5 %s %s (0 DVs)"):format(id, stat)) + end + end +end + +-- ------- engine literals that a conversion overrides + +T.eq(Data.constants.fallbackMove, facts.fallbackMove, "the move-slot repair floor") + +for i, move in ipairs(facts.hmMoves) do + T.eq(Data.constants.hmMoves[i], move, "HM move " .. i) +end + +for i, badge in ipairs(facts.badges) do + T.eq(Data.constants.badges[i].id, badge, "badge " .. i .. " in gym order") +end + +-- ------- party icons + +for dex, icon in pairs(facts.icons) do + T.eq(Data.icons.byDex[dex], icon, "party icon for dex " .. dex) +end + +run.release() + +T.finish("content_red_facts") diff --git a/tests/content_red/facts.lua b/tests/content_red/facts.lua new file mode 100644 index 00000000..72317e7a --- /dev/null +++ b/tests/content_red/facts.lua @@ -0,0 +1,75 @@ +-- The pinned Red facts, as data (21-testing-and-ci "test taxonomy" T3). +-- +-- The content tier asserts values that are true of Pokemon Red and of +-- nothing else -- map sizes, encounter slots, exact pokered strings. Held +-- as a table rather than spread through assertion code, a total conversion +-- can drop in tests/content_/facts.lua, point the same suites at it, +-- and keep the pinned-value style without touching the engine tier. +-- +-- This is deliberately not a copy of every assertion in run_tests.lua: +-- it is the machine-readable spine that new content assertions are written +-- against, so the pinned numbers live in one reviewable place. + +return { + -- Data:load must produce these before anything else is meaningful + requiredModules = { + "constants", "maps", "tilesets", "text", "text_pointers", + "trainer_headers", "font", "sprites", "pokemon", "moves", "items", + "type_chart", "trainers", "encounters", "field", "battle_anims", + }, + + dexSize = 151, + typeCount = 15, + + -- map dimensions in cells (MapLoader reports widthCells/heightCells) + maps = { + PALLET_TOWN = { widthCells = 20, heightCells = 18 }, + VIRIDIAN_CITY = { widthCells = 40, heightCells = 36 }, + OAKS_LAB = { widthCells = 10, heightCells = 12 }, + }, + + -- known walkability and warp ground truth in Pallet Town + pallet = { + spawn = { x = 5, y = 6 }, + walkable = { { 5, 6 }, { 5, 5 } }, + blocked = { { 4, 4 }, { 0, 3 } }, + doorWarp = { x = 5, y = 5, destMap = "REDS_HOUSE_1F" }, + oakSign = { x = 13, y = 13, text = "TEXT_PALLETTOWN_OAKSLAB_SIGN" }, + }, + + -- the starter trio and their level-5 stats at zero DVs. DVs must be + -- pinned or the numbers move: Pokemon.new rolls them, so these are + -- Stats.calc against an explicit zero set, the same way the behavior + -- suite's fixedMon does it. + starters = { + BULBASAUR = { dex = 1, types = { "GRASS", "POISON" }, + statsAt5 = { hp = 19, attack = 9, defense = 9, speed = 9, special = 11 } }, + CHARMANDER = { dex = 4, types = { "FIRE" }, + statsAt5 = { hp = 18, attack = 10, defense = 9, speed = 11, special = 10 } }, + SQUIRTLE = { dex = 7, types = { "WATER" }, + statsAt5 = { hp = 19, attack = 9, defense = 11, speed = 9, special = 10 } }, + }, + + -- party-icon dex mapping (data/icon_pointers.asm) + icons = { + [1] = "GRASS", [10] = "BUG", [19] = "QUADRUPED", [23] = "SNAKE", + }, + + -- the engine's own fallback move and the HM set + fallbackMove = "TACKLE", + hmMoves = { "CUT", "FLY", "SURF", "STRENGTH", "FLASH" }, + + badges = { + "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", + "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", + }, + + -- the shipped example mod and what it is supposed to do + exampleMod = { + path = "mods/example_mew_starter", + id = "example_mew_starter", + species = "MEW", + frontSprite = "mods/example_mew_starter/assets/mew_front_inverted.png", + backSprite = "mods/example_mew_starter/assets/mew_back_inverted.png", + }, +} diff --git a/tests/content_red/gate_fingerprint.lua b/tests/content_red/gate_fingerprint.lua new file mode 100644 index 00000000..f3e98c97 --- /dev/null +++ b/tests/content_red/gate_fingerprint.lua @@ -0,0 +1,48 @@ +-- T3: the vanilla fingerprint gate (21-testing-and-ci "the fingerprint +-- gate", local variant). +-- +-- The fixture gate in tests/engine/ proves the mechanism; this one pins +-- the number that actually matters for players -- the digest two vanilla +-- Red builds must agree on to link. If a change to a built-in registry +-- record moves this hash, every existing build stops linking with every +-- new one, silently. That is a parity change, and it has to be a +-- deliberate one: re-bless with scripts/test.sh --bless only after +-- recording it in the tri-ledger (docs/known-differences.md or +-- docs/new-features.md). + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Fingerprint = require("src.link.Fingerprint") + +local GOLDEN = "tests/goldens/vanilla_fingerprint.txt" + +local Data = require("src.core.Data") +Data:load() + +local run = T.sdk.loadNone({ data = Data }) +T.eq(#run.errors, 0, "the generated dataset loads with no mods and no errors") + +local actual = Fingerprint.compute(Data, {}) + +local handle = io.open(GOLDEN, "r") +T.check(handle ~= nil, "the committed vanilla fingerprint golden exists: " .. GOLDEN) +if handle then + local golden = handle:read("*l") + handle:close() + golden = golden and golden:gsub("%s+$", "") + T.eq(actual, golden, "the vanilla link fingerprint matches the committed golden") +end + +-- the digest must not depend on anything but the data: recomputing it has +-- to give the same answer, or two builds of the same commit disagree +T.eq(Fingerprint.compute(Data, {}), actual, "the vanilla fingerprint is stable") + +-- a link-affecting mod must move it, which is what makes a modded peer +-- detectable at handshake time +T.neq(Fingerprint.compute(Data, { { id = "x", version = "1.0.0", affectsLink = true } }), + actual, "a link-affecting mod moves the vanilla fingerprint") + +run.release() + +T.finish("content_red_gate_fingerprint") diff --git a/tests/content_red/headless_loader.lua b/tests/content_red/headless_loader.lua new file mode 100644 index 00000000..b342bfee --- /dev/null +++ b/tests/content_red/headless_loader.lua @@ -0,0 +1,54 @@ +-- T3: the shipped example mod, loaded headlessly through the fs seam +-- (21-testing-and-ci acceptance: "loads mods/example_mew_starter under +-- plain Lua via the fs seam, asserts zero loader.errors, and asserts the +-- Mew override merged into Data.pokemon"). +-- +-- This is content-tier rather than SDK-tier because the mod overrides MEW +-- and refuses to load against a dataset that has no Mew -- it is a Red +-- content mod, so it is pinned against Red content. What it proves is the +-- seam: discovery, topo-sort, entry chunk and merge all running with no +-- love.filesystem anywhere, which was impossible before Loader took an +-- injectable fs. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local facts = require("tests.content_red.facts") + +local Data = require("src.core.Data") +Data:load() + +local example = facts.exampleMod + +-- the mod is loaded off the real directory through the io-backed +-- filesystem: no love.filesystem, no in-memory synthesis +local run = T.sdk.loadMod(example.path, { data = Data }) + +T.eq(#run.errors, 0, + "the example mod loads with zero errors (" .. tostring(run.errors[1]) .. ")") +T.check(run.mod ~= nil, "the loader discovered the example mod") +T.eq(run.mod and run.mod.manifest.id, example.id, "the manifest id is read off disk") +T.eq(run.mod and run.mod.state, "loaded", "the example mod reached the loaded state") + +-- the override reached Data +T.check(Data.pokemon[example.species] ~= nil, "the overridden species is present") +T.eq(Data.pokemon[example.species].spriteFront, example.frontSprite, + "the Mew front-sprite override merged into Data.pokemon") +T.eq(Data.pokemon[example.species].spriteBack, example.backSprite, + "the Mew back-sprite override merged into Data.pokemon") + +-- the sprite files the override points at actually exist, so the mod is +-- not merely registering a path into the void +for _, path in ipairs({ example.frontSprite, example.backSprite }) do + local handle = io.open(path, "rb") + T.check(handle ~= nil, "the overridden sprite exists on disk: " .. path) + if handle then handle:close() end +end + +-- an api=1 mod keeps working unchanged: the shipped example predates the +-- v2 manifest and must not need a rewrite +T.check(run.mod and run.mod.manifest.version ~= nil, "the manifest carries a version") + +run.release() + +T.finish("content_red_headless_loader") diff --git a/tests/engine/formulas.lua b/tests/engine/formulas.lua new file mode 100644 index 00000000..0a175223 --- /dev/null +++ b/tests/engine/formulas.lua @@ -0,0 +1,166 @@ +-- T2 engine-invariant tier (21-testing-and-ci "test taxonomy"): the +-- formulas and machinery, parameterized by whatever dataset is loaded. +-- +-- Nothing here names a Red value. Every assertion is a property that has +-- to hold for any dataset the engine can boot -- which is what lets it run +-- in CI against tests/fixture_data with no ROM, and lets a total +-- conversion keep the whole tier green. The pinned Red numbers live in +-- tests/content_red/ instead. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local data = T.fixtures.fresh() +local run = T.sdk.loadNone({ data = data }) +T.eq(#run.errors, 0, "the dataset under test loads with no mods and no errors") + +local Pokemon = require("src.pokemon.Pokemon") +local Growth = require("src.pokemon.Growth") +local TypeChart = require("src.battle.TypeChart") +local Damage = require("src.battle.Damage") + +TypeChart.load(data) + +local speciesIds = {} +for id in pairs(data.pokemon) do speciesIds[#speciesIds + 1] = id end +table.sort(speciesIds) +T.check(#speciesIds > 0, "the dataset has at least one species") + +-- ------- growth curves + +-- the curve is an inverse pair; a dataset that ships a curve the level +-- lookup cannot invert breaks every exp gain in the game +for _, id in ipairs(speciesIds) do + local def = data.pokemon[id] + local rate = def.growthRate + T.eq(Growth.expForLevel(rate, 1), 0, "level 1 costs no exp: " .. tostring(rate)) + + local previous = -1 + local monotonic = true + for level = 1, data.constants.levelCap do + local need = Growth.expForLevel(rate, level) + if need < previous then monotonic = false end + previous = need + end + T.check(monotonic, "exp requirement never decreases with level: " .. tostring(rate)) + + -- levelForExp is the inverse: standing exactly on a threshold reports + -- that level, one point short reports the one below + local mid = math.max(2, math.floor(data.constants.levelCap / 2)) + local atMid = Growth.expForLevel(rate, mid) + T.eq(Growth.levelForExp(rate, atMid), mid, + "levelForExp inverts expForLevel at a threshold: " .. tostring(rate)) + if atMid > 0 then + T.check(Growth.levelForExp(rate, atMid - 1) < mid, + "one exp short of a threshold is the level below: " .. tostring(rate)) + end +end + +-- ------- stats + +for _, id in ipairs(speciesIds) do + local low = Pokemon.new(data, id, 5) + local high = Pokemon.new(data, id, math.min(50, data.constants.levelCap)) + + T.check(low.stats.hp > 0, "a fresh mon has positive max HP: " .. id) + T.eq(low.hp, low.stats.hp, "a fresh mon starts at full HP: " .. id) + T.check(#low.moves > 0, "a fresh mon knows at least one move: " .. id) + T.check(#low.moves <= data.constants.moveMax, + "a fresh mon never exceeds the move cap: " .. id) + + for _, stat in ipairs({ "hp", "attack", "defense", "speed", "special" }) do + T.check(high.stats[stat] >= low.stats[stat], + ("%s never decreases with level: %s"):format(stat, id)) + end + + -- level is clamped to the dataset's cap, not to a literal 100 + local capped = Pokemon.new(data, id, data.constants.levelCap) + T.eq(capped.level, data.constants.levelCap, "a mon can reach the dataset's level cap: " .. id) +end + +-- ------- type chart + +-- categories come out of the loaded type records, not a hard-coded +-- physical/special split; this is the de-hard-coded seam from 07 +local typeIds = {} +for id in pairs(data.type_chart.types or {}) do typeIds[#typeIds + 1] = id end +table.sort(typeIds) +T.check(#typeIds > 0, "the dataset supplies type category records") + +for _, id in ipairs(typeIds) do + local category = TypeChart.category(id) + T.check(category == "physical" or category == "special", + ("every type declares a damage category: %s (%s)"):format(id, tostring(category))) + T.eq(Damage.isSpecial(id), category == "special", + "Damage.isSpecial agrees with the loaded type record: " .. id) +end + +-- every matchup the dataset declares is reachable through effectiveness, +-- and neutral is the default for an undeclared pair +for _, row in ipairs(data.type_chart.matchups or {}) do + local mult = TypeChart.effectiveness(row.attacker, { row.defender }) + T.eq(mult, row.multiplier, + ("declared matchup applies: %s vs %s"):format(row.attacker, row.defender)) +end + +do + local declared = {} + for _, row in ipairs(data.type_chart.matchups or {}) do + declared[row.attacker .. ">" .. row.defender] = true + end + local checkedNeutral = false + for _, attacker in ipairs(typeIds) do + for _, defender in ipairs(typeIds) do + if not declared[attacker .. ">" .. defender] and not checkedNeutral then + T.eq(TypeChart.effectiveness(attacker, { defender }), 10, + ("an undeclared matchup is neutral: %s vs %s"):format(attacker, defender)) + checkedNeutral = true + end + end + end + T.check(checkedNeutral, "the dataset has at least one undeclared (neutral) matchup") +end + +-- ------- damage + +do + local ruleset = { critIgnoresStages = true } + local attacker = Pokemon.new(data, speciesIds[1], 20) + local defender = Pokemon.new(data, speciesIds[#speciesIds], 20) + + local function battler(mon) + return { mon = mon, curStats = mon.stats, stages = {}, level = mon.level, + curTypes = data.pokemon[mon.species].types } + end + + local moveId = next(data.moves) + local move = data.moves[moveId] + + -- max roll is deterministic under a fixed rng, and damage is never zero + -- for a damaging move nor negative for any input + local dealt = Damage.compute(ruleset, battler(attacker), battler(defender), move, + { rng = T.rng.fixed(255), forceCrit = false }) + T.check(dealt >= 1, "a damaging move always deals at least 1: " .. moveId) + + local minRoll = Damage.compute(ruleset, battler(attacker), battler(defender), move, + { rng = T.rng.fixed(0), forceCrit = false }) + T.check(minRoll <= dealt, "the low damage roll never exceeds the high roll") + T.check(minRoll >= 1, "even the low roll deals at least 1") + + -- a crit is never weaker than the same non-crit roll + local crit = Damage.compute(ruleset, battler(attacker), battler(defender), move, + { rng = T.rng.fixed(255), forceCrit = true }) + T.check(crit >= dealt, "a critical hit never deals less than a normal hit") + + -- a zero-power move deals nothing regardless of the roll + local status = { id = "T_STATUS", power = 0, type = move.type, category = "status" } + local none, info = Damage.compute(ruleset, battler(attacker), battler(defender), status, + { rng = T.rng.fixed(255) }) + T.eq(none, 0, "a zero-power move deals no damage") + T.eq(info.typeMult, 10, "a zero-power move reports neutral effectiveness") +end + +run.release() + +T.finish("engine_formulas") diff --git a/tests/engine/gate_events.lua b/tests/engine/gate_events.lua new file mode 100644 index 00000000..d680973e --- /dev/null +++ b/tests/engine/gate_events.lua @@ -0,0 +1,77 @@ +-- No-mod parity gate for every event in the catalog (21-testing-and-ci +-- "parity gate for every extension point", constraint 2). +-- +-- An event is a broadcast, so its parity claim is weaker than a hook's but +-- just as load-bearing: with nobody subscribed, emit must do nothing, +-- allocate nothing, and above all not change the engine path that emitted +-- it. The hot-path guard Runtime.wants must agree that nobody is +-- listening, or the guarded call sites build payloads for no reason. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Catalog = T.catalog +local Runtime = require("src.mods.Runtime") +local Events = require("src.mods.Events") + +local events = Catalog.events() +T.check(#events > 0, "the event catalog is non-empty") + +-- 1. the null object -- the pre-loader state every headless tool runs in +for _, name in ipairs(events) do + local ok = pcall(Runtime.emit, name, { probe = true }) + T.check(ok, "null events swallow the emit: " .. name) + T.eq(Runtime.wants(name), false, "null events report no listener: " .. name) +end + +-- 2. a live but unsubscribed bus -- the mod-free boot +local bus = Events.new() +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +Runtime.events = bus + +for _, name in ipairs(events) do + Runtime.emit(name, { probe = true }) + T.eq(bus.listeners[name], nil, "an unsubscribed emit allocates no list: " .. name) + T.eq(Runtime.wants(name), false, "wants is false with no listener: " .. name) +end + +-- 3. subscribe/unsubscribe returns delivery to the mod-free state, which +-- is what makes entry-chunk rollback and mod disable a true no-op. +-- Note the residue check is on the list contents, not the key: the +-- unsubscribe closure empties the list but leaves the (empty) table, and +-- Runtime.wants keys off the table's existence -- see followUps. +for _, name in ipairs(events) do + local seen = 0 + local unsubscribe = bus:on(name, function() seen = seen + 1 end, 0, "gate") + Runtime.emit(name, { probe = true }) + T.eq(seen, 1, "a listener receives its event: " .. name) + unsubscribe() + Runtime.emit(name, { probe = true }) + T.eq(seen, 1, "an unsubscribed listener stops receiving: " .. name) + T.eq(#(bus.listeners[name] or {}), 0, "unsubscribe drains the listener list: " .. name) +end + +-- removeOwner is the rollback path; it must clear as completely as the +-- per-listener closure does +for _, name in ipairs(events) do + bus:on(name, function() end, 0, "rollback_mod") +end +bus:removeOwner("rollback_mod") +local residue = 0 +for _ in pairs(bus.listeners) do residue = residue + 1 end +T.eq(residue, 0, "removeOwner returns the bus to the mod-free state") + +-- a throwing listener is contained: the emitting engine path completes and +-- the error never reaches the call site +local reached = false +bus:on(events[1], function() error("listener exploded", 0) end, 0, "bad_mod") +bus:on(events[1], function() reached = true end, 0, "good_mod") +local ok = pcall(Runtime.emit, events[1], {}) +T.check(ok, "a throwing listener does not propagate to the emitter") +T.check(reached, "a throwing listener does not stop its siblings") +bus:removeOwner("bad_mod") +bus:removeOwner("good_mod") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks + +T.finish("gate_events") diff --git a/tests/engine/gate_fingerprint.lua b/tests/engine/gate_fingerprint.lua new file mode 100644 index 00000000..73199601 --- /dev/null +++ b/tests/engine/gate_fingerprint.lua @@ -0,0 +1,128 @@ +-- The fingerprint gate (21-testing-and-ci "the fingerprint gate"). +-- +-- The link fingerprint is a deterministic digest of the link surface -- +-- species, moves, type_chart, statuses, move_effects, constants, link +-- fields. Hashing it over the fixture dataset with no mods and pinning +-- the result catches the failure mode nothing else does: an accidental +-- edit to a *built-in* registry record. That would not fail a schema +-- check, would not fail a no-mod parity gate that only compares data to +-- itself, and would silently make two builds of the same engine refuse to +-- link. Here it flips one hex string and fails. +-- +-- Regenerate deliberately with scripts/test.sh --bless after recording the +-- intended parity change; never automatically. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Fingerprint = require("src.link.Fingerprint") + +local GOLDEN = "tests/goldens/fixture_fingerprint.txt" + +local function readGolden(path) + local handle = io.open(path, "r") + if not handle then return nil end + local value = handle:read("*l") + handle:close() + return value and value:gsub("%s+$", "") +end + +local data = T.fixtures.fresh() +local run = T.sdk.loadNone({ data = data }) +T.eq(#run.errors, 0, "the fixture dataset loads with no mods and no errors") + +local actual = Fingerprint.compute(data, {}) +local golden = readGolden(GOLDEN) + +T.check(golden ~= nil, "the committed fixture fingerprint golden exists: " .. GOLDEN) +T.eq(actual, golden, "fixture fingerprint matches the committed golden") + +-- determinism: same data, same digest. A fingerprint that folded a table +-- address or a pairs() order would pass once and fail in CI. +T.eq(Fingerprint.compute(data, {}), actual, "the fingerprint is stable within a process") + +local second = T.fixtures.fresh() +local secondRun = T.sdk.loadNone({ data = second }) +T.eq(Fingerprint.compute(second, {}), actual, + "a freshly built fixture dataset digests identically") +secondRun.release() + +-- the mutation test the plan asks be verified in review, run instead: a +-- changed built-in record MUST move the hash. If any of these pass +-- unchanged the gate is decorative. +local mutations = { + { + name = "a species base stat", + apply = function(d) d.pokemon.FIXMON_A.baseStats.attack = + d.pokemon.FIXMON_A.baseStats.attack + 1 end, + }, + { + name = "a move's power", + apply = function(d) d.moves.FIX_TACKLE.power = d.moves.FIX_TACKLE.power + 1 end, + }, + { + name = "a move's type", + apply = function(d) d.moves.FIX_TACKLE.type = "FIRE" end, + }, + { + name = "a type-chart matchup", + apply = function(d) d.type_chart.matchups[1].multiplier = 40 end, + }, + { + -- replaced, not edited in place: Builtins hands the same record table + -- to every dataset it merges into, so mutating one of its fields would + -- corrupt the other datasets in this process (see followUps) + name = "a built-in type category", + apply = function(d) + local types = d.type_chart.types + if types and types.NORMAL then + types.NORMAL = { name = "NORMAL", category = "special" } + end + end, + }, + { + name = "a built-in status record", + apply = function(d) + local id = next(d.statuses or {}) + if id then d.statuses[id] = { mutated = true } end + end, + }, + { + name = "a built-in move effect", + apply = function(d) + local id = next(d.move_effects or {}) + if id then d.move_effects[id] = { mutated = true } end + end, + }, + { + name = "a link-surface constant", + apply = function(d) d.constants.levelCap = d.constants.levelCap - 1 end, + }, +} + +for _, mutation in ipairs(mutations) do + local mutated = T.fixtures.fresh() + local mutatedRun = T.sdk.loadNone({ data = mutated }) + mutation.apply(mutated) + T.neq(Fingerprint.compute(mutated, {}), actual, + "mutating " .. mutation.name .. " moves the fingerprint") + mutatedRun.release() +end + +-- no mutation above leaked into this dataset; if one did, every assertion +-- after it would be measuring a corrupted baseline +T.eq(Fingerprint.compute(data, {}), actual, + "the mutation cases left the gate's own dataset untouched") + +-- a mod in the hello moves the digest too, which is what makes a one-sided +-- install detectable at handshake time rather than at desync time +T.neq(Fingerprint.compute(data, { { id = "gate_mod", version = "1.0.0", affectsLink = true } }), + actual, "a link-affecting mod in the hello moves the fingerprint") + +-- ...and a mod that declares it does not affect link must not +T.eq(Fingerprint.compute(data, { { id = "cosmetic", version = "1.0.0", affectsLink = false } }), + actual, "a mod that does not affect link leaves the fingerprint alone") + +run.release() + +T.finish("gate_fingerprint") diff --git a/tests/engine/gate_hooks.lua b/tests/engine/gate_hooks.lua new file mode 100644 index 00000000..0c9b3927 --- /dev/null +++ b/tests/engine/gate_hooks.lua @@ -0,0 +1,79 @@ +-- No-mod parity gate for every hook in the catalog (21-testing-and-ci +-- "parity gate for every extension point", constraint 2). +-- +-- The claim under test is the one the whole mod API rests on: a hook with +-- nothing wrapped around it returns exactly what the vanilla function +-- returned, having called it exactly once. This walks the live catalog +-- (tests/modkit/catalog scans the source for Runtime.call sites), so a +-- hook added tomorrow is gated tomorrow without editing a list here. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Catalog = T.catalog +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") + +local hooks = Catalog.hooks() +T.check(#hooks > 0, "the hook catalog is non-empty") + +-- a value of each shape a hook actually carries, so "unchanged" is tested +-- against tables and multiple returns, not just a number +local SENTINEL = { tag = "vanilla-result" } + +-- 1. the null object: this is the state of the process before any loader +-- exists, and every headless tool and test runs in it +for _, name in ipairs(hooks) do + local calls = 0 + local got = Runtime.call(name, function(a, b) + calls = calls + 1 + return a + b + end, 2, 3) + T.eq(got, 5, "null hooks pass through: " .. name) + T.eq(calls, 1, "null hooks run vanilla exactly once: " .. name) +end + +-- 2. a live but unsubscribed bus: the state after a mod-free boot, where +-- Loader:load has installed real Events/Hooks that nobody wrapped +local bus = Hooks.new() +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +Runtime.hooks = bus + +for _, name in ipairs(hooks) do + local calls = 0 + local got = Runtime.call(name, function(value) + calls = calls + 1 + return value + end, SENTINEL) + T.check(rawequal(got, SENTINEL), "empty chain returns the identical table: " .. name) + T.eq(calls, 1, "empty chain runs vanilla exactly once: " .. name) + T.eq(bus.chains[name], nil, "empty chain allocates nothing for: " .. name) +end + +-- multiple returns survive an empty chain (the trailing-nil case is how a +-- "return value, reason" hook silently loses its reason) +for _, name in ipairs(hooks) do + local a, b, c = Runtime.call(name, function() return 1, nil, "three" end) + T.check(a == 1 and b == nil and c == "three", + "empty chain preserves multiple returns: " .. name) +end + +-- varargs in, varargs through +local n = select("#", Runtime.call(hooks[1], function(...) return ... end, 1, nil, 3)) +T.eq(n, 3, "empty chain preserves argument count including nil holes") + +-- an error from vanilla propagates unwrapped rather than being swallowed +-- or re-raised as a hook failure +T.raises(function() + Runtime.call(hooks[1], function() error("vanilla exploded", 0) end) +end, "vanilla exploded", "empty chain propagates a vanilla error verbatim") + +-- 3. wants-guard parity: nothing is subscribed, so every hot path that +-- guards on wantsHook must skip its ctx construction +for _, name in ipairs(hooks) do + T.eq(Runtime.wantsHook(name), false, "wantsHook is false with no chain: " .. name) +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks + +T.finish("gate_hooks") diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua new file mode 100644 index 00000000..0646247d --- /dev/null +++ b/tests/engine/gate_meta_coverage.lua @@ -0,0 +1,178 @@ +-- The parity-guarantee meta-test (21-testing-and-ci "parity gate for every +-- extension point"; 26 M14 "the suite tests itself"). +-- +-- The rule D14 states is that every extension point ships three artifacts +-- in the same change: a unit test through the public mod API, a no-mod +-- parity test, and docs. This file is what makes that a gate instead of a +-- convention. +-- +-- Two directions are enforced: +-- +-- parity -- structural. gate_hooks/gate_events/gate_registries each +-- walk the live catalog rather than a hand-kept list, so +-- every seam is parity-gated the moment its call site exists. +-- This file asserts those gates really do iterate the +-- catalog, which is the property that makes the coverage +-- automatic. +-- +-- unit -- a ratchet. A seam is "covered" when some test names it. +-- Seams that predate this gate are listed in DEBT below with +-- the milestone that owes them. A seam missing from both the +-- corpus and DEBT fails -- that is the gate on new work. A +-- seam in DEBT that has since been covered ALSO fails, so the +-- ledger cannot rot into a permanent excuse; it only shrinks. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Catalog = T.catalog + +-- ------- parity side + +local PARITY_GATES = { + { kind = "hooks", file = "tests/engine/gate_hooks.lua", accessor = "Catalog.hooks()" }, + { kind = "events", file = "tests/engine/gate_events.lua", accessor = "Catalog.events()" }, + { kind = "registries", file = "tests/engine/gate_registries.lua", + accessor = "T.catalog.registries()" }, +} + +local function slurp(path) + local handle = io.open(path, "r") + if not handle then return nil end + local body = handle:read("*a") + handle:close() + return body +end + +for _, gate in ipairs(PARITY_GATES) do + local body = slurp(gate.file) + T.check(body ~= nil, "a no-mod parity gate exists for " .. gate.kind .. ": " .. gate.file) + if body then + -- the gate must derive its subjects from the catalog; a gate that + -- inlined its own list would silently stop covering new seams + T.check(body:find(gate.accessor, 1, true) ~= nil, + ("the %s gate walks the live catalog (%s)"):format(gate.kind, gate.accessor)) + T.check(body:find("T.finish", 1, true) ~= nil, + "the " .. gate.kind .. " gate reports through the shared harness") + end +end + +-- ------- unit side + +-- every file that can hold a seam's unit test: the mod-API suites, the +-- engine tier, the SDK cases, and any tests a shipped mod carries +local function testCorpus() + local files, bodies = {}, {} + local pipe = io.popen( + "ls tests/*.lua tests/engine/*.lua tests/modkit/cases/*.lua mods/*/tests/*.lua 2>/dev/null") + if pipe then + for line in pipe:lines() do + if line ~= "" then files[#files + 1] = line end + end + pipe:close() + end + for _, path in ipairs(files) do + bodies[path] = slurp(path) or "" + end + return bodies +end + +local corpus = testCorpus() +local corpusCount = 0 +for _ in pairs(corpus) do corpusCount = corpusCount + 1 end +T.check(corpusCount > 0, "the test corpus is non-empty") + +-- a seam is covered when a test names it in quotes -- registrations and +-- subscriptions are written too many ways (literal, table-driven loop, +-- built-up string) for a syntactic match to be reliable, but a test that +-- exercises a seam always names it +local function coveredBy(name) + local needle = '"' .. name .. '"' + for path, body in pairs(corpus) do + if body:find(needle, 1, true) then return path end + end + return nil +end + +-- Coverage debt inherited from the milestones that introduced these seams +-- (M14 adds the gate; it does not retro-fit other milestones' unit tests). +-- Removing a name from this list is the only way to close its entry, and +-- the staleness check below forces that the moment a test lands. +local DEBT = { + -- M6 audio: the registry is exercised through cries/music/sfx, never by + -- the aggregate `audio` name + ["registry:audio"] = "M6", + -- M12 link: declared for the extra-bag negotiation, no case names it yet + ["registry:link_fields"] = "M12", + + ["hook:encounter.fishing"] = "M5", + ["hook:render.zones"] = "M9", + ["hook:trainer.party"] = "M7", + ["hook:ui.pc.items"] = "M8", + + ["event:link.connected"] = "M12", + ["event:link.ended"] = "M12", + ["event:player.warped"] = "M5", + ["event:pokemon.before_give"] = "M7", + ["event:pokemon.evolved"] = "M7", + ["event:pokemon.level_up"] = "M7", + ["event:pokemon.move_learned"] = "M7", + ["event:save.loaded"] = "M11", + ["event:save.loading"] = "M11", + ["event:save.writing"] = "M11", + ["event:trade.completed"] = "M12", + ["event:world.blacked_out"] = "M5", + ["event:world.boulder_moved"] = "M5", + ["event:world.interacted"] = "M5", + ["event:world.npc_spawned"] = "M5", + ["event:world.trainer_engaged"] = "M5", +} + +local seen = {} + +local function requireUnitTest(kind, name) + local key = kind .. ":" .. name + seen[key] = true + local where = coveredBy(name) + if DEBT[key] then + -- the ratchet: a debt entry that is now covered must be deleted, or + -- the ledger drifts into fiction + T.check(where == nil, + ("%s is covered by %s -- remove the DEBT entry %s (owed by %s)") + :format(key, tostring(where), key, DEBT[key])) + return + end + T.check(where ~= nil, + ("%s has no unit test naming it through the public mod API " .. + "(add one, or add a DEBT entry saying which milestone owes it)"):format(key)) +end + +for _, name in ipairs(Catalog.registries()) do requireUnitTest("registry", name) end +for _, name in ipairs(Catalog.hooks()) do requireUnitTest("hook", name) end +for _, name in ipairs(Catalog.events()) do + if not Catalog.isModEvent(name) then requireUnitTest("event", name) end +end + +-- a DEBT key for a seam that no longer exists is dead weight; drop it with +-- the seam so the ledger stays a description of the present +for key, owed in pairs(DEBT) do + T.check(seen[key], + ("DEBT lists %s (owed by %s) but no such seam is in the catalog -- remove it") + :format(key, owed)) +end + +-- ------- docs side + +-- the third artifact. The generated reference is what keeps registry docs +-- from drifting, so assert the generator exists and covers the catalog +-- rather than diffing prose. +do + local generator = slurp("tools/gen_registry_docs.lua") + T.check(generator ~= nil, "the registry doc generator exists") + if generator then + T.check(generator:find("REGISTRIES", 1, true) ~= nil, + "the doc generator renders from Schemas.REGISTRIES, not a hand-kept list") + end +end + +T.finish("gate_meta_coverage") diff --git a/tests/engine/gate_registries.lua b/tests/engine/gate_registries.lua new file mode 100644 index 00000000..555ace32 --- /dev/null +++ b/tests/engine/gate_registries.lua @@ -0,0 +1,147 @@ +-- No-mod parity gate for the registry catalog (21-testing-and-ci "parity +-- gate for every extension point", constraint 2). +-- +-- De-hard-coding a literal into a registry is the single riskiest move in +-- the whole plan: the value a consumer reads has to come out of the +-- registry byte-identical to the literal it replaced. The gate that +-- proves it is this one -- load zero mods over a dataset and assert that +-- every record the dataset already had survives untouched, and that the +-- namespaces which appear contain nothing but engine-owned records. +-- +-- Namespaces DO appear (statuses, move_effects, growth_rates, balls, ...): +-- those are the engine's own vanilla records for rules that used to be +-- inline, which is the point. What must never happen is a base record +-- changing value. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Schemas = require("src.mods.Schemas") +local Registry = require("src.mods.Registry") + +-- stable structural render, so "unchanged" compares by value and is +-- readable when it fails +local function snapshot(value, seen) + if type(value) ~= "table" then return tostring(value) end + seen = seen or {} + if seen[value] then return "" end + local nested = {} + for k in pairs(seen) do nested[k] = true end + nested[value] = true + local keys = {} + for key in pairs(value) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + local out = {} + for _, key in ipairs(keys) do + out[#out + 1] = tostring(key) .. "=" .. snapshot(value[key], nested) + end + return "{" .. table.concat(out, ",") .. "}" +end + +local registries = T.catalog.registries() +T.check(#registries > 0, "the registry catalog is non-empty") + +-- 1. every declared registry is instantiated on a fresh loader +local data = T.fixtures.fresh() +local before = {} +for key, value in pairs(data) do before[key] = snapshot(value) end + +local run = T.sdk.loadNone({ data = data }) +T.eq(#run.errors, 0, "a zero-mod load reports no errors") + +for _, name in ipairs(registries) do + local registry = run.loader.content[name] + T.check(registry ~= nil, "registry is instantiated: " .. name) + if registry then + T.eq(registry.spec, Schemas.REGISTRIES[name], "registry carries its catalog spec: " .. name) + end +end + +-- 2. the parity claim: nothing the dataset already held changed value. +-- Added keys are legal and expected (the engine's own records for rules +-- that used to be literals -- type_chart.types is the type-category list +-- lifted out of Damage.lua); a key that existed before and now reads +-- differently is the parity break this gate exists to catch. +local function assertUnchanged(oldValue, newValue, path) + if type(oldValue) ~= "table" then + T.eq(snapshot(newValue), snapshot(oldValue), + "zero-mod load leaves base data unchanged: " .. path) + return + end + if type(newValue) ~= "table" then + T.check(false, "zero-mod load replaced a table with a scalar: " .. path) + return + end + for key, value in pairs(oldValue) do + assertUnchanged(value, newValue[key], path .. "." .. tostring(key)) + end +end + +local pristine = T.fixtures.fresh() +for key in pairs(before) do + assertUnchanged(pristine[key], data[key], key) +end + +-- 3. everything a zero-mod load wrote is engine-owned. A record under any +-- other owner after loading no mods would mean the merge invented one. +for _, name in ipairs(registries) do + local registry = run.loader.content[name] + local foreign = {} + for id, list in pairs((registry and registry.ops) or {}) do + for _, entry in ipairs(list) do + if entry.owner and entry.owner ~= Schemas.ENGINE then + foreign[#foreign + 1] = tostring(id) .. "@" .. tostring(entry.owner) + end + end + end + T.eq(#foreign, 0, + ("no non-engine record after a zero-mod load: %s (%s)"):format(name, table.concat(foreign, ","))) +end + +-- 4. a registry nobody wrote to leaves its target absent rather than +-- materializing an empty namespace, so the shape of Data still reflects +-- what actually has content behind it +for _, name in ipairs(registries) do + local registry = run.loader.content[name] + local spec = Schemas.REGISTRIES[name] + if registry and spec and spec.target and next(registry.ops) == nil then + local node, missing = data, false + for part in spec.target:gmatch("[^%.]+") do + if type(node) ~= "table" or node[part] == nil then missing = true break end + node = node[part] + end + T.check(missing or before[spec.target:match("^[^%.]+")] ~= nil, + "an unwritten registry materializes no namespace: " .. name) + end +end + +run.release() + +-- 5. Schemas.check takes the spec FIRST; called with the wrong arity it +-- silently returns true, so a suite that gets this backwards validates +-- nothing. Pin the signature here rather than discovering it per-suite. +do + local spec = Schemas.REGISTRIES.pokemon + T.check(spec ~= nil, "the pokemon registry has a spec") + + -- validate a record the dataset already ships: hand-rolling one here + -- would only test whatever fields this file happened to remember + local ok = Schemas.check(spec, "pokemon", "FIXMON_A", pristine.pokemon.FIXMON_A, "register") + T.check(ok, "a shipped fixture record validates against its own schema") + + local bad = Schemas.check(spec, "pokemon", "FIXMON_A", { id = 42 }, "register") + T.check(not bad, "a record with a wrong-typed field fails validation") + + -- the arity trap: spec-first is the signature, and calling it the other + -- way round returns true for a record that just failed + local reversed = Schemas.check("pokemon", "FIXMON_A", { id = 42 }, "register") + T.check(reversed, "the wrong arity silently passes -- spec must come first") +end + +-- 6. the tombstone sentinel is the documented one; a suite that invents +-- its own DELETE would silently write a literal table into the data +T.check(Registry.DELETE ~= nil, "Registry exposes the DELETE tombstone") +T.eq(Registry.DELETE, require("src.mods.Merge").DELETE, + "Registry.DELETE is Merge.DELETE, not a private copy") + +T.finish("gate_registries") diff --git a/tests/fixture_data/assets/fix_font.png b/tests/fixture_data/assets/fix_font.png new file mode 100644 index 00000000..7d97fa15 Binary files /dev/null and b/tests/fixture_data/assets/fix_font.png differ diff --git a/tests/fixture_data/assets/fix_out.png b/tests/fixture_data/assets/fix_out.png new file mode 100644 index 00000000..f2cd4fcb Binary files /dev/null and b/tests/fixture_data/assets/fix_out.png differ diff --git a/tests/fixture_data/assets/fix_sprite.png b/tests/fixture_data/assets/fix_sprite.png new file mode 100644 index 00000000..0bae9b53 Binary files /dev/null and b/tests/fixture_data/assets/fix_sprite.png differ diff --git a/tests/fixture_data/assets/fixmon_a_back.png b/tests/fixture_data/assets/fixmon_a_back.png new file mode 100644 index 00000000..194cb75b Binary files /dev/null and b/tests/fixture_data/assets/fixmon_a_back.png differ diff --git a/tests/fixture_data/assets/fixmon_a_front.png b/tests/fixture_data/assets/fixmon_a_front.png new file mode 100644 index 00000000..e15af9a8 Binary files /dev/null and b/tests/fixture_data/assets/fixmon_a_front.png differ diff --git a/tests/fixture_data/assets/fixmon_b_back.png b/tests/fixture_data/assets/fixmon_b_back.png new file mode 100644 index 00000000..adc494d1 Binary files /dev/null and b/tests/fixture_data/assets/fixmon_b_back.png differ diff --git a/tests/fixture_data/assets/fixmon_b_front.png b/tests/fixture_data/assets/fixmon_b_front.png new file mode 100644 index 00000000..2d1d2a17 Binary files /dev/null and b/tests/fixture_data/assets/fixmon_b_front.png differ diff --git a/tests/fixture_data/assets/fixmon_c_back.png b/tests/fixture_data/assets/fixmon_c_back.png new file mode 100644 index 00000000..bd56da92 Binary files /dev/null and b/tests/fixture_data/assets/fixmon_c_back.png differ diff --git a/tests/fixture_data/assets/fixmon_c_front.png b/tests/fixture_data/assets/fixmon_c_front.png new file mode 100644 index 00000000..c56b70e8 Binary files /dev/null and b/tests/fixture_data/assets/fixmon_c_front.png differ diff --git a/tests/fixture_data/battle_anims.lua b/tests/fixture_data/battle_anims.lua new file mode 100644 index 00000000..bae58d08 --- /dev/null +++ b/tests/fixture_data/battle_anims.lua @@ -0,0 +1,14 @@ +-- one no-op move anim per fixture move so anim lookups resolve +local none = { seq = {}, source = "fixture" } + +return { + moveAnims = { + FIX_TACKLE = none, + FIX_SCRATCH = none, + FIX_EMBERISH = none, + FIX_CUT = none, + }, + subanims = {}, + tilesheets = {}, + baseCoords = {}, +} diff --git a/tests/fixture_data/constants.lua b/tests/fixture_data/constants.lua new file mode 100644 index 00000000..f9062cb4 --- /dev/null +++ b/tests/fixture_data/constants.lua @@ -0,0 +1,18 @@ +-- Data.constants: the hard limits, in the shapes Data.seedDefaults seeds +return { + bagSize = 20, + partyMax = 6, + boxCount = 2, + boxSize = 20, + moveMax = 4, + dexSize = 3, + dexDigits = 3, + levelCap = 100, + coinCap = 9999, + fallbackMove = "FIX_TACKLE", + badges = { + { id = "FIX_BADGE_1" }, + { id = "FIX_BADGE_2" }, + }, + hmMoves = { "FIX_CUT" }, +} diff --git a/tests/fixture_data/encounters.lua b/tests/fixture_data/encounters.lua new file mode 100644 index 00000000..76481fcb --- /dev/null +++ b/tests/fixture_data/encounters.lua @@ -0,0 +1,11 @@ +return { + FIX_ROUTE = { + grass = { + rate = 25, + slots = { + { level = 3, species = "FIXMON_A" }, + { level = 4, species = "FIXMON_C" }, + }, + }, + }, +} diff --git a/tests/fixture_data/field.lua b/tests/fixture_data/field.lua new file mode 100644 index 00000000..ee9a6a29 --- /dev/null +++ b/tests/fixture_data/field.lua @@ -0,0 +1,17 @@ +return { + ledges = {}, + hiddenItems = {}, + flyOrder = { "FIX_TOWN" }, + townMap = { + locations = { + FIX_TOWN = { x = 4, y = 4, name = "FIX TOWN" }, + FIX_ROUTE = { x = 4, y = 3, name = "FIX ROUTE" }, + }, + }, + boot = { + startMap = "FIX_TOWN", startX = 5, startY = 6, startFacing = "down", + playerName = "FIX", rivalName = "RIV", + startMoney = 3000, + lastHeal = { map = "FIX_TOWN", x = 5, y = 6 }, + }, +} diff --git a/tests/fixture_data/font.lua b/tests/fixture_data/font.lua new file mode 100644 index 00000000..297aae1e --- /dev/null +++ b/tests/fixture_data/font.lua @@ -0,0 +1,16 @@ +-- one 16x8-glyph page starting at $80 plus the ASCII charmap rows the +-- tests print with; the placeholder sheet is 4-shade grayscale +local charmap = {} +-- A-Z at $80.., 0-9 at $F6.., space at $7F like the vanilla map +for i = 0, 25 do + charmap[#charmap + 1] = { code = 0x80 + i, seq = string.char(65 + i) } +end +for i = 0, 9 do + charmap[#charmap + 1] = { code = 0xF6 + i, seq = string.char(48 + i) } +end +charmap[#charmap + 1] = { code = 0x7F, seq = " " } + +return { + image = "tests/fixture_data/assets/fix_font.png", + charmap = charmap, +} diff --git a/tests/fixture_data/init.lua b/tests/fixture_data/init.lua new file mode 100644 index 00000000..a7d22d9a --- /dev/null +++ b/tests/fixture_data/init.lua @@ -0,0 +1,25 @@ +-- ROM-free base dataset (21-testing-and-ci): a tiny hand-written stand-in +-- for data/generated/* so the mod loader, the modkit SDK tests and CI run +-- with no ROM present. load() assembles a fresh Data-shaped table each +-- call -- modules are re-required so one test's merge never leaks into the +-- next. Test-only: nothing in a shipped build ever reads this tree. + +local M = {} + +M.MODULES = { + "constants", "maps", "tilesets", "text", "text_pointers", + "trainer_headers", "font", "sprites", "pokemon", "moves", "items", + "type_chart", "trainers", "encounters", "field", "battle_anims", +} + +function M.load() + local data = {} + for _, name in ipairs(M.MODULES) do + local key = "tests.fixture_data." .. name + package.loaded[key] = nil + data[name] = require(key) + end + return data +end + +return M diff --git a/tests/fixture_data/items.lua b/tests/fixture_data/items.lua new file mode 100644 index 00000000..cd4f36a6 --- /dev/null +++ b/tests/fixture_data/items.lua @@ -0,0 +1,21 @@ +-- one of each item seam: heal effect, ball, machine, badge +return { + FIX_POTION = { + id = "FIX_POTION", index = 1, name = "FIX POTION", price = 300, + tossable = true, + }, + FIX_BALL = { + id = "FIX_BALL", index = 2, name = "FIX BALL", price = 200, + ball = "POKE_BALL", + }, + FIX_TM = { + id = "FIX_TM", index = 3, name = "FIX TM01", price = 3000, + machine = { kind = "TM", number = 1, move = "FIX_CUT" }, + }, + FIX_BADGE_1 = { + id = "FIX_BADGE_1", index = 4, name = "FIX BADGE 1", price = 0, + }, + FIX_BADGE_2 = { + id = "FIX_BADGE_2", index = 5, name = "FIX BADGE 2", price = 0, + }, +} diff --git a/tests/fixture_data/maps.lua b/tests/fixture_data/maps.lua new file mode 100644 index 00000000..d0abfc4b --- /dev/null +++ b/tests/fixture_data/maps.lua @@ -0,0 +1,54 @@ +-- FIX_TOWN (spawn, one warp, one NPC) and FIX_ROUTE (grass, one trainer) +local function flat(width, height, block) + local blocks = {} + for i = 1, width * height do blocks[i] = block end + return blocks +end + +local town = { + id = "FIX_TOWN", label = "FixTown", index = 1000, + tileset = "FIX_OUT", + width = 10, height = 9, + blocks = flat(10, 9, 1), + borderBlock = 0, + connections = { + north = { map = "FIX_ROUTE", offset = 0 }, + }, + warps = { + { x = 5, y = 5, destMap = "FIX_ROUTE", destWarp = 1 }, + }, + objects = { + { + index = 1, name = "FIXTOWN_GREETER", sprite = "SPRITE_FIX_NPC", + movement = "STAY", range = "NONE", + text = "TEXT_FIXTOWN_GREETER", x = 4, y = 4, + }, + }, + signs = { + { text = "TEXT_FIXTOWN_SIGN", x = 6, y = 6 }, + }, +} + +local route = { + id = "FIX_ROUTE", label = "FixRoute", index = 1001, + tileset = "FIX_OUT", + width = 10, height = 18, + blocks = flat(10, 18, 2), + borderBlock = 0, + connections = { + south = { map = "FIX_TOWN", offset = 0 }, + }, + warps = { + { x = 5, y = 1, destMap = "FIX_TOWN", destWarp = 1 }, + }, + objects = { + { + index = 1, name = "FIXROUTE_TRAINER", sprite = "SPRITE_FIX_NPC", + movement = "STAY", range = "NONE", + text = "TEXT_FIXROUTE_TRAINER", x = 5, y = 9, + }, + }, + signs = {}, +} + +return { FIX_TOWN = town, FIX_ROUTE = route } diff --git a/tests/fixture_data/moves.lua b/tests/fixture_data/moves.lua new file mode 100644 index 00000000..a43c046f --- /dev/null +++ b/tests/fixture_data/moves.lua @@ -0,0 +1,20 @@ +-- FIX_TACKLE doubles as constants.fallbackMove, the move-slot repair floor +return { + FIX_TACKLE = { + id = "FIX_TACKLE", index = 1, name = "FIX TACKLE", + type = "NORMAL", power = 40, accuracy = 100, pp = 35, effect = "NO_ADDITIONAL_EFFECT", + }, + FIX_SCRATCH = { + id = "FIX_SCRATCH", index = 2, name = "FIX SCRATCH", + type = "NORMAL", power = 40, accuracy = 100, pp = 35, effect = "NO_ADDITIONAL_EFFECT", + }, + FIX_EMBERISH = { + id = "FIX_EMBERISH", index = 3, name = "FIX EMBER", + type = "FIRE", power = 40, accuracy = 100, pp = 25, + effect = "BURN_SIDE_EFFECT1", + }, + FIX_CUT = { + id = "FIX_CUT", index = 4, name = "FIX CUT", + type = "NORMAL", power = 50, accuracy = 95, pp = 30, effect = "NO_ADDITIONAL_EFFECT", + }, +} diff --git a/tests/fixture_data/pokemon.lua b/tests/fixture_data/pokemon.lua new file mode 100644 index 00000000..ad036948 --- /dev/null +++ b/tests/fixture_data/pokemon.lua @@ -0,0 +1,54 @@ +-- three species covering the evolve/learnset/tmhm seams +return { + FIXMON_A = { + id = "FIXMON_A", index = 1, dex = 1, name = "FIXMON A", + types = { "GRASS" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 }, + catchRate = 45, baseExp = 64, + level1Moves = { "FIX_TACKLE" }, + growthRate = "MEDIUM_SLOW", + tmhm = { "FIX_CUT" }, + learnset = { + { level = 1, move = "FIX_TACKLE" }, + { level = 7, move = "FIX_EMBERISH" }, + }, + evolutions = { { method = "LEVEL", level = 16, species = "FIXMON_B" } }, + spriteFront = "tests/fixture_data/assets/fixmon_a_front.png", + spriteBack = "tests/fixture_data/assets/fixmon_a_back.png", + frontSize = 5, + dexEntry = { kind = "SEED", heightFt = 2, heightIn = 4, weight = 150, + text = "A fixture." }, + }, + FIXMON_B = { + id = "FIXMON_B", index = 2, dex = 2, name = "FIXMON B", + types = { "FIRE" }, + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, special = 60 }, + catchRate = 45, baseExp = 65, + level1Moves = { "FIX_SCRATCH" }, + growthRate = "MEDIUM_SLOW", + tmhm = {}, + learnset = { { level = 1, move = "FIX_SCRATCH" } }, + evolutions = {}, + spriteFront = "tests/fixture_data/assets/fixmon_b_front.png", + spriteBack = "tests/fixture_data/assets/fixmon_b_back.png", + frontSize = 5, + dexEntry = { kind = "FLAME", heightFt = 2, heightIn = 0, weight = 190, + text = "A fixture." }, + }, + FIXMON_C = { + id = "FIXMON_C", index = 3, dex = 3, name = "FIXMON C", + types = { "WATER" }, + baseStats = { hp = 44, attack = 48, defense = 65, speed = 43, special = 50 }, + catchRate = 45, baseExp = 66, + level1Moves = { "FIX_TACKLE" }, + growthRate = "MEDIUM_SLOW", + tmhm = { "FIX_CUT" }, + learnset = { { level = 1, move = "FIX_TACKLE" } }, + evolutions = {}, + spriteFront = "tests/fixture_data/assets/fixmon_c_front.png", + spriteBack = "tests/fixture_data/assets/fixmon_c_back.png", + frontSize = 5, + dexEntry = { kind = "TINY", heightFt = 1, heightIn = 8, weight = 200, + text = "A fixture." }, + }, +} diff --git a/tests/fixture_data/sprites.lua b/tests/fixture_data/sprites.lua new file mode 100644 index 00000000..0459ff56 --- /dev/null +++ b/tests/fixture_data/sprites.lua @@ -0,0 +1,14 @@ +return { + SPRITE_FIX_PLAYER = { + id = "SPRITE_FIX_PLAYER", + image = "tests/fixture_data/assets/fix_sprite.png", + frames = 6, + walker = true, + }, + SPRITE_FIX_NPC = { + id = "SPRITE_FIX_NPC", + image = "tests/fixture_data/assets/fix_sprite.png", + frames = 6, + walker = true, + }, +} diff --git a/tests/fixture_data/text.lua b/tests/fixture_data/text.lua new file mode 100644 index 00000000..6622b4a7 --- /dev/null +++ b/tests/fixture_data/text.lua @@ -0,0 +1,8 @@ +return { + _FixTownSignText = "FIX TOWN\nA fixture village.", + _FixTownGreeterText = "Welcome to the\nfixture dataset!", + _FixRouteTrainerBattleText = "Fixtures, fight!", + _FixRouteTrainerEndText = "Well fixed!", + _FixRouteTrainerAfterText = "Nice fixture.", + _FixMartText = "Fixture mart.", +} diff --git a/tests/fixture_data/text_pointers.lua b/tests/fixture_data/text_pointers.lua new file mode 100644 index 00000000..37ed8601 --- /dev/null +++ b/tests/fixture_data/text_pointers.lua @@ -0,0 +1,23 @@ +return { + FixTown = { + TEXT_FIXTOWN_SIGN = { + label = "FixTownSignText", + text = "_FixTownSignText", + }, + TEXT_FIXTOWN_GREETER = { + label = "FixTownGreeterText", + text = "_FixTownGreeterText", + }, + TEXT_FIXTOWN_MART = { + label = "FixTownMartText", + text = "_FixMartText", + mart = { "FIX_POTION", "FIX_BALL" }, + }, + }, + FixRoute = { + TEXT_FIXROUTE_TRAINER = { + label = "FixRouteTrainerBattleText", + text = "_FixRouteTrainerBattleText", + }, + }, +} diff --git a/tests/fixture_data/tilesets.lua b/tests/fixture_data/tilesets.lua new file mode 100644 index 00000000..7761b191 --- /dev/null +++ b/tests/fixture_data/tilesets.lua @@ -0,0 +1,19 @@ +-- one outdoor tileset; blocks are 16-tile rows like the generated cache +local function row(tile) + local out = {} + for i = 1, 16 do out[i] = tile end + return out +end + +return { + FIX_OUT = { + id = "FIX_OUT", + image = "tests/fixture_data/assets/fix_out.png", + blocks = { row(0), row(1), row(2), row(3) }, + walkable = { [0] = true, [1] = true, [2] = true }, + counterTiles = {}, + doorTiles = {}, + warpTiles = { 3 }, + grassTile = 2, + }, +} diff --git a/tests/fixture_data/trainer_headers.lua b/tests/fixture_data/trainer_headers.lua new file mode 100644 index 00000000..73e8ffa0 --- /dev/null +++ b/tests/fixture_data/trainer_headers.lua @@ -0,0 +1,13 @@ +return { + FixRoute = { + { + range = 2, + battle = "_FixRouteTrainerBattleText", + won = "_FixRouteTrainerEndText", + after = "_FixRouteTrainerAfterText", + event = "EVENT_BEAT_FIXROUTE_TRAINER_0", + opponent = "OPP_FIX_YOUNGSTER", + party = 1, + }, + }, +} diff --git a/tests/fixture_data/trainers.lua b/tests/fixture_data/trainers.lua new file mode 100644 index 00000000..76d724b0 --- /dev/null +++ b/tests/fixture_data/trainers.lua @@ -0,0 +1,12 @@ +return { + OPP_FIX_YOUNGSTER = { + id = "OPP_FIX_YOUNGSTER", index = 1, name = "FIX YOUNGSTER", + baseMoney = 15, + parties = { + { + { level = 5, species = "FIXMON_A" }, + { level = 5, species = "FIXMON_C" }, + }, + }, + }, +} diff --git a/tests/fixture_data/type_chart.lua b/tests/fixture_data/type_chart.lua new file mode 100644 index 00000000..615b8108 --- /dev/null +++ b/tests/fixture_data/type_chart.lua @@ -0,0 +1,12 @@ +-- the GRASS/FIRE/WATER triangle; type records themselves come from the +-- engine's own registrations (src/battle/TypeChart.TYPES) +return { + matchups = { + { attacker = "FIRE", defender = "GRASS", multiplier = 20 }, + { attacker = "GRASS", defender = "WATER", multiplier = 20 }, + { attacker = "WATER", defender = "FIRE", multiplier = 20 }, + { attacker = "FIRE", defender = "WATER", multiplier = 5 }, + { attacker = "WATER", defender = "GRASS", multiplier = 5 }, + { attacker = "GRASS", defender = "FIRE", multiplier = 5 }, + }, +} diff --git a/tests/fs_io.lua b/tests/fs_io.lua new file mode 100644 index 00000000..6a1d892e --- /dev/null +++ b/tests/fs_io.lua @@ -0,0 +1,79 @@ +-- io-backed filesystem for the headless Loader (21-testing-and-ci +-- "headless loader seam"). Loader.new takes opts.fs; under plain luajit +-- there is no love.filesystem, so this adapter reads a real mod directory +-- off disk and lets discovery/topo-sort/merge run with no love at all. +-- +-- Paths handed to the loader are repo-relative ("mods/example_mew_starter"), +-- the same strings love.filesystem would see, so a mod loaded through here +-- and one loaded in the game take identical code paths. + +local FsIo = {} + +-- shell-quote for the popen/os.execute probes; single quotes survive +-- spaces and the ' inside a name closes-escapes-reopens +local function quote(path) + return "'" .. tostring(path):gsub("'", "'\\''") .. "'" +end + +function FsIo.new(rootDir) + local base = rootDir or "." + + local function abs(path) + if path == nil or path == "" then return base end + return base .. "/" .. path + end + + local fs = {} + + function fs.read(path) + local handle = io.open(abs(path), "rb") + if not handle then return nil, "nofile" end + local body = handle:read("*a") + handle:close() + return body + end + + function fs.write(path, body) + local handle = io.open(abs(path), "wb") + if not handle then return false end + handle:write(body) + handle:close() + return true + end + + -- files answer without a shell; only the directory case pays for a probe + function fs.getInfo(path) + local handle = io.open(abs(path), "rb") + if handle then + local probe = handle:read(1) + handle:close() + -- a directory opens on some libc builds but reads nothing + if probe ~= nil then return { type = "file" } end + end + local ok = os.execute("test -d " .. quote(abs(path))) + if ok == true or ok == 0 then return { type = "directory" } end + if handle then return { type = "file" } end + return nil + end + + function fs.load(path) + return loadfile(abs(path)) + end + + function fs.getDirectoryItems(path) + local items = {} + local pipe = io.popen("ls -1 " .. quote(abs(path)) .. " 2>/dev/null") + if not pipe then return items end + for line in pipe:lines() do + if line ~= "" then items[#items + 1] = line end + end + pipe:close() + table.sort(items) + return items + end + + fs.root = base + return fs +end + +return FsIo diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt new file mode 100644 index 00000000..6b90a321 --- /dev/null +++ b/tests/goldens/fixture_fingerprint.txt @@ -0,0 +1 @@ +9820543f7a223fab diff --git a/tests/goldens/vanilla_fingerprint.txt b/tests/goldens/vanilla_fingerprint.txt new file mode 100644 index 00000000..41e86474 --- /dev/null +++ b/tests/goldens/vanilla_fingerprint.txt @@ -0,0 +1 @@ +54a52ea81751495c diff --git a/tests/harness.lua b/tests/harness.lua new file mode 100644 index 00000000..0056db34 --- /dev/null +++ b/tests/harness.lua @@ -0,0 +1,194 @@ +-- Shared test bootstrap (21-testing-and-ci D14). Every suite used to +-- re-implement check/eq and its own failure counter; this is the single +-- copy. Deliberately love-free so the T1 primitive suites keep proving +-- the engine's mod core runs with no love global at all -- tests/modkit +-- installs the stub for the tiers that need it. +-- +-- Two shapes, because the repo runs suites two ways: +-- +-- T.check/T.eq + T.finish() -- a suite that owns its process (the +-- tests/engine, tests/content_red and tests/modkit/cases tiers, which +-- tier_runner spawns one at a time). finish sets the exit code. +-- T.suite(label) -- a suite tests/run_tests.lua dofiles into +-- its own process (the mod_*/parity_* files). Scoped counters, and +-- finish raises rather than exiting so one bad suite is one FAIL line +-- in the parent instead of the end of the run. + +-- suites are dofile'd from the repo root, and requiring this file already +-- needed the prefix, so only add it once +if not package.path:find("./?/init.lua", 1, true) then + package.path = "./?.lua;./?/init.lua;" .. package.path +end + +local T = {} + +T.failures = 0 +T.checks = 0 +T.messages = {} + +-- the seed the behavior suite has always used; suites that inject their +-- own rolls still do, this only pins the ambient stream +T.SEED = 12345 +math.randomseed(T.SEED) + +-- quiet mode prints only failures, so a 1600-check parent run stays +-- readable; verbose is the historical per-check "ok" stream +T.VERBOSE_ENV = os.getenv("POKEPORT_TEST_VERBOSE") == "1" +T.verbose = T.VERBOSE_ENV + +-- One check is one line. Suites embed captured subprocess output in a +-- message (modkit_tests folds a whole `modkit lint` transcript into one), +-- and a bare newline there puts an unrelated "FAIL ..." at column 0 -- +-- which every line-oriented consumer of this output, scripts/test.sh +-- included, then counts as a failure of its own. +local function oneline(msg) + return (tostring(msg):gsub("%s*\n%s*", " ")) +end + +-- structural compare for payload/record assertions; nil-holes compare equal +local function deep(a, b) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + for k, v in pairs(a) do if not deep(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end + +-- Every assertion is expressed against one `check`, so the module-level +-- API and a scoped suite share these bodies instead of keeping two copies +-- that can drift. +local function assertions(check) + local A = { check = check } + + function A.eq(got, want, msg) + return check(got == want, + ("%s (got %s, want %s)"):format(tostring(msg), tostring(got), tostring(want))) + end + + function A.neq(got, want, msg) + return check(got ~= want, ("%s (got %s)"):format(tostring(msg), tostring(got))) + end + + function A.same(got, want, msg) + return check(deep(got, want), msg) + end + + -- the seam under test is supposed to raise; assert it did and that the + -- message names the reason, so a rename does not quietly pass + function A.raises(fn, fragment, msg) + local ok, err = pcall(fn) + if ok then return check(false, msg .. " (no error raised)") end + if fragment then + return check(tostring(err):find(fragment, 1, true) ~= nil, + ("%s (error was %s)"):format(msg, tostring(err))) + end + return check(true, msg) + end + + return A +end + +function T.check(cond, msg) + T.checks = T.checks + 1 + if cond then + if T.verbose then print("ok " .. oneline(msg)) end + else + T.failures = T.failures + 1 + T.messages[#T.messages + 1] = oneline(msg) + print("FAIL " .. oneline(msg)) + end + return cond and true or false +end + +do + local A = assertions(function(cond, msg) return T.check(cond, msg) end) + T.eq, T.neq, T.same, T.raises = A.eq, A.neq, A.same, A.raises +end + +-- deterministic roll injection, the idiom the current suites hand-roll as +-- `{ rng = function() return 255 end }` +T.rng = {} + +function T.rng.fixed(value) + return function() return value end +end + +function T.rng.seq(...) + local values, i = { ... }, 0 + return function() + i = i + 1 + return values[math.min(i, #values)] + end +end + +-- A scoped counter for the suites that run *inside* a parent's process. +-- tests/run_tests.lua dofiles the mod_*/parity_* files rather than +-- spawning one process each (tier_runner does that for the newer tiers), +-- so module-level counters would report the whole chained run as one +-- suite's total. A scoped suite counts only its own checks, and its +-- finish raises instead of exiting -- os.exit here would take the parent +-- down with it, which is why every chained file ended in `error(...)` +-- before there was a harness to share. +function T.suite(label) + local S = { label = label or "suite", failures = 0, checks = 0, messages = {} } + + -- A chained suite's stream is its own: the parent sets T.verbose for the + -- checks it makes itself, and inheriting that would bury its progress + -- under every assertion of twenty-odd child files. + S.verbose = T.VERBOSE_ENV + + -- deliberately does not touch the module-level counters: the parent that + -- dofiles this suite counts the suite as one check of its own, and + -- folding the child's assertions in too would report every failure twice + local function check(cond, msg) + S.checks = S.checks + 1 + if cond then + if S.verbose then print("ok " .. oneline(msg)) end + else + S.failures = S.failures + 1 + S.messages[#S.messages + 1] = oneline(msg) + print("FAIL " .. oneline(msg)) + end + return cond and true or false + end + + local A = assertions(check) + S.check, S.eq, S.neq = A.check, A.eq, A.neq + S.same, S.raises = A.same, A.raises + S.rng = T.rng + + function S.finish() + print(("%s: %d/%d checks passed"):format(S.label, S.checks - S.failures, S.checks)) + if S.failures > 0 then + error(("%d %s assertion(s) failed (first: %s)") + :format(S.failures, S.label, S.messages[1] or "?"), 0) + end + end + + return S +end + +-- POKEPORT_TEST_CHILD is the escape hatch for a parent that dofiles a +-- process-owning suite rather than spawning it: raise instead of exiting, +-- so the parent survives. T.suite is the better answer and nothing in the +-- repo sets this, but a mod's own runner may. +function T.finish(label) + local name = label or "suite" + if T.failures == 0 then + print(("%d/%d checks passed (%s)"):format(T.checks, T.checks, name)) + else + print(("%d/%d checks passed, %d FAILURES (%s)") + :format(T.checks - T.failures, T.checks, T.failures, name)) + end + if T.failures == 0 then + if not _G.POKEPORT_TEST_CHILD then os.exit(0) end + return + end + local first = T.messages[1] or "?" + if _G.POKEPORT_TEST_CHILD then + error(("%d failed (first: %s)"):format(T.failures, first), 0) + end + os.exit(1) +end + +return T diff --git a/tests/love_stub.lua b/tests/love_stub.lua index a2900981..2a7db360 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -63,11 +63,64 @@ stub.math = { stub.filesystem = { write = function(name, content) files[name] = content return true end, read = function(name) return files[name] end, - getInfo = function(name) return files[name] and { type = "file" } or nil end, + -- directories are implied by key prefixes ("mods/x/manifest.json") + getInfo = function(name) + if files[name] then return { type = "file" } end + local prefix = name .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, load = function(name) if not files[name] then return nil, "no file" end return load(files[name], name) end, + getDirectoryItems = function(name) + local seen, items = {}, {} + local prefix = name .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, +} + +-- table-backed SoundData so ChipAudio's offline render seam +-- (_renderMusicForTest) runs headless; modkit bounce writes WAVs from it +local SoundData = {} +SoundData.__index = SoundData +local function slot(self, index, channel) + return index * self.channels + (channel - 1) + 1 +end +function SoundData:setSample(index, a, b) + if b == nil then + self.data[slot(self, index, 1)] = a + else + self.data[slot(self, index, a)] = b + end +end +function SoundData:getSample(index, channel) + return self.data[slot(self, index, channel or 1)] or 0 +end +function SoundData:getSampleCount() return self.samples end +function SoundData:getSampleRate() return self.rate end +function SoundData:getBitDepth() return self.bits end +function SoundData:getChannelCount() return self.channels end +function SoundData:getDuration() return self.samples / self.rate end + +stub.sound = { + newSoundData = function(samples, rate, bits, channels) + return setmetatable({ samples = samples, rate = rate or 44100, + bits = bits or 16, channels = channels or 1, data = {} }, SoundData) + end, } stub.keyboard = { isDown = function() return false end } diff --git a/tests/mod_audio_tests.lua b/tests/mod_audio_tests.lua new file mode 100644 index 00000000..0c777729 --- /dev/null +++ b/tests/mod_audio_tests.lua @@ -0,0 +1,809 @@ +-- Audio modding (M9): per-definition shape dispatch in Music/Sound, failure +-- isolation instead of a latching global disable, the granular +-- sfx/cries/map_songs merge, the ChipAsm assembler and its def-local blob +-- mode in ChipAudio, the song-literal tables and their fallbacks, and the +-- music.select hook plus the audio events. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("mod audio") +local check = S.check + +-- ------- love audio stub +-- Records every source so the tests can assert on which branch built it; +-- string paths only resolve when listed in `assets`, which is how a broken +-- definition is simulated. + +local love = _G.love or {} +_G.love = love +local savedAudio, savedSound = love.audio, love.sound + +local assets = { + ["assets/theme.ogg"] = true, + ["assets/theme_loop.ogg"] = true, + ["assets/other.ogg"] = true, + ["assets/beep.wav"] = true, + ["assets/chime.ogg"] = true, + ["assets/cry.ogg"] = true, +} + +local sources = {} + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true self.plays = self.plays + 1 end +function Source:stop() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:pause() self.playing = false end +function Source:setLooping(value) self.looping = value end +function Source:setVolume(value) self.volume = value end +function Source:setPitch(value) self.pitch = value end +function Source:setFilter() end +function Source:getDuration() return 1 end +function Source:getFreeBufferCount() return self.free end +function Source:queue() self.free = math.max(0, self.free - 1) end + +local function newSource(what, mode) + if type(what) == "string" and not assets[what] then + error("could not open file " .. what, 0) + end + local src = setmetatable({ + file = what, mode = mode, plays = 0, free = 0, queueable = false, + }, Source) + sources[#sources + 1] = src + return src +end + +local SoundData = {} +SoundData.__index = SoundData +function SoundData:setSample(index, a, b) + self.samples[index] = b or a +end +function SoundData:getSample(index) return self.samples[index] or 0 end +function SoundData:getSampleCount() return self.count end + +love.audio = { + newSource = newSource, + newQueueableSource = function() + local src = setmetatable({ + plays = 0, free = 32, queueable = true, + }, Source) + sources[#sources + 1] = src + return src + end, +} +love.sound = { + newSoundData = function(count, rate, bits, channels) + return setmetatable({ + samples = {}, count = count, rate = rate, bits = bits, + channels = channels, + }, SoundData) + end, +} + +local function lastSource() + return sources[#sources] +end + +local function resetSources() + for index = #sources, 1, -1 do sources[index] = nil end +end + +local ChipAsm = require("src.audio.ChipAsm") +local ChipAudio = require("src.core.ChipAudio") +local Music = require("src.core.Music") +local Sound = require("src.core.Sound") +local Logger = require("src.core.Logger") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local savedErrors = Runtime.errors + +local function loggedCount(fragment) + local count = 0 + for _, line in ipairs(Logger.history) do + if line:find(fragment, 1, true) then count = count + 1 end + end + return count +end + +-- ------- ChipAsm: the encoding oracle +-- Byte-for-byte expectations read off the command table Channel:nextEvent +-- decodes, so a change in either one breaks this. + +local dslSong = ChipAsm.song{ + tempo = 0x100, + channels = { + { hw = 1, + program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 12, fade = 1 } }, + { octave = 4 }, + { label = "body" }, + { note = "C#", len = 4 }, + { rest = 2 }, + { vibrato = { delay = 6, depth = 3, rate = 4 } }, + { slide = { len = 2, octave = 4, note = "E" } }, + { call = "riff" }, + { loop = { count = 0, to = "body" } }, + }, + subroutines = { riff = { { note = "G", len = 2 }, { ret = true } } } }, + { hw = 4, program = { { drum = 3, len = 8 }, { loop = { count = 0, to = 1 } } } }, + }, +} + +local function hex(blob) + local out = {} + for index = 1, #blob do out[#out + 1] = ("%02X"):format(blob:byte(index)) end + return table.concat(out, " ") +end + +check(hex(dslSong.chip.blob) == + "ED 01 00 EC 02 DC C1 E4 13 C1 EA 06 34 EB 02 44 FD 17 40 FE 00 08 40 " .. + "71 FF 37 FE 00 19 40", + "ChipAsm encodes the documented program byte for byte") +check(dslSong.chip.channels[1].number == 1 + and dslSong.chip.channels[1].address == 0x4000, + "first channel is based at the 0x4000 window") +check(dslSong.chip.channels[2].number == 4 + and dslSong.chip.channels[2].address == 0x4019, + "the second channel starts after the first one's bytes") +check(dslSong.chip.engine == 1, "engine defaults to 1") + +-- an sfx program lives on channels 5-8 so the interpreter reads the effect +-- command set +local dslSfx = ChipAsm.sfx{ + channels = { + { hw = 1, program = { + { pitchSweep = { pace = 5, subtract = true, shift = 2 } }, + { squareNote = { len = 4, volume = 15, fade = 1, frequency = 0x123 } }, + } }, + { hw = 4, program = { + { noiseNote = { len = 2, volume = 15, fade = -1, parameter = 0x44 } }, + } }, + }, +} +check(hex(dslSfx.chip.blob) == "10 5A 23 F1 23 01 FF 21 F9 44 FF", + "ChipAsm.sfx encodes sweep, square and noise notes") +check(dslSfx.chip.channels[1].number == 5 + and dslSfx.chip.channels[2].number == 8, + "sfx channels are numbered 5-8") + +-- terminator: a stream that can fall off its end gets the endchannel byte, +-- one that loops forever does not +check(ChipAsm.song{ channels = { { hw = 1, program = { { note = "C" } } } } } + .chip.blob == string.char(0x00, 0xFF), + "a finite stream is terminated with endchannel") + +-- errors name the channel and the event index +local function failsWith(fragment, fn) + local ok, err = pcall(fn) + check(not ok, "expected a ChipAsm error: " .. fragment) + check(tostring(err):find(fragment, 1, true), + ("error %q does not mention %q"):format(tostring(err), fragment)) +end + +failsWith("channel 1 event 2: unknown note \"H\"", function() + ChipAsm.song{ channels = { { hw = 1, + program = { { note = "C" }, { note = "H" } } } } } +end) +failsWith("channel 2 event 1: len out of range 1-16", function() + ChipAsm.song{ channels = { + { hw = 1, program = { { note = "C" } } }, + { hw = 2, program = { { note = "C", len = 40 } } } } } +end) +failsWith("channel 1 event 1: drums only play on channel 4", function() + ChipAsm.song{ channels = { { hw = 1, program = { { drum = 1 } } } } } +end) +failsWith("channel 1 event 3: unknown label \"nope\"", function() + ChipAsm.song{ channels = { { hw = 1, program = { + { note = "C" }, { note = "D" }, { call = "nope" } } } } } +end) +failsWith("channel 1 subroutine \"riff\" event 1: octave out of range", function() + ChipAsm.song{ channels = { { hw = 1, program = { { call = "riff" } }, + subroutines = { riff = { { octave = 12 } } } } } } +end) + +-- friendly drum rows become the segment shape Engine:noiseInstrument caches +local drumDef = ChipAsm.song{ + channels = { { hw = 4, program = { { drum = 3, len = 4 } } } }, + drums = { [3] = { { len = 4, volume = 13, fade = 2, parameter = 0x42 } } }, +} +local segment = drumDef.chip.drums[3][1] +check(segment.startSample == 0 and segment.volume == 13 + and segment.fade == 2 and segment.parameter == 0x42, + "drum rows assemble into cached noise segments") +check(segment.endSample > 0, "drum segment spans samples") + +-- ------- ChipAudio: def-local blobs render without touching programs.bin + +local blobData = { audio = {} } + +local blobSong = ChipAsm.song{ + tempo = 0x100, + channels = { { hw = 1, program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 12, fade = 0 } }, + { octave = 4 }, + { label = "body" }, + { note = "C", len = 8 }, + { note = "E", len = 8 }, + { loop = { count = 0, to = "body" } }, + } } }, +} +local rendered = ChipAudio._renderMusicForTest(blobData, blobSong, 0.1) +local nonzero = 0 +for index = 0, rendered:getSampleCount() - 1 do + if rendered:getSample(index) ~= 0 then nonzero = nonzero + 1 end +end +check(nonzero > 0, "a blob def renders nonzero audio with no ROM banks") + +local blobTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, blobSong) +check(blobTrace[1].register == 1797 and blobTrace[1].volume == 12, + "the blob's first note decodes to the C4 register") + +-- def-local waves are honored over the ROM's wave banks +local flatWave = {} +for index = 1, 32 do flatWave[index] = 1 end +local waveSong = ChipAsm.song{ + channels = { { hw = 3, program = { + { notetype = { speed = 12, waveLevel = 1, waveInstrument = 0 } }, + { octave = 4 }, + { note = "C", len = 8 }, + } } }, + waves = { flatWave }, +} +local waveTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, waveSong) +check(math.abs(waveTrace[1].value - 0.55) < 1e-9, + "def-local waves drive the wave channel") + +-- def-local drums are honored over the ROM's noise headers +local drumTrace = ChipAudio._traceFirstMusicSampleForTest(blobData, drumDef) +check(drumTrace[1].drumSegments == 1, "def-local drums reach the noise channel") + +-- ------- data fixtures + +local chipSong = ChipAsm.song{ + channels = { { hw = 1, program = { + { notetype = { speed = 12, volume = 12, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 8 }, + { loop = { count = 0, to = 1 } }, + } } }, +} + +local function fixtureData() + return { + audio = { + songs = { + Music_Chip = chipSong, + Music_File = { file = "assets/theme.ogg" }, + Music_Split = { file = "assets/theme.ogg", + loopFile = "assets/theme_loop.ogg" }, + Music_Other = { file = "assets/other.ogg" }, + Music_Broken = { file = "assets/missing.ogg" }, + Music_BikeRiding = { file = "assets/other.ogg" }, + Music_PalletTown = { file = "assets/theme.ogg" }, + }, + sfx = { + Beep = "assets/beep.wav", + Chime = { file = "assets/chime.ogg", fanfare = true }, + Level_Up = "assets/beep.wav", + Broken = { file = "assets/missing.ogg" }, + Chip_Sfx = ChipAsm.sfx{ channels = { { hw = 1, program = { + { squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } }, + } } } }, + }, + cries = {}, + mapSongs = { PALLET_TOWN = "Music_PalletTown" }, + battle = { wild = "Music_Chip", wildWin = "Music_File" }, + }, + } +end + +local function reset(data) + Sound.invalidate() + Music.reload() + resetSources() + return data +end + +-- ------- dispatch: the branch follows the definition, not a global flag + +local data = reset(fixtureData()) + +Music.play(data, "Music_Chip") +check(lastSource() and lastSource().queueable, + "a chip def streams through ChipAudio") +check(lastSource().playing, "the chip song started") +local chipSource = lastSource() + +Music.play(data, "Music_File") +check(lastSource() and not lastSource().queueable + and lastSource().file == "assets/theme.ogg", + "a file def becomes a stream source") +check(not chipSource.playing, "the outgoing chip song was stopped") +check(lastSource().looping == true, "a looping file song loops") + +-- intro/loop chaining now works regardless of import mode +Music.play(data, "Music_Split") +local intro = sources[#sources - 1] +local body = sources[#sources] +check(intro.file == "assets/theme.ogg" and body.file == "assets/theme_loop.ogg", + "a split def loads both files") +check(intro.looping == false and body.looping == true, + "the intro plays once and the body loops") +check(intro.playing and not body.playing, "the loop body waits for the intro") +intro.playing = false +Music.update(data) +check(body.playing, "update() chains the intro into the loop body") + +-- a file song never latches chip playback off for the songs around it +Music.play(data, "Music_Chip") +check(lastSource().queueable and lastSource().playing, + "a chip song still plays after a file song") +check(not body.playing, "the outgoing file song was stopped") + +-- sfx shape dispatch +check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing") +check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source") +resetSources() +Sound.play(data, "Chip_Sfx") +check(lastSource() and lastSource().mode == "static" + and type(lastSource().file) == "table", + "a chip sfx renders to a static source") + +-- ------- failure isolation: a bad def costs one log line, nothing else + +data = reset(fixtureData()) +Music.play(data, "Music_File") +local playing = lastSource() +local before = loggedCount("bad song def") +Music.play(data, "Music_Broken") +Music.play(data, "Music_File") +Music.play(data, "Music_Broken") +check(loggedCount("bad song def") == before + 1, + "a broken song def is logged exactly once") +check(playing.playing, "the previous song keeps playing through a bad def") +Music.play(data, "Music_Other") +check(lastSource().file == "assets/other.ogg" and lastSource().playing, + "a bad def does not disable the rest of the music") + +local sfxBefore = loggedCount("bad sfx def") +Sound.play(data, "Broken") +Sound.play(data, "Broken") +check(loggedCount("bad sfx def") == sfxBefore + 1, + "a broken sfx def is logged exactly once") +resetSources() +Sound.play(data, "Beep") +check(lastSource() and lastSource().file == "assets/beep.wav", + "a bad sfx does not disable the rest of the effects") + +-- ------- cries: every authoring variant plays + +data = reset(fixtureData()) +data.audio.cries.RHYDON = { + header = { address = 0x4000, bank = 2, engine = 1 }, pitch = 0, length = 0, +} +data.audio.cries.CHIPMON = { chip = ChipAsm.sfx{ channels = { { hw = 1, + program = { { squareNote = { len = 8, volume = 15, fade = 1, + frequency = 0x600 } } } } } }.chip, + pitch = 0, length = 0 } +data.audio.cries.SHELLORD = { base = "CHIPMON", pitch = 0x2A, length = 0x50 } +data.audio.cries.FILEMON = { file = "assets/cry.ogg", pitch = 1.1 } +data.audio.cries.CHAINMON = { base = "SHELLORD" } + +check(Sound.playCry(data, "CHIPMON"), "a chip cry plays") +check(Sound.playCry(data, "SHELLORD"), "a derived cry plays") +check(Sound.playCry(data, "CHAINMON"), "a derived cry chain resolves") +local fileCry = Sound.playCry(data, "FILEMON") +check(fileCry and fileCry.file == "assets/cry.ogg", "a file cry plays") +check(fileCry.pitch == 1.1, "a file cry honors its playback rate") +check(Sound.playCry(data, "NOBODY") == nil, "an unregistered species is silent") + +-- GROWL/ROAR layer their own tempo shift on top of any cry shape +Sound.playMoveCry(data, "FILEMON", 0xC0) +check(math.abs(fileCry.pitch - 256 / (128 + 0xC0)) < 1e-9, + "playMoveCry layers the move's tempo shift onto a file cry") + +data.audio.cries.ORPHAN = { base = "MISSING" } +local cryBefore = loggedCount("bad cry def") +check(Sound.playCry(data, "ORPHAN") == nil, "a dangling base cry is silent") +check(loggedCount("bad cry def") == cryBefore + 1, + "a dangling base cry is logged once") + +-- ------- song literals: data tables win, module fallbacks preserve vanilla + +data = reset(fixtureData()) +check(Music.special(data, "title") == "Music_TitleScreen", + "special song roles fall back to the vanilla labels") +check(Music.special(data, "bike") == "Music_BikeRiding", + "the bike role falls back to Music_BikeRiding") +Music.playMap(data, "PALLET_TOWN", true, false) +check(lastSource().file == "assets/other.ogg", + "the fallback outdoor set engages the bike theme") + +data = reset(fixtureData()) +data.audio.special = { bike = "Music_File" } +data.audio.outdoorSongs = { Music_PalletTown = true } +Music.playMap(data, "PALLET_TOWN", true, false) +check(lastSource().file == "assets/theme.ogg", + "a renamed bike theme engages on outdoor maps") +check(Music.special(data, "title") == "Music_TitleScreen", + "roles the data table omits still fall back") + +data = reset(fixtureData()) +data.audio.outdoorSongs = {} +Music.playMap(data, "PALLET_TOWN", true, false) +check(lastSource().file == "assets/theme.ogg", + "a map outside the outdoor set keeps its own theme on the bike") + +-- fanfare ducking: the shared table or the definition's own flag +data = reset(fixtureData()) +local ducked = {} +local realDuck = Music.duckForFanfare +Music.duckForFanfare = function(src) ducked[#ducked + 1] = src end +Sound.play(data, "Level_Up") +check(#ducked == 1, "a vanilla fanfare ducks the music") +Sound.play(data, "Chime") +check(#ducked == 2, "a def with fanfare = true ducks without a table edit") +Sound.play(data, "Beep") +check(#ducked == 2, "an ordinary sfx does not duck") +data.audio.fanfares = { Beep = true } +Sound.invalidate() +Sound.play(data, "Beep") +check(#ducked == 3, "data.audio.fanfares supersedes the fallback table") +Music.duckForFanfare = realDuck + +-- ------- cache invalidation + +data = reset(fixtureData()) +Sound.play(data, "Beep") +local firstBeep = lastSource() +Sound.play(data, "Beep") +check(lastSource() == firstBeep, "sources are cached across plays") +Sound.invalidate("Beep") +Sound.play(data, "Beep") +check(lastSource() ~= firstBeep, "Sound.invalidate drops the cached source") + +data = reset(fixtureData()) +Music.play(data, "Music_Broken") +check(#sources == 0, "a broken def creates no source") +data.audio.songs.Music_Broken = { file = "assets/other.ogg" } +Music.play(data, "Music_Broken") +check(#sources == 0, "a failed label stays negatively cached") +Music.reload() +Music.play(data, "Music_Broken") +check(lastSource() and lastSource().file == "assets/other.ogg", + "Music.reload re-resolves a repaired def") +ChipAudio.invalidate() + +-- ------- the music.select hook and the audio events + +local events = require("src.mods.Events").new() +local hooks = require("src.mods.Hooks").new() +Runtime.install(events, hooks) + +data = reset(fixtureData()) +check(not Runtime.wantsHook("music.select"), + "with no wrapper the hook builds no context") + +local seen = {} +hooks:wrap("music.select", function(nextLink, song, ctx) + seen[#seen + 1] = { song = song, reason = ctx.reason, mapId = ctx.mapId, + kind = ctx.kind, trainerId = ctx.trainerId, + onBike = ctx.onBike } + return nextLink(song, ctx) +end, nil, "test") + +Music.playMap(data, "PALLET_TOWN", false, false) +check(seen[1].reason == "map" and seen[1].mapId == "PALLET_TOWN" + and seen[1].song == "Music_PalletTown", + "playMap reaches the hook with the map context") +Music.playBattle(data, "wild", "OPP_RIVAL3") +check(seen[2].reason == "battle" and seen[2].kind == "wild" + and seen[2].trainerId == "OPP_RIVAL3", + "playBattle threads the battle kind and trainer") +Music.playVictory(data, "wild") +check(seen[3].reason == "victory" and seen[3].kind == "wild", + "playVictory reaches the hook") +Music.playOnce(data, "Music_Other") +check(seen[4].reason == "once", "playOnce reaches the hook") +Music.play(data, "Music_Split") +check(seen[5].reason == "direct", "a direct play defaults to the direct reason") + +-- returning nil silences the cue; returning a label plays that label +Music.reload() +resetSources() +hooks:removeOwner("test") +hooks:wrap("music.select", function() return nil end, nil, "silencer") +Music.play(data, "Music_File") +check(#sources == 0, "a hook returning nil silences the cue") +hooks:removeOwner("silencer") + +hooks:wrap("music.select", function(nextLink, song, ctx) + if song == "Music_File" then return nextLink("Music_Other", ctx) end + return nextLink(song, ctx) +end, nil, "swap") +Music.play(data, "Music_File") +check(lastSource().file == "assets/other.ogg", "a hook may swap the label") +-- the swapped label is what dedupe compares, so re-asking still restarts +-- nothing but a genuinely different choice does +Music.play(data, "Music_Chip") +check(lastSource().queueable, "an unswapped label still plays") + +-- a throwing wrapper is skipped and the chain continues +hooks:wrap("music.select", function() error("boom", 0) end, nil, "thrower") +Music.reload() +resetSources() +Music.play(data, "Music_File") +check(lastSource() and lastSource().file == "assets/other.ogg", + "a throwing wrapper is skipped and the surviving chain still runs") +hooks:removeOwner("thrower") +hooks:removeOwner("swap") + +-- events +Music.reload() +resetSources() +local started, stopped, played = {}, {}, {} +events:on("music.started", function(p) started[#started + 1] = p end, nil, "test") +events:on("music.stopped", function(p) stopped[#stopped + 1] = p end, nil, "test") +events:on("sound.played", function(p) played[#played + 1] = p end, nil, "test") + +Music.playMap(data, "PALLET_TOWN", false, false) +check(started[1] and started[1].song == "Music_PalletTown" + and started[1].reason == "map" and started[1].chip == false, + "music.started carries the song, reason and chip flag") +Music.play(data, "Music_Chip") +check(started[2].previous == "Music_PalletTown" and started[2].chip == true, + "music.started names the song it replaced") +Music.stop() +check(#stopped == 1 and stopped[1].song == "Music_Chip", + "music.stopped names the song that was playing") +Music.stop() +check(#stopped == 1, "stopping silence emits nothing") + +Sound.play(data, "Beep") +check(played[1] and played[1].kind == "sfx" and played[1].name == "Beep", + "sound.played fires for an sfx") +Sound.playMove(data, { sound = "Chip_Sfx", pitch = 0x10, tempo = 0x90 }) +check(played[2].kind == "move" and played[2].name == "Chip_Sfx", + "sound.played fires for a move sound") +data.audio.cries.FILEMON = { file = "assets/cry.ogg" } +Sound.playCry(data, "FILEMON") +check(played[3].kind == "cry" and played[3].species == "FILEMON", + "sound.played fires for a cry") + +Runtime.install(savedEvents, savedHooks) +check(not Runtime.wants("music.started"), + "with no listener the event site allocates no payload") + +-- ------- registries: the granular merge into data.audio + +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function manifestJson(id, api, deps) + return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua","dependencies":%s,"api":%d}]]) + :format(id, id, deps or "[]", api) +end + +local granularFiles = { + ["mods/coast/manifest.json"] = manifestJson("coast", 2), + ["mods/coast/main.lua"] = [[ +return function(mod) + mod.content.music:register("Music_CoastTown", { + file = "assets/theme.ogg", loopFile = "assets/theme_loop.ogg" }) + mod.content.sfx:register("Shell_Found", { + file = "assets/chime.ogg", fanfare = true }) + mod.content.cries:register("SHELLORD", { + header = { address = 16696, bank = 2, engine = 1 }, pitch = 42, length = 80 }) + mod.content.cries:register("REEFMON", { file = "assets/cry.ogg" }) + mod.content.map_songs:override("PALLET_TOWN", "Music_CoastTown") + mod.content.cries:patch("RHYDON", { pitch = 200 }) + mod.content.sfx:remove("Beep") +end +]], +} +local merged = fixtureData() +merged.audio.cries.RHYDON = { + header = { address = 1, bank = 2, engine = 1 }, pitch = 0, length = 9, +} +local granular = Loader.new({ fs = memfs(granularFiles) }) +check(granular:load(merged) == true, + "the granular audio mod loads: " .. table.concat(granular.errors, "; ")) +check(merged.audio.songs.Music_CoastTown.loopFile == "assets/theme_loop.ogg", + "music merges into data.audio.songs") +check(merged.audio.sfx.Shell_Found.fanfare == true, + "sfx merges into data.audio.sfx") +check(merged.audio.cries.SHELLORD.pitch == 42, + "cries merge into data.audio.cries") +check(merged.audio.mapSongs.PALLET_TOWN == "Music_CoastTown", + "map_songs merge into data.audio.mapSongs") +check(merged.audio.cries.RHYDON.pitch == 200 + and merged.audio.cries.RHYDON.length == 9, + "patch is field-precise on a cry record") +check(merged.audio.sfx.Beep == nil, "remove tombstones an sfx") + +-- the merged map song plays through the ordinary map path +reset(merged) +Music.playMap(merged, "PALLET_TOWN", false, false) +check(sources[1] and sources[1].file == "assets/theme.ogg" and sources[1].playing, + "a mod's map song plays on the map it claims") + +-- a brand-new species sounds everywhere a vanilla one does +local reefCry = Sound.playCry(merged, "REEFMON") +check(reefCry and reefCry.file == "assets/cry.ogg" and reefCry.playing, + "a species the mod invented plays its registered cry") + +-- and the hook can still take the map theme away from it +local mapHooks = require("src.mods.Hooks").new() +Runtime.install(require("src.mods.Events").new(), mapHooks) +mapHooks:wrap("music.select", function(nextLink, song, ctx) + if ctx.reason == "map" then return nextLink("Music_Other", ctx) end + return nextLink(song, ctx) +end, nil, "night") +reset(merged) +Music.playMap(merged, "PALLET_TOWN", false, false) +check(lastSource().file == "assets/other.ogg", + "music.select overrides the track for a map") +Runtime.install(savedEvents, savedHooks) + +-- bootstrap: a dataset with no audio namespace at all +local bootstrapFiles = { + ["mods/tc/manifest.json"] = manifestJson("tc", 2), + ["mods/tc/main.lua"] = [[ +return function(mod) + mod.content.music:register("Music_TC", { file = "assets/theme.ogg" }) + mod.content.map_songs:register("TC_TOWN", "Music_TC") +end +]], +} +local bare = {} +local bootstrap = Loader.new({ fs = memfs(bootstrapFiles) }) +check(bootstrap:load(bare) == true, + "an audio-only conversion loads against a dataset with no audio: " + .. table.concat(bootstrap.errors, "; ")) +check(bare.audio and bare.audio.songs.Music_TC + and bare.audio.mapSongs.TC_TOWN == "Music_TC", + "the audio namespace is created when the base cache never shipped one") + +-- the v1 whole-table registry still works and loses to a granular +-- registration of the same id +local v1Files = { + ["mods/legacy/manifest.json"] = manifestJson("legacy", 1), + ["mods/legacy/main.lua"] = [[ +return function(mod) + mod.content.audio:override("sfx", { Beep = "assets/other.ogg", + Legacy_Only = "assets/beep.wav" }) +end +]], + ["mods/modern/manifest.json"] = manifestJson("modern", 2, '["legacy"]'), + ["mods/modern/main.lua"] = [[ +return function(mod) + mod.content.sfx:override("Beep", "assets/chime.ogg") +end +]], +} +local v1Data = fixtureData() +local v1Loader = Loader.new({ fs = memfs(v1Files) }) +check(v1Loader:load(v1Data) == true, + "the v1 audio registry still loads: " .. table.concat(v1Loader.errors, "; ")) +check(v1Data.audio.sfx.Legacy_Only == "assets/beep.wav", + "the v1 whole-table replacement still applies") +check(v1Data.audio.sfx.Beep == "assets/chime.ogg", + "a granular registration beats a v1 whole-table replacement") +check(v1Data.audio._owners.sfx.Beep == "modern", + "the granular writer owns the id even when a v1 table landed on it first") + +-- attribution: the owner map the merge stamps names the mod in the log and +-- in Loader.errors, which is the only feed the mod manager's errors screen +-- and its errored-mod glyph read +local function errorsMentioning(loader, fragment) + local count = 0 + for _, line in ipairs(loader.errors) do + if line:find(fragment, 1, true) then count = count + 1 end + end + return count +end + +-- the defs are registered through a real load, not planted in the data, so +-- the provenance under test is the one the merge produced +local badFiles = { + ["mods/coast/manifest.json"] = manifestJson("coast", 2), + ["mods/coast/main.lua"] = [[ +return function(mod) + mod.content.music:register("Music_Bad", { file = "assets/missing.ogg" }) + mod.content.sfx:register("Sfx_Bad", { file = "assets/missing.ogg" }) + mod.content.sfx:register("Loop_Bad", { file = "assets/missing.ogg" }) + mod.content.cries:register("BADMON", { file = "assets/missing.ogg" }) + mod.content.sfx:register("Gone", { file = "assets/missing.ogg" }) + mod.content.sfx:remove("Gone") +end +]], +} +local badData = fixtureData() +local badLoader = Loader.new({ fs = memfs(badFiles) }) +check(badLoader:load(badData) == true, + "the mod shipping the broken defs loads: " + .. table.concat(badLoader.errors, "; ")) +check(badData.audio._owners.songs.Music_Bad == "coast" + and badData.audio._owners.sfx.Sfx_Bad == "coast" + and badData.audio._owners.sfx.Loop_Bad == "coast" + and badData.audio._owners.cries.BADMON == "coast", + "the merge stamps every def a mod registered with its owner") +check(badData.audio._owners.sfx.Gone == nil, + "a tombstoned id keeps no provenance behind it") +check(badData.audio._owners.sfx.Beep == nil, + "an id no mod touched stays unattributed") + +reset(badData) +local attributedBefore = loggedCount("(mod coast)") +local errorsBefore = #badLoader.errors +Music.play(badData, "Music_Bad") +Sound.play(badData, "Sfx_Bad") +Sound.playCry(badData, "BADMON") +Sound.startLoop(badData, "Loop_Bad") +check(loggedCount("(mod coast)") == attributedBefore + 4, + "a bad def is logged against the mod that registered it") +check(#badLoader.errors == errorsBefore + 4, + "every play-time audio failure reaches Loader.errors") +check(errorsMentioning(badLoader, 'coast: audio: bad song def "Music_Bad"') == 1 + and errorsMentioning(badLoader, 'coast: audio: bad sfx def "Sfx_Bad"') == 1 + and errorsMentioning(badLoader, 'coast: audio: bad cry def "BADMON"') == 1 + and errorsMentioning(badLoader, 'coast: audio: bad sfx def "Loop_Bad"') == 1, + "Loader.errors names the owning mod and the def that failed") + +-- replaying a known-bad def is silent: the negative cache keeps the errors +-- screen from filling up with one broken def +Music.play(badData, "Music_Bad") +Sound.play(badData, "Sfx_Bad") +Sound.playCry(badData, "BADMON") +Sound.startLoop(badData, "Loop_Bad") +check(#badLoader.errors == errorsBefore + 4, + "a known-bad def reports to Loader.errors once, not per play") + +-- an engine-owned def has no mod to blame, so it stays a console line +badData.audio.songs.Music_BaseBad = { file = "assets/missing.ogg" } +Music.play(badData, "Music_BaseBad") +check(loggedCount("bad song def") > 0 and #badLoader.errors == errorsBefore + 4, + "a base-owned failure never lands in Loader.errors") + +-- ------- restore the ambient stubs for the suites that follow + +Sound.invalidate() +Music.reload() +Runtime.install(savedEvents, savedHooks, savedErrors) +love.audio, love.sound = savedAudio, savedSound + +S.finish() diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua new file mode 100644 index 00000000..75d4c6ff --- /dev/null +++ b/tests/mod_battle_tests.lua @@ -0,0 +1,778 @@ +-- M6 battle extensibility: effect-record coverage and parity, statuses/ +-- balls/rulesets/ai_classes consumption, move-field promotion, the battle +-- hooks and events, and the side/field substrate. Self-contained like the +-- other mod suites: own bootstrap, assert-based checks, error() on failure. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local BattleState = require("src.battle.BattleState") +local Catching = require("src.battle.Catching") +local Damage = require("src.battle.Damage") +local Events = require("src.mods.Events") +local Evolution = require("src.pokemon.Evolution") +local Experience = require("src.battle.Experience") +local Growth = require("src.pokemon.Growth") +local Hooks = require("src.mods.Hooks") +local MoveEffects = require("src.battle.MoveEffects") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local Status = require("src.battle.Status") +local TrainerAI = require("src.battle.TrainerAI") +local TurnOrder = require("src.battle.TurnOrder") +local TypeChart = require("src.battle.TypeChart") + +TypeChart.load(Data) +local ruleset = require("src.battle.rulesets.gen1_faithful") + +local S = require("tests.harness").suite("mod battle") +local check = S.check + +local function mkseq(vals) -- scripted rng: pops vals, then max rolls + local i = 0 + return function(a, b) + i = i + 1 + return vals[i] ~= nil and vals[i] or b + end +end + +-- a stub stack keeps the queue pump self-contained (no UI rows in these +-- probes, so top() never has to return the battle) +local function makeGame(party) + local save = SaveData.newGame() + save.party = party + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function() return true end } } +end + +local function pump(battle, limit) + local steps = 0 + while steps < (limit or 6000) do + steps = steps + 1 + if not battle:updateQueue() then break end + end +end + +local function hasText(battle, fragment) + for _, item in ipairs(battle.queue) do + if item.text and item.text:find(fragment, 1, true) then return true end + end + return false +end + +-- fresh buses for the hook/event sections; restored at the bottom +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local events, hooks = Events.new(), Hooks.new() +Runtime.install(events, hooks) + +-- ------- every vanilla move's effect resolves to a registered record + +local effectCount, fullCount = 0, 0 +for _, move in pairs(Data.moves) do + if move.effect then + local record = MoveEffects.RECORDS[move.effect] + check(record ~= nil, "effect record exists for " .. move.effect) + check(record.kind == "primary" or record.kind == "secondary" + or record.kind == "full", "effect record kind valid for " .. move.effect) + end +end +for _, record in pairs(MoveEffects.RECORDS) do + effectCount = effectCount + 1 + if record.kind == "full" then fullCount = fullCount + 1 end +end +check(fullCount >= 25, "the 25 inline effects registered as full records") +check(effectCount >= 60, "primary/secondary/full records all registered") +check(MoveEffects.RECORDS.SWIFT_EFFECT.neverMiss == true, "Swift record never misses") +check(MoveEffects.RECORDS.OHKO_EFFECT.gate ~= nil, "OHKO record carries its gate") +check(MoveEffects.RECORDS.TWINEEDLE_EFFECT.kind == "full" + and MoveEffects.RECORDS.TWINEEDLE_EFFECT.run ~= nil, + "Twineedle is a full record with its secondary run") +check(MoveEffects.RECORDS.SLEEP_EFFECT.accuracyChecked == true + and MoveEffects.RECORDS.ATTACK_UP1_EFFECT.accuracyChecked == nil, + "accuracyChecked marks the MoveHitTest primaries") + +-- ------- category-vs-isSpecial equivalence + +local typeCount = 0 +for id, record in pairs(TypeChart.TYPES) do + typeCount = typeCount + 1 + check(Damage.isSpecial(id) == (record.category == "special"), + "category matches isSpecial for " .. id) +end +check(typeCount == 15, "all 15 vanilla types checked") +check(TypeChart.displayName("PSYCHIC_TYPE") == "PSYCHIC", "type display name") + +-- ------- record-driven parity probes + +do + -- fixed damage through the SPECIAL_DAMAGE record + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + local battle = BattleState.newWild(game, "SNORLAX", 30) + battle.rng = mkseq({ 0 }) -- accuracy only: SONICBOOM rolls nothing else + local before = battle.enemy.mon.hp + battle:performMove(battle.player, battle.enemy, { id = "SONICBOOM", pp = 10 }) + check(before - battle.enemy.mon.hp == 20, "SONICBOOM deals a fixed 20") + + -- OHKO gate fails against a faster target + local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 5) }) + local slow = BattleState.newWild(game2, "RATTATA", 30) + slow.rng = mkseq({}) + slow:performMove(slow.player, slow.enemy, { id = "FISSURE", pp = 5 }) + check(hasText(slow, "But, it failed!"), "OHKO fails against a faster target") + check(slow.enemy.mon.hp == slow.enemy.mon.stats.hp, "no damage through a failed gate") +end + +-- ------- a mod-registered move effect drives a battle + +do + local landed = false + local effects = {} + for id, record in pairs(MoveEffects.RECORDS) do effects[id] = record end + effects.TEST_SIDE_EFFECT = { kind = "secondary", run = function(ctx) + landed = true + return { "It tingles!" } + end } + Data.move_effects = effects + Data.moves.TEST_STRIKE = { id = "TEST_STRIKE", name = "TEST STRIKE", + type = "NORMAL", power = 40, accuracy = 100, pp = 10, + effect = "TEST_SIDE_EFFECT" } + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + local battle = BattleState.newWild(game, "SNORLAX", 30) + battle.rng = mkseq({ 0, 255, 255 }) + battle:performMove(battle.player, battle.enemy, { id = "TEST_STRIKE", pp = 10 }) + check(landed, "a registered move effect runs post-damage") + check(hasText(battle, "It tingles!"), "the effect's message is queued") + check(battle.enemy.mon.hp < battle.enemy.mon.stats.hp, + "an unknown-kind move still deals its damage") + Data.move_effects = nil + Data.moves.TEST_STRIKE = nil +end + +-- ------- statuses registry: gauntlet, residual, HUD, catch bonus + +do + local statuses = {} + for id, record in pairs(Status.RECORDS) do statuses[id] = record end + statuses.FBT = { + id = "FBT", label = "FBT", hudLabel = "FBT", + catchBonus = 20, shakeBonus = 30, + beforeMovePriority = 40, + beforeMove = function(battler) + return false, { battler.name .. "\nis frostbitten!" } + end, + residual = function(battler) + battler.mon.hp = math.max(0, battler.mon.hp - 3) + return { "The frostbite\nhurts!" } + end, + } + Data.statuses = statuses + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + local battle = BattleState.newWild(game, "SNORLAX", 30) + battle.enemy.mon.status = "FBT" + local canMove, msgs = Status.beforeMove(battle.enemy, battle.rng, battle) + check(canMove == false and msgs[1]:find("frostbitten", 1, true), + "a registered status joins the beforeMove gauntlet") + local hp = battle.enemy.mon.hp + local residualMsgs = Status.residual(battle.enemy, battle.player, battle) + check(battle.enemy.mon.hp == hp - 3 and #residualMsgs == 1, + "a registered status joins the residual sweep") + check(battle:statusLabel(battle.enemy.mon) == "FBT", + "the HUD reads the registered hudLabel") + + -- the catch roll subtracts the registered catchBonus + local mon = { status = "FBT", stats = { hp = 100 }, hp = 100 } + local caught = Catching.attempt("POKE_BALL", mon, { catchRate = 0 }, + mkseq({ 19 }), nil, { statuses = statuses }) + check(caught == true, "registered catchBonus underflows the catch roll") + local uncaught = Catching.attempt("POKE_BALL", mon, { catchRate = 0 }, + mkseq({ 19 }), nil, nil) + check(uncaught == false, "an unknown status grants no catch bonus") + + -- the failure wobble adds the registered shakeBonus (fallback +5) + local _, wobbles = Catching.attempt("POKE_BALL", mon, { catchRate = 50 }, + mkseq({ 100 }), nil, { statuses = statuses }) + check(wobbles == 2, "registered shakeBonus feeds the wobble math") + local _, plain = Catching.attempt("POKE_BALL", mon, { catchRate = 50 }, + mkseq({ 100 }), nil, nil) + check(plain == 1, "an unknown status falls back to the stock wobble bonus") + Data.statuses = nil + + -- vanilla gauntlet parity without a battle on hand + local sleeper = { mon = { status = "SLP" }, sleepTurns = 2, name = "SLEEPY" } + local slpMove, slpMsgs = Status.beforeMove(sleeper, mkseq({})) + check(slpMove == false and slpMsgs[1]:find("fast asleep", 1, true), + "vanilla sleep runs through its record") + local par = { mon = { status = "PAR" }, name = "ZAPPED" } + local parMove = Status.beforeMove(par, mkseq({ 62 })) + check(parMove == false, "full paralysis on a low roll") + local parFree = Status.beforeMove(par, mkseq({ 63 })) + check(parFree == true, "paralysis clears on a high roll") +end + +-- ------- balls registry: record fields and the attempt override + +do + local calls = {} + local rng = function(a, b) + calls[#calls + 1] = { a, b } + return b + end + local mon = { status = nil, stats = { hp = 100 }, hp = 100 } + Catching.attempt("MOD_BALL", mon, { catchRate = 100 }, rng, nil, + { ballDef = { randMax = 100, hpFactor = 12, wobbleFactor = 150 } }) + check(calls[1][2] == 100, "a registered ball's randMax bounds the roll") + + local auto = Catching.attempt("MOD_BALL", mon, { catchRate = 0 }, + function() error("autoCatch must not roll") end, nil, + { ballDef = { randMax = 0, autoCatch = true } }) + check(auto == true, "autoCatch skips every roll") + + -- an attempt override doubles the rate then falls through to the math + local caught = Catching.attempt("MOD_BALL", mon, { catchRate = 100 }, + mkseq({ 100, 85 }), nil, + { ballDef = { randMax = 255, hpFactor = 12, wobbleFactor = 150, + attempt = function(ctx) + ctx.rateOverride = math.min(255, ctx.targetDef.catchRate * 2) + return ctx.vanillaAttempt() + end } }) + check(caught == true, "an attempt override rewrites the rate and delegates") + + -- toss/flicker resolve from the records + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + local battle = BattleState.newWild(game, "RATTATA", 5) + check(battle:tossAnimFor("GREAT_BALL") == "GREATTOSS_ANIM", "toss arc from record") + check(battle:ballFlicker("ULTRA_BALL") == true, "Ultra flickers") + check(battle:ballFlicker("POKE_BALL") == false, "Poke ball does not flicker") +end + +-- ------- rulesets from the merged registry + +do + Data.rulesets = { + gen1_faithful = ruleset, + test_rules = { name = "test_rules", oneIn256Miss = false, + critUsesBaseSpeed = true, critIgnoresStages = true, + randMin = 255, randMax = 255, focusEnergyBug = true }, + } + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + game.save.options = { ruleset = "test_rules" } + local battle = BattleState.newWild(game, "RATTATA", 5) + check(battle.ruleset.name == "test_rules", "battle picks the registered ruleset") + game.save.options = { ruleset = "no_such_rules" } + local fallback = BattleState.newWild(game, "RATTATA", 5) + check(fallback.ruleset == ruleset, "unknown ruleset falls back to the default") + Data.rulesets = nil +end + +-- ------- options menu cycles the merged ruleset registry + +do + local OptionsMenu = require("src.ui.OptionsMenu") + Data.rulesets = { + gen1_faithful = ruleset, + modern_clean = require("src.battle.rulesets.modern_clean"), + aaa_rules = { name = "aaa rules" }, + zz_hidden = { name = "hidden rules", hidden = true }, + } + local pressed = {} + local game = { data = Data, save = SaveData.newGame(), + input = { wasPressed = function(_, key) + return pressed[key] or false + end }, + stack = { pop = function() end } } + local menu = OptionsMenu.new(game) + menu.index = 4 + local function press(key) + pressed = { [key] = true } + menu:update(1 / 60) + pressed = {} + end + check(game.save.options.ruleset == "gen1_faithful", + "new saves start on the default ruleset") + press("right") + check(game.save.options.ruleset == "modern_clean", + "right steps to the next sorted id") + press("right") + check(game.save.options.ruleset == "aaa_rules", + "a mod-registered ruleset is selectable") + press("right") + check(game.save.options.ruleset == "gen1_faithful", + "the cycle wraps and never offers the hidden record") + press("left") + check(game.save.options.ruleset == "aaa_rules", "left steps backwards") + + local drawn = {} + local savedDraw = Font.draw + Font.draw = function(text) drawn[#drawn + 1] = text end + menu:draw() + Font.draw = savedDraw + local shown = false + for _, text in ipairs(drawn) do + if text == "aaa rules" then shown = true end + end + check(shown, "the row displays the record's name, not the id") + Data.rulesets = nil +end + +-- ------- ai_classes: brains and layer records + +do + Data.ai_classes = { OPP_YOUNGSTER = { brain = function(battle) + return { id = "SPLASH", pp = 1, brained = true } + end } } + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + local battle = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1) + local action = battle:enemyAction() + check(action.brained == true, "a registered brain chooses the enemy action") + + Data.ai_classes = { LAYER_1 = { score = function(view, moveDef, score) + if moveDef and moveDef.id == "TACKLE" then return score + 50 end + return score + end } } + local aiMon = { curMoves = { { id = "TACKLE", pp = 10 }, + { id = "GROWL", pp = 10 } } } + local aiBattle = { enemyAIMods = { 1 }, data = Data, + player = { mon = {}, curTypes = { "NORMAL" } } } + local pick = TrainerAI.chooseMove(aiMon, mkseq({}), aiBattle) + check(pick.id == "GROWL", "a registered layer record rescores the choice") + Data.ai_classes = nil + + local brock = TrainerAI.classFor({ trainer = { id = "OPP_BROCK" }, data = Data }) + check(brock and brock.item == "FULL_HEAL", "class lookup falls back to the data file") +end + +-- ------- move-field promotion + +do + -- priority beats speed + local slow = { curStats = { speed = 5 }, stages = {}, mon = {} } + local fast = { curStats = { speed = 99 }, stages = {}, mon = {} } + check(TurnOrder.firstMover(slow, { id = "X", priority = 1 }, fast, + { id = "TACKLE" }, mkseq({})) == true, + "move.priority wins the turn order") + check(TurnOrder.firstMover(slow, { id = "QUICK_ATTACK" }, fast, + { id = "TACKLE" }, mkseq({})) == true, + "legacy priority ids keep resolving") + + -- highCrit matches the legacy table's boosted rate + local battler = { def = { baseStats = { speed = 128 } } } + local function critCount(moveId, highCrit) + local n = 0 + for i = 0, 255 do + if Damage.critRoll(ruleset, battler, moveId, function() return i end, + highCrit) then + n = n + 1 + end + end + return n + end + check(critCount("TACKLE", true) == critCount("SLASH", nil), + "highCrit = true matches the legacy high-crit list") + check(critCount("TACKLE", nil) == 64, "an unmarked move keeps the normal rate") + + -- fixedDamage field through the SPECIAL_DAMAGE record + Data.moves.TEST_FIX = { id = "TEST_FIX", name = "TEST FIX", type = "NORMAL", + power = 1, accuracy = 100, pp = 10, effect = "SPECIAL_DAMAGE_EFFECT", + fixedDamage = 15 } + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + local battle = BattleState.newWild(game, "SNORLAX", 30) + battle.rng = mkseq({ 0 }) + local before = battle.enemy.mon.hp + battle:performMove(battle.player, battle.enemy, { id = "TEST_FIX", pp = 10 }) + check(before - battle.enemy.mon.hp == 15, "fixedDamage field sets the damage") + Data.moves.TEST_FIX = nil + + -- chargeText and semiInvulnerable fields + Data.moves.TEST_CHARGE = { id = "TEST_CHARGE", name = "TEST CHARGE", + type = "NORMAL", power = 40, accuracy = 100, pp = 10, + effect = "CHARGE_EFFECT", chargeText = "%s\nis winding up!", + semiInvulnerable = true } + local cb = BattleState.newWild(makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }), + "RATTATA", 5) + cb.rng = mkseq({}) + local inst = { id = "TEST_CHARGE", pp = 10 } + cb:performMove(cb.player, cb.enemy, inst) + check(cb.player.charging == inst, "charge record starts the charge turn") + check(cb.player.invulnerable == true, "semiInvulnerable field goes invulnerable") + check(hasText(cb, "is winding up!"), "chargeText field picks the text") + Data.moves.TEST_CHARGE = nil + + -- counterable field replaces the Normal/Fighting whitelist + local counterGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + local counter = BattleState.newWild(counterGame, "SNORLAX", 30) + counter.rng = mkseq({ 0 }) + counter.lastDamage = 30 + counter.enemy.lastMove = "WATER_GUN" + counter:performMove(counter.player, counter.enemy, { id = "COUNTER", pp = 10 }) + check(hasText(counter, "attack missed!"), "a Water move is not counterable") + Data.moves.WATER_GUN.counterable = true + local counter2 = BattleState.newWild(counterGame, "SNORLAX", 30) + counter2.rng = mkseq({ 0 }) + counter2.lastDamage = 30 + counter2.enemy.lastMove = "WATER_GUN" + local hp = counter2.enemy.mon.hp + counter2:performMove(counter2.player, counter2.enemy, { id = "COUNTER", pp = 10 }) + check(hp - counter2.enemy.mon.hp == 60, "counterable = true doubles the last damage") + Data.moves.WATER_GUN.counterable = nil + + -- multiHit field: a plain count consumes no distribution roll + Data.moves.TEST_MULTI = { id = "TEST_MULTI", name = "TEST MULTI", + type = "NORMAL", power = 15, accuracy = 100, pp = 10, + effect = "TWO_TO_FIVE_ATTACKS_EFFECT", multiHit = 2 } + local mh = BattleState.newWild(makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }), + "SNORLAX", 30) + mh.rng = mkseq({ 0, 255, 255 }) + mh:performMove(mh.player, mh.enemy, { id = "TEST_MULTI", pp = 10 }) + check(hasText(mh, "Hit the enemy\n2 times!"), "multiHit = 2 lands two hits") + Data.moves.TEST_MULTI = nil +end + +-- ------- constants: badge boosts and exp tuning + +do + local function plain(badges, boosts) + return { curStats = { attack = 10, defense = 10, speed = 10, special = 10 }, + stages = {}, curTypes = {}, badges = badges, badgeBoosts = boosts, + name = "TEST", mon = { level = 10 }, + def = { baseStats = { speed = 10 } } } + end + local physTest = { id = "PHYS", power = 100, type = "NORMAL", accuracy = 100 } + local maxRoll = { rng = function() return 255 end, forceCrit = false } + local rows = { { badge = "ZAPBADGE", stat = "attack", num = 2, den = 1 } } + local boosted = Damage.compute(ruleset, plain({ ZAPBADGE = true }, rows), + plain(nil), physTest, maxRoll) + check(boosted == 26, "a registered badge boost row rescales the stat") + local speedRows = { { badge = "ZAPBADGE", stat = "speed", num = 2, den = 1 } } + check(TurnOrder.effectiveSpeed(plain({ ZAPBADGE = true }, speedRows)) == 20, + "a registered speed boost row reaches TurnOrder") + + local rat = Data.pokemon.RATTATA + check(Experience.gainFor(rat, 10, false, 1, false, { exp = { divisor = 14 } }) + == math.floor(rat.baseExp * 10 / 14), + "constants.exp.divisor retunes the exp formula") + check(Experience.gainFor(rat, 10, false, 1, false) == + math.floor(rat.baseExp * 10 / 7), "no constants keeps the /7 formula") + check(Growth.levelForExp("MEDIUM_FAST", 100000000, 50) == 50, + "levelForExp honors the level cap") +end + +-- ------- growth rates registry + +do + local rates = { TESTCURVE = { expForLevel = function(n) return n * 100 end } } + check(Growth.expForLevel("TESTCURVE", 3, rates) == 300, + "a registered curve resolves through the rates table") + check(Growth.levelForExp("TESTCURVE", 500, 100, rates) == 5, + "levelForExp walks a registered curve") + check(Growth.expForLevel("MEDIUM_FAST", 10) == 1000, + "vanilla curves unchanged without a rates table") +end + +-- ------- evolution methods and the evolution.check hook + +do + local egame = { data = { + pokemon = { TESTMON = { evolutions = { + { method = "LEVEL", level = 5, species = "RAICHU" } } } }, + } } + local mon = { species = "TESTMON", level = 10 } + local species = Evolution.pendingFor(egame, mon, { kind = "levelup" }) + check(species == "RAICHU", "LEVEL method fires through pendingFor") + check(Evolution.pendingFor(egame, mon, { kind = "trade" }) == nil, + "a trade trigger does not fire LEVEL") + + local unsub = hooks:wrap("evolution.check", function() return false end) + check(Evolution.pendingFor(egame, mon, { kind = "levelup" }) == nil, + "evolution.check can cancel an evolution") + unsub() + check(Evolution.pendingFor(egame, mon, { kind = "levelup" }) == "RAICHU", + "unhooked dispatch is vanilla again") + + local fgame = { data = { + pokemon = { TESTMON = { evolutions = { + { method = "FRIENDSHIP", species = "RAICHU" } } } }, + evolution_methods = { FRIENDSHIP = { check = function(g, m, evo, trigger) + return trigger.kind == "levelup" and (m.friendship or 0) >= 200 + end } }, + } } + local buddy = { species = "TESTMON", level = 5, friendship = 250 } + check(Evolution.pendingFor(fgame, buddy, { kind = "levelup" }) == "RAICHU", + "a registered evolution method fires") + buddy.friendship = 0 + check(Evolution.pendingFor(fgame, buddy, { kind = "levelup" }) == nil, + "the registered method's own gate holds") +end + +-- ------- the rare-candy flow runs the hook-wrapped dispatch + +do + local Bag = require("src.inventory.Bag") + local BagMenu = require("src.ui.BagMenu") + + local pressed = {} + local function uiGame(party) + local save = SaveData.newGame() + save.party = party + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + return { data = Data, save = save, stack = stack, + input = { wasPressed = function(_, key) + return pressed[key] or false + end, + isDown = function() return false end } } + end + + -- feed one candy through the real bag UI: press A through the item + -- list, USE, the party pick, the level text, the stat box and any + -- evolution text until every state has popped + local function candyFlow() + local mon = Pokemon.new(Data, "CHARMANDER", 15) + local game = uiGame({ mon }) + Bag.add(game.save, "RARE_CANDY", 1) + game.stack:push(BagMenu.new(game)) + for _ = 1, 600 do + local top = game.stack:top() + if not top then break end + pressed = { a = true } + top:update(1) + pressed = {} + end + check(game.stack:top() == nil, "the candy flow runs to completion") + return mon + end + + local fed = candyFlow() + check(fed.level == 16, "the candy levels the mon") + check(fed.species == "CHARMELEON", "the level evolution fires afterwards") + + local unsub = hooks:wrap("evolution.check", function() return false end) + local blocked = candyFlow() + check(blocked.level == 16, "the cancel hook leaves the level gain alone") + check(blocked.species == "CHARMANDER", + "evolution.check gates the rare-candy evolution") + unsub() +end + +-- ------- battle hooks: pass-through, transform, isolation + +do + local function tackleProbe() + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) + local battle = BattleState.newWild(game, "SNORLAX", 30) + battle.rng = mkseq({ 0, 255, 255 }) + local before = battle.enemy.mon.hp + battle:performMove(battle.player, battle.enemy, { id = "TACKLE", pp = 10 }) + return before - battle.enemy.mon.hp + end + + local baseline = tackleProbe() + check(baseline > 0, "baseline tackle deals damage") + check(tackleProbe() == baseline, "unhooked damage is deterministic") + + local unsub = hooks:wrap("battle.damage", function(nextFn, ctx) + local dmg, info = nextFn(ctx) + return dmg * 2, info + end) + check(tackleProbe() == baseline * 2, "battle.damage hook doubles the damage") + unsub() + check(tackleProbe() == baseline, "unwrapped damage is vanilla again") + + unsub = hooks:wrap("battle.damage", function() error("boom") end) + check(tackleProbe() == baseline, "a throwing damage wrapper is skipped") + unsub() + + unsub = hooks:wrap("battle.crit", function() return true end) + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) + local critBattle = BattleState.newWild(game, "SNORLAX", 30) + critBattle.rng = mkseq({ 0, 255 }) -- accuracy, damage; the hook owns the crit + critBattle:performMove(critBattle.player, critBattle.enemy, { id = "TACKLE", pp = 10 }) + check(hasText(critBattle, "Critical hit!"), "battle.crit hook forces a crit") + unsub() + + unsub = hooks:wrap("battle.accuracy", function() return false end) + local missBattle = BattleState.newWild(game, "SNORLAX", 30) + missBattle.rng = mkseq({}) + missBattle:performMove(missBattle.player, missBattle.enemy, { id = "TACKLE", pp = 10 }) + check(hasText(missBattle, "attack missed!"), "battle.accuracy hook forces a miss") + unsub() + + unsub = hooks:wrap("catch.rate", function() return true, 3 end) + local catchBattle = BattleState.newWild(game, "SNORLAX", 30) + catchBattle.rng = mkseq({}) + local caught, shakes = catchBattle:catchAttempt("POKE_BALL") + check(caught == true and shakes == 3, "catch.rate hook decides the catch") + unsub() + + unsub = hooks:wrap("exp.gain", function(nextFn, ctx) + return nextFn(ctx) * 2 + end) + local mon = Pokemon.new(Data, "BULBASAUR", 10) + local expBefore = mon.exp + local _, gained = Experience.apply(Data, mon, Data.pokemon.RATTATA, 10, + false, 1, false) + check(gained == Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, false) * 2, + "exp.gain hook doubles the award") + check(mon.exp == expBefore + gained, "the doubled award is what lands") + unsub() + + local sawOrder = false + unsub = hooks:wrap("battle.turn_order", function(nextFn, a, aMove, b, bMove, ctx) + sawOrder = true + return nextFn(a, aMove, b, bMove, ctx) + end) + local orderGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) + local orderBattle = BattleState.newWild(orderGame, "RATTATA", 5) + orderBattle.rng = mkseq({}) + orderBattle:resolveTurn(orderBattle.player.curMoves[1]) + check(sawOrder, "battle.turn_order hook wraps the order roll") + unsub() + + unsub = hooks:wrap("battle.run", function() return true end) + local runGame = makeGame({ Pokemon.new(Data, "CATERPIE", 3) }) + local runBattle = BattleState.newWild(runGame, "RATTATA", 30) + runBattle.rng = mkseq({ 0 }) + runBattle:tryRun() + check(runBattle.result == "run", "battle.run hook forces the escape") + unsub() + + unsub = hooks:wrap("battle.enemy_action", function() + return { id = "TACKLE", pp = 1, hooked = true } + end) + local actGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) + local actBattle = BattleState.newWild(actGame, "RATTATA", 5) + check(actBattle:enemyAction().hooked == true, + "battle.enemy_action hook rewrites the choice") + unsub() +end + +-- ------- battle events: the scripted sequence + +do + local log = {} + local function listen(name) + events:on(name, function(payload) + log[#log + 1] = { name = name, payload = payload } + end) + end + for _, name in ipairs({ "battle.started", "battle.turn_started", + "battle.turn_ended", "battle.move_used", "battle.damage_dealt", + "battle.fainted", "battle.exp_gained", "battle.ended", + "battle.status_inflicted", "battle.ball_thrown", "pokemon.caught", + "battle.battler_switched" }) do + listen(name) + end + local function indexOf(name) + for i, entry in ipairs(log) do + if entry.name == name then return i end + end + return nil + end + + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) }) + local battle = BattleState.newWild(game, "RATTATA", 2) + battle.onFinish = function() end + battle:enter() + check(indexOf("battle.started") ~= nil, "battle.started fires on enter") + check(log[indexOf("battle.started")].payload.kind == "wild", + "battle.started carries the kind") + battle.rng = function(a) return a end -- min rolls: the move always hits + battle:resolveTurn(battle.player.curMoves[1]) + pump(battle) + battle:finish() + check(indexOf("battle.turn_started") ~= nil, "battle.turn_started fires") + check(indexOf("battle.move_used") ~= nil, "battle.move_used fires") + check(indexOf("battle.damage_dealt") ~= nil, "battle.damage_dealt fires") + check(indexOf("battle.fainted") ~= nil, "battle.fainted fires") + check(indexOf("battle.turn_ended") ~= nil, "battle.turn_ended fires") + check(indexOf("battle.exp_gained") ~= nil, "battle.exp_gained fires") + check(indexOf("battle.ended") ~= nil, "battle.ended fires") + check(indexOf("battle.started") < indexOf("battle.turn_started") + and indexOf("battle.turn_started") < indexOf("battle.move_used") + and indexOf("battle.move_used") < indexOf("battle.damage_dealt") + and indexOf("battle.damage_dealt") < indexOf("battle.fainted") + and indexOf("battle.fainted") < indexOf("battle.ended"), + "the battle events fire in order") + + -- status_inflicted on a landing Thunder Wave + local waveGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 10) }) + local wave = BattleState.newWild(waveGame, "RATTATA", 5) + wave.rng = mkseq({ 254 }) + wave:performMove(wave.player, wave.enemy, { id = "THUNDER_WAVE", pp = 10 }) + local inflicted = indexOf("battle.status_inflicted") + check(inflicted ~= nil and log[inflicted].payload.status == "PAR", + "battle.status_inflicted carries the status") + + -- ball_thrown + pokemon.caught (box destination, no UI rows) + local party = {} + for _ = 1, 6 do party[#party + 1] = Pokemon.new(Data, "PIDGEY", 5) end + local catchGame = makeGame(party) + catchGame.save.pokedex.owned.RATTATA = true + local catchBattle = BattleState.newWild(catchGame, "RATTATA", 3) + catchBattle.onFinish = function() end + catchBattle.rng = function(a) return a end + catchBattle.queue = {} + catchBattle:throwBall("POKE_BALL") + pump(catchBattle) + local thrown = indexOf("battle.ball_thrown") + check(thrown ~= nil and log[thrown].payload.caught == true, + "battle.ball_thrown reports the outcome") + local caughtIdx = indexOf("pokemon.caught") + check(caughtIdx ~= nil, "pokemon.caught fires") + check(log[caughtIdx].payload.ball == "POKE_BALL" + and log[caughtIdx].payload.destination == "box", + "pokemon.caught carries ball and destination") + + -- battler_switched on a mid-battle switch + local swGame = makeGame({ Pokemon.new(Data, "BULBASAUR", 20), + Pokemon.new(Data, "PIDGEY", 20) }) + local swBattle = BattleState.newWild(swGame, "RATTATA", 5) + swBattle.rng = mkseq({}) + swBattle:resolveSwitch(swGame.save.party[2]) + pump(swBattle) + local switched = indexOf("battle.battler_switched") + check(switched ~= nil and log[switched].payload.side.index == 1, + "battle.battler_switched names the side") +end + +-- ------- side/field substrate + +do + local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) }) + local battle = BattleState.newWild(game, "RATTATA", 5) + battle:syncSides() + check(battle.sides[1].battlers[1] == battle.player + and battle.sides[2].battlers[1] == battle.enemy, + "sides mirror the singles battlers") + check(battle:sideOf(battle.enemy).index == 2, "sideOf maps by side") + check(battle.field.sides == battle.sides and battle.field.weather == nil, + "the field substrate starts empty") + + local residuals, expired = 0, false + table.insert(battle.sides[2].tokens, { id = "test", turns = 2, + onResidual = function() residuals = residuals + 1 end, + onExpire = function() expired = true end }) + table.insert(battle.field.tokens, { id = "haze", turns = 1, + onExpire = function() end }) + battle.rng = mkseq({}) + battle:endOfTurn() + check(residuals == 1 and not expired, "side tokens tick each end of turn") + check(#battle.field.tokens == 0, "an expired field token is removed") + battle:endOfTurn() + check(expired and #battle.sides[2].tokens == 0, + "a side token expires after its turns run out") +end + +Runtime.install(savedEvents, savedHooks) + +S.finish() diff --git a/tests/mod_catalog_tests.lua b/tests/mod_catalog_tests.lua new file mode 100644 index 00000000..a0ed4825 --- /dev/null +++ b/tests/mod_catalog_tests.lua @@ -0,0 +1,375 @@ +-- The D3 catalog completed in M4: a round-trip through every registry the +-- milestone adds, a crafted rejection per registry, the engine's own +-- registrations checked against their own schemas, and the type_chart +-- category oracle against the Damage.isSpecial list it will replace. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Loader = require("src.mods.Loader") +local Schemas = require("src.mods.Schemas") +local Merge = require("src.mods.Merge") +local Builtins = require("src.mods.Builtins") +local TypeChart = require("src.battle.TypeChart") +local Damage = require("src.battle.Damage") +local Commands = require("src.script.Commands") + +local S = require("tests.harness").suite("mod catalog") +local check = S.check + +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +-- ------- the parity oracle: type category vs the hard-coded special list + +-- Damage.isSpecial is still the live implementation; M7 replaces its body +-- with this lookup, so the two must already agree on every vanilla type. +local typeCount = 0 +for id, record in pairs(TypeChart.TYPES) do + typeCount = typeCount + 1 + check((record.category == "special") == Damage.isSpecial(id), + "type_chart category matches Damage.isSpecial for " .. id) +end +check(typeCount == 15, "all 15 vanilla types carry a category record") + +-- and the reverse direction: nothing outside the chart is special +check(not Damage.isSpecial("FAIRY"), "an unknown type is not special") + +-- ------- the fixture + +local vanillaChart = require("data.generated.type_chart") + +local function fixtureData() + return { + pokemon = {}, moves = {}, + items = { POTION = { id = "POTION", name = "POTION", price = 300 } }, + type_chart = Merge.deepCopy(vanillaChart), + } +end + +-- one registration per registry this milestone adds, all cross-consistent +local catalogMod = [[ +return function(mod) + local function handler() end + mod.content.type_chart:register("FAIRY", { name = "FAIRY", category = "special" }) + mod.content.type_chart:register("FAIRY>DRAGON", { multiplier = 20 }) + mod.content.statuses:register("CRS", { id = "CRS", label = "CRS", + hudLabel = "CRS", catchBonus = 12 }) + mod.content.move_effects:register("SAP_PP_EFFECT", { kind = "primary", run = handler }) + mod.content.item_effects:register("MOON_FLUTE", { use = handler, field = true }) + mod.content.balls:register("DUSK_BALL", { randMax = 100, hpFactor = 10 }) + mod.content.evolution_methods:register("FRIENDSHIP", { check = handler }) + mod.content.growth_rates:register("ERRATIC", + { expForLevel = function(level) return level * level end }) + mod.content.rulesets:register("no_crits", { name = "no crits", critRate = 0 }) + mod.content.ai_classes:register("OPP_MODDER", + { uses = 2, chance = 64, item = "POTION" }) + mod.content.battle_anims:register("SHADOW_BALL", { seq = { 1, 2 } }) + mod.content.battle_anims:register("subanim:99", { blocks = { 1 }, type = "shake" }) + mod.content.battle_anims:register("tilesheet:9", + { path = "anim.png", width = 8, height = 8, tiles = 1 }) + mod.content.palettes:register("MODMON", + { { 255, 255, 255 }, { 200, 200, 200 }, { 100, 100, 100 }, { 0, 0, 0 } }) + mod.content.icons:register("PIKACHU", "SPARK") + mod.content.font:register("cyrillic", { image = "cyr.png", base = 128, + glyphsPerRow = 16, charmap = { { code = 200, seq = "" } } }) + mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" }) + mod.content.cries:register("MODMON", { file = "cry.ogg" }) + mod.content.music:register("Music_ModTheme", { file = "theme.ogg" }) + mod.content.map_songs:register("MOD_TOWN", "Music_ModTheme") + mod.content.commands:register("shake_screen", handler) + mod.content.tokens:register("CLOCK", handler) + mod.content.transitions:register("dissolve", { frames = 30, draw = handler }) + mod.content.text_pointers:patch("PalletTown", + { TEXT_MOD_SIGN = { text = "_ModSign", mart = { "POTION" } } }) + mod.content.migrations:register("catalog", { since = "1.0.0", run = handler }) +end +]] + +local data = fixtureData() +local loader = Loader.new({ fs = memfs({ + ["mods/catalog/manifest.json"] = + '{"id":"catalog","name":"catalog","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/catalog/main.lua"] = catalogMod, +}) }) +check(loader:load(data) == true, + "the catalog mod loads clean: " .. table.concat(loader.errors, "; ")) + +-- ------- round-trip: every registration reaches its Data target + +local function at(path) + local node = data + for key in path:gmatch("[^%.]+") do + if type(node) ~= "table" then return nil end + node = node[key] + end + return node +end + +check(at("type_chart.types").FAIRY.category == "special", "type_chart type record merges") +check(at("statuses").CRS.label == "CRS", "statuses record merges") +check(at("move_effects").SAP_PP_EFFECT.kind == "primary", "move_effects record merges") +check(at("item_effects").MOON_FLUTE.field == true, "item_effects record merges") +check(at("balls").DUSK_BALL.randMax == 100, "balls record merges") +check(at("evolution_methods").FRIENDSHIP.check ~= nil, "evolution_methods record merges") +check(at("growth_rates").ERRATIC.expForLevel(5) == 25, "growth_rates record merges") +check(at("rulesets").no_crits.critRate == 0, "rulesets record merges") +check(at("ai_classes").OPP_MODDER.item == "POTION", "ai_classes record merges") +check(at("battle_anims.moveAnims").SHADOW_BALL.seq[2] == 2, "battle_anims move id merges") +check(at("battle_anims.subanims")[99].type == "shake", "battle_anims subanim id routes") +check(at("battle_anims.tilesheets")[9].tiles == 1, "battle_anims tilesheet id routes") +check(#at("palettes.palettes").MODMON == 4, "palettes record merges") +check(at("icons.bySpecies").PIKACHU == "SPARK", "icons record merges keyed by species") +check(at("font.pages").cyrillic.base == 128, "font page record merges") +check(at("audio.sfx").SFX_MOD_CHIME.file == "chime.ogg", "sfx record merges") +check(at("audio.cries").MODMON.file == "cry.ogg", "cries record merges") +check(at("audio.mapSongs").MOD_TOWN == "Music_ModTheme", "map_songs record merges") +check(at("commands").shake_screen ~= nil, "commands record merges") +check(at("tokens").CLOCK ~= nil, "tokens record merges") +check(at("transitions").dissolve.frames == 30, "transitions record merges") +check(at("text_pointers").PalletTown.TEXT_MOD_SIGN.text == "_ModSign", + "text_pointers deep key merges") +check(#loader.content.migrations:chain("catalog") == 1, "migrations chain accumulates") + +-- registry reads agree with the merged tables +check(loader.content.balls:get("DUSK_BALL").hpFactor == 10, "get returns the mod record") +check(loader.content.battle_anims:get("subanim:99").type == "shake", + "get resolves a routed id") + +-- ------- each(): the merged vanilla + mod view + +local function idsOf(name) + local ids = {} + for id in loader.content[name]:each() do ids[id] = true end + return ids +end + +local statusIds = idsOf("statuses") +check(statusIds.PAR and statusIds.CRS, "statuses each() yields engine and mod ids") +local commandIds = idsOf("commands") +check(commandIds.show_text and commandIds.shake_screen, + "commands each() yields engine and mod ids") +local typeIds = idsOf("type_chart") +check(typeIds.NORMAL and typeIds.FAIRY and typeIds["FAIRY>DRAGON"], + "type_chart each() yields type records and matchup rows") +local ballIds = idsOf("balls") +check(ballIds.POKE_BALL and ballIds.DUSK_BALL, "balls each() yields engine and mod ids") +local animIds = idsOf("battle_anims") +check(animIds.SHADOW_BALL and animIds["subanim:99"], + "battle_anims each() yields both id forms") + +-- ------- type_chart merge rebuilds the row array the consumer reads + +local rebuilt = data.type_chart.matchups +check(#rebuilt == #vanillaChart.matchups + 1, + "the rebuilt chart keeps every vanilla row and adds the mod's") +for index, row in ipairs(vanillaChart.matchups) do + local got = rebuilt[index] + check(got.attacker == row.attacker and got.defender == row.defender + and got.multiplier == row.multiplier, + "rebuilt matchup row " .. index .. " is the vanilla row, in order") +end +local added = rebuilt[#rebuilt] +check(added.attacker == "FAIRY" and added.defender == "DRAGON" + and added.multiplier == 20, "a registered matchup lands as a chart row") + +-- ------- the engine's own registrations satisfy their own schemas + +for name, registry in pairs(loader.content) do + local spec = registry.spec + for id in pairs(registry.ops) do + if registry.owners[id] == Builtins.OWNER then + local ok, err = Schemas.check(spec, name, id, registry:get(id), "register") + check(ok, "vanilla " .. name .. " record validates: " .. tostring(err)) + end + end +end + +-- the registry serves the same function object the dispatcher calls, so +-- there is no window in which the two disagree +check(data.commands.show_text == Commands.show_text, + "a registered command is the engine's own handler") + +-- ------- schema rejection, one crafted violation per registry + +local rejections = { + { "type_chart", "FIRE", { name = "FIRE", category = "elemental" } }, + { "statuses", "CRS", { label = 7 } }, + { "move_effects", "X", { kind = "tertiary" } }, + { "item_effects", "X", { use = "not a function" } }, + { "balls", "X", { randMax = 900 } }, + { "evolution_methods", "X", { check = "nope" } }, + { "growth_rates", "X", { expForLevel = function() return 5 end } }, + { "rulesets", "X", { name = false } }, + { "ai_classes", "X", { uses = "many" } }, + { "battle_anims", "X", { seq = "not a list" } }, + { "palettes", "X", { { 1, 2, 3 }, { 4, 5, 6 } } }, + { "icons", "X", { frames = 2 } }, + { "font", "X", { image = "f.png", base = 0, glyphsPerRow = 1, + charmap = { { code = -1, seq = "x" } } } }, + { "sfx", "X", { file = 12 } }, + { "cries", "X", { pitch = "high" } }, + { "map_songs", "X", 42 }, + { "commands", "X", "not a function" }, + { "tokens", "X", { 1, 2 } }, + { "transitions", "X", { frames = 0 } }, + { "text_pointers", "PalletTown", { TEXT_X = { asm = "yes" } } }, + { "migrations", "mod", { since = 1, run = function() end } }, +} +local covered = {} +for _, case in ipairs(rejections) do + local name, id, value = case[1], case[2], case[3] + covered[name] = true + local spec = Schemas.REGISTRIES[name] + check(spec ~= nil, name .. " is in the catalog") + local ok, err = Schemas.check(spec, name, id, value, "register") + check(not ok, name .. " rejects a malformed record") + check(type(err) == "string" and err:find(name .. "." .. id, 1, true) ~= nil, + name .. " names the offending path: " .. tostring(err)) +end + +-- the milestone's registry list, so a catalog entry cannot land untested +for _, name in ipairs({ "type_chart", "statuses", "move_effects", "item_effects", + "balls", "evolution_methods", "growth_rates", "rulesets", "ai_classes", + "battle_anims", "palettes", "icons", "font", "sfx", "cries", "map_songs", + "commands", "tokens", "transitions", "text_pointers", "migrations" }) do + check(Schemas.REGISTRIES[name] ~= nil, name .. " is declared") + check(covered[name], name .. " has a rejection case") + check(loader.content[name] ~= nil, name .. " is built by the loader") +end + +-- ------- a schema violation is a load error for an api 2 mod + +local badData = fixtureData() +local badLoader = Loader.new({ fs = memfs({ + ["mods/bad/manifest.json"] = + '{"id":"bad","name":"bad","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/bad/main.lua"] = [[ +return function(mod) + mod.content.balls:register("SNAG_BALL", { randMax = 4096 }) +end +]], +}) }) +check(badLoader:load(badData) == false, "a malformed catalog record fails the mod") +check(table.concat(badLoader.errors, "\n"):find("balls.SNAG_BALL", 1, true) ~= nil, + "the failure names the registry and id") +check(badData.balls.SNAG_BALL == nil, "a rejected record leaves no residue") +check(badData.balls.POKE_BALL ~= nil, "the engine's own balls still merged") + +-- ------- a mod must say override to replace an engine record + +local clashData = fixtureData() +local clashLoader = Loader.new({ fs = memfs({ + ["mods/clash/manifest.json"] = + '{"id":"clash","name":"clash","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/clash/main.lua"] = [[ +return function(mod) + mod.content.balls:register("GREAT_BALL", { randMax = 180 }) +end +]], +}) }) +check(clashLoader:load(clashData) == false, "registering over an engine record fails") +check(clashData.balls.GREAT_BALL.randMax == 200, "the vanilla record survives") + +local overData = fixtureData() +local overLoader = Loader.new({ fs = memfs({ + ["mods/over/manifest.json"] = + '{"id":"over","name":"over","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/over/main.lua"] = [[ +return function(mod) + mod.content.balls:override("GREAT_BALL", { randMax = 180, hpFactor = 12 }) + mod.content.type_chart:override("FIRE", { name = "FIRE", category = "physical" }) +end +]], +}) }) +check(overLoader:load(overData) == true, "override replaces an engine record") +check(overData.balls.GREAT_BALL.randMax == 180, "the override reaches Data") +check(overData.type_chart.types.FIRE.category == "physical", + "a retyped type reaches the rebuilt chart") + +-- ------- merge order is decided by the target paths, not by pairs() + +-- a whole-table registry has to merge before anything nested under it, or +-- its subtable swap drops the ids the granular registry already wrote +local order, position = loader:_mergeOrder(), {} +for index, name in ipairs(order) do position[name] = index end +local pairsChecked = 0 +for outer, outerRegistry in pairs(loader.content) do + local outerTarget = outerRegistry.spec.target + for inner, innerRegistry in pairs(loader.content) do + local innerTarget = innerRegistry.spec.target + if outerTarget and innerTarget and outer ~= inner + and innerTarget:sub(1, #outerTarget + 1) == outerTarget .. "." then + pairsChecked = pairsChecked + 1 + check(position[outer] < position[inner], + outer .. " must merge before " .. inner) + end + end +end +check(pairsChecked >= 4, "the audio family exercises the prefix rule") + +-- and the same content always yields the same order +local sameOrder = Loader.new({ fs = memfs({}) }):_mergeOrder() +check(#sameOrder == #order, "the merge order covers every registry") +for index, name in ipairs(order) do + check(sameOrder[index] == name, "merge order is stable at slot " .. index) +end + +-- ------- the deprecated audio registry coexists with the granular ones + +local mixedData = fixtureData() +local mixedLoader = Loader.new({ fs = memfs({ + ["mods/legacy/manifest.json"] = + '{"id":"legacy","name":"legacy","version":"1.0.0","entry":"main.lua","api":1}', + ["mods/legacy/main.lua"] = [[ +return function(mod) + mod.content.audio:override("cries", { OLD = { file = "old.ogg" } }) + mod.content.audio:override("sfx", { OLD = "SFX_OLD" }) +end +]], + ["mods/granular/manifest.json"] = + '{"id":"granular","name":"granular","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/granular/main.lua"] = [[ +return function(mod) + mod.content.cries:register("NEW", { file = "new.ogg" }) + mod.content.sfx:register("NEW", { file = "new.ogg" }) +end +]], +}) }) +check(mixedLoader:load(mixedData) == true, + "the v1 and v2 audio registries load together: " + .. table.concat(mixedLoader.errors, "; ")) +check(mixedData.audio.cries.OLD ~= nil and mixedData.audio.cries.NEW ~= nil, + "the whole-table cries swap and the granular id both survive") +check(mixedData.audio.sfx.OLD ~= nil and mixedData.audio.sfx.NEW ~= nil, + "the whole-table sfx swap and the granular id both survive") + +S.finish() diff --git a/tests/mod_constants_tests.lua b/tests/mod_constants_tests.lua new file mode 100644 index 00000000..78825fd7 --- /dev/null +++ b/tests/mod_constants_tests.lua @@ -0,0 +1,419 @@ +-- The constants & field deep registries: engine-seeded vanilla defaults, +-- per-key deep merge (siblings survive, lists extend, override replaces), +-- schema and cross-reference enforcement, and the field.boot config read +-- at new-game time. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Data = require("src.core.Data") +local Loader = require("src.mods.Loader") +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") +local SaveData = require("src.core.SaveData") +local Badges = require("src.inventory.Badges") + +local S = require("tests.harness").suite("mod constants") +local check = S.check + +Data:load() + +-- the fs surface the loader needs, backed by a flat path->content table +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function manifestJson(id) + return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua","api":2}]]) + :format(id, id) +end + +-- a private copy of the real tables: these tests merge into their data, and +-- the suites that run after this one read the live Data +local function fixture() + return { + constants = Merge.deepCopy(Data.constants), + field = Merge.deepCopy(Data.field), + items = Merge.deepCopy(Data.items), + moves = Merge.deepCopy(Data.moves), + tilesets = Merge.deepCopy(Data.tilesets), + } +end + +local function deepEqual(a, b, path) + path = path or "root" + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false, path end + for k, v in pairs(a) do + local ok, where = deepEqual(v, b[k], path .. "." .. tostring(k)) + if not ok then return false, where end + end + for k in pairs(b) do + if a[k] == nil then return false, path .. "." .. tostring(k) end + end + return true +end + +-- run one inline mod against a fresh fixture; returns the merged data +local function withMod(id, source) + local loader = Loader.new({ fs = memfs({ + ["mods/" .. id .. "/manifest.json"] = manifestJson(id), + ["mods/" .. id .. "/main.lua"] = source, + }) }) + local data = fixture() + local ok = loader:load(data) + return data, loader, ok +end + +-- ------- engine-seeded vanilla defaults + +check(Data.constants.bagSize == 20, "bagSize seeded from BAG_ITEM_CAPACITY") +check(Data.constants.partyMax == 6, "partyMax seeded") +check(Data.constants.boxCount == 12 and Data.constants.boxSize == 20, + "box geometry seeded") +check(Data.constants.moveMax == 4, "moveMax seeded") +check(Data.constants.levelCap == 100, "levelCap seeded") +check(Data.constants.coinCap == 9999, "coinCap seeded") +check(Data.constants.dexSize == 151, "dexSize derived from the merged roster") +check(Data.constants.dexDigits == 3, "dexDigits derived from dexSize") +check(#Data.constants.badges == 8 + and Data.constants.badges[1].id == "BOULDERBADGE" + and Data.constants.badges[8].id == "EARTHBADGE", + "the eight Kanto badges seeded in gym order") +check(#Data.constants.hmMoves == 5 and Data.constants.hmMoves[1] == "CUT", + "hmMoves seeded") + +local boot = Data.field.boot +check(boot.startMap == "PALLET_TOWN" and boot.startX == 5 and boot.startY == 6 + and boot.startFacing == "down", "field.boot seeded with the Pallet spawn") +check(boot.playerName == "RED" and boot.rivalName == "BLUE" + and boot.startMoney == 3000, "field.boot seeded with the Red new-game values") +check(boot.namePresets.player[1] == "RED" and boot.namePresets.rival[1] == "BLUE", + "field.boot name presets seeded from the extracted preset names") +check(boot.screens.title == "TitleState", "field.boot boot-screen chain seeded") +-- the presets are copied, not aliased, so a boot patch cannot rewrite the +-- table the naming screen data came from +check(boot.namePresets.player ~= Data.field.presetNames.player, + "seeded presets are a copy of field.presetNames") + +-- every seeded and imported key satisfies the catalog schema, so a mod +-- that copies a vanilla value back in always validates +for _, pair in ipairs({ { "constants", Data.constants }, { "field", Data.field } }) do + local name, table_ = pair[1], pair[2] + local spec = Schemas.REGISTRIES[name] + for id, value in pairs(table_) do + local ok, err = Schemas.check(spec, name, id, value, "override") + check(ok, "vanilla " .. name .. " key validates: " .. tostring(err)) + end +end + +-- ------- parity: with no mod, neither table moves + +local parityData = fixture() +local snapshot = Merge.deepCopy(parityData) +local emptyLoader = Loader.new({ fs = memfs({}) }) +check(emptyLoader:load(parityData) == true, "empty load succeeds") +-- the engine's own registrations own the namespaces they create; the deep +-- tables this suite is about must not move +local engineRoots = require("src.mods.Builtins").namespaceRoots() +local carried = {} +for key, value in pairs(parityData) do + if snapshot[key] ~= nil then carried[key] = value + else check(engineRoots[key], "only engine namespaces appear (saw " .. key .. ")") end +end +local same, where = deepEqual(carried, snapshot) +check(same, "no-mod merge leaves constants and field identical (differs at " + .. tostring(where) .. ")") + +-- ------- deep merge of one constant leaves every sibling intact + +local capData, capLoader = withMod("rebalance", [[ +return function(mod) + mod.content.constants:patch("levelCap", 80) +end +]]) +check(#capLoader.errors == 0, "constants patch loads cleanly: " + .. table.concat(capLoader.errors, "; ")) +check(capData.constants.levelCap == 80, "scalar constant patched") +check(capData.constants.bagSize == 20 and capData.constants.partyMax == 6 + and capData.constants.boxCount == 12 and capData.constants.moveMax == 4 + and capData.constants.coinCap == 9999 and capData.constants.dexSize == 151, + "sibling constants intact after a single-key patch") +check(#capData.constants.badges == 8 and #capData.constants.hmMoves == 5, + "sibling list constants intact after a single-key patch") +check(capData.constants.speciesOrder ~= nil and capData.constants.moveOrder ~= nil, + "imported constants the catalog does not describe survive the merge") +check(Data.constants.levelCap == 100, "the merge never touched the live Data") + +-- register is a synonym of patch on a deep registry +local regData = withMod("register_form", [[ +return function(mod) + mod.content.constants:register("levelCap", 55) +end +]]) +check(regData.constants.levelCap == 55, + "register merges instead of colliding on a deep key") + +-- ------- dexSize drives the dex upper bound + +local dexData, dexLoader = withMod("big_dex", [[ +return function(mod) + mod.content.constants:patch("dexSize", 200) +end +]]) +check(#dexLoader.errors == 0, "dexSize patch loads cleanly") +check(dexData.constants.dexSize == 200, "dexSize patched") +check(dexData.constants.dexDigits == 3, + "dexDigits is a separate constant a mod may leave alone") +check(dexData.constants.badges ~= nil and #dexData.constants.badges == 8, + "dexSize patch touched no other constant") + +-- the dex list itself: bound and number width come from the constants, so +-- a species numbered past the vanilla roster is reachable +local function dexList(constants, dex) + return require("src.ui.PokedexMenu").new({ + data = { constants = constants, + pokemon = { NEWMON = { id = "NEWMON", name = "NEWMON", dex = dex } } }, + save = { pokedex = { seen = { NEWMON = true }, owned = {} } }, + stack = { push = function() end }, + }).items +end +check(#dexList({ dexSize = 151, dexDigits = 3 }, 152) == 0, + "a species past dexSize is out of the list's range") +local widened = dexList({ dexSize = 200, dexDigits = 3 }, 152) +check(#widened == 1 and widened[1].label == "152 NEWMON", + "raising dexSize brings the species into the list") +local padded = dexList({ dexSize = 1000, dexDigits = 4 }, 152) +check(padded[1].label == "0152 NEWMON", + "dexDigits widens every dex number at once") +check(dexList({}, 151)[1].label == "151 NEWMON", + "an absent constant falls back to the Kanto bound and width") + +-- ------- badge list: lists accumulate, only override replaces + +local ninth = [[ + mod.content.items:register("NINTH_BADGE", + { id = "NINTH_BADGE", name = "NINTH BADGE", price = 0 }) +]] + +local appendData, appendLoader = withMod("ninth_gym", [[ +return function(mod) +]] .. ninth .. [[ + mod.content.constants:patch("badges", { __append = { { id = "NINTH_BADGE" } } }) +end +]]) +check(#appendLoader.errors == 0, "badge append loads cleanly: " + .. table.concat(appendLoader.errors, "; ")) +local appended = appendData.constants.badges +check(#appended == 9, "the __append wrapper extends the badge list") +check(appended[1].id == "BOULDERBADGE" and appended[8].id == "EARTHBADGE", + "Kanto's eight badges survive the append") +check(appended[9].id == "NINTH_BADGE", "the new badge lands last") +check(appended.__append == nil, "the extension wrapper never survives into Data") + +local prependData = withMod("zeroth_gym", [[ +return function(mod) +]] .. ninth .. [[ + mod.content.constants:patch("badges", { __prepend = { { id = "NINTH_BADGE" } } }) +end +]]) +check(prependData.constants.badges[1].id == "NINTH_BADGE" + and #prependData.constants.badges == 9, + "the __prepend wrapper extends the front of the list") + +-- a bare list is the same append: the wrapper is only needed where the +-- payload also carries dictionary keys, or to reach the front of the list +local bareData = withMod("two_gyms", [[ +return function(mod) +]] .. ninth .. [[ + mod.content.constants:patch("badges", { { id = "NINTH_BADGE" } }) +end +]]) +check(#bareData.constants.badges == 9 + and bareData.constants.badges[1].id == "BOULDERBADGE" + and bareData.constants.badges[9].id == "NINTH_BADGE", + "a bare list appends on a deep registry instead of erasing Kanto") + +local overrideData = withMod("one_gym", [[ +return function(mod) + mod.content.constants:override("badges", { { id = "EARTHBADGE" } }) +end +]]) +check(#overrideData.constants.badges == 1, + "override replaces the badge list, the total-conversion path") + +-- the badge consumers read the merged list, not their own copy +check(#Badges.list(appendData) == 9, "Badges.list reads constants.badges") +check(#Badges.list({}) == 8, "Badges.list falls back to the gym order") +local badgeSave = { inventory = { BOULDERBADGE = 1, NINTH_BADGE = 1 } } +check(Badges.count(appendData, badgeSave) == 2, + "Badges.count counts a mod-added badge") +check(Badges.count({}, badgeSave) == 1, + "the fallback list counts only the badges it knows") + +-- ------- field: per-map dictionaries and lists merge per key + +local worldData, worldLoader = withMod("sable_cove", [[ +return function(mod) + mod.content.field:patch("hiddenItems", { + SABLE_COVE = { { x = 3, y = 9, item = "NUGGET" } }, + }) + mod.content.field:patch("flyOrder", { __append = { "SABLE_COVE" } }) + mod.content.field:patch("townMap", { + locations = { SABLE_COVE = { x = 4, y = 17, name = "SABLE COVE" } }, + }) +end +]]) +check(#worldLoader.errors == 0, "field patches load cleanly: " + .. table.concat(worldLoader.errors, "; ")) +check(worldData.field.hiddenItems.SABLE_COVE[1].item == "NUGGET", + "a new map's hidden items merge in") +check(worldData.field.hiddenItems.CERULEAN_CAVE_1F ~= nil, + "Kanto's hidden items survive the map-dict merge") +check(#worldData.field.flyOrder == #Data.field.flyOrder + 1 + and worldData.field.flyOrder[#worldData.field.flyOrder] == "SABLE_COVE", + "flyOrder appends without disturbing the vanilla order") +check(worldData.field.townMap.locations.SABLE_COVE.name == "SABLE COVE" + and worldData.field.townMap.locations.PALLET_TOWN ~= nil, + "town-map locations merge per map") +check(worldData.field.townMap.gridPixelSize == Data.field.townMap.gridPixelSize, + "unpatched town-map keys keep their imported values") +check(#worldData.field.ledges == #Data.field.ledges, + "sibling field keys are untouched by a patch elsewhere") + +-- ------- field.boot changes the new game + +local vanillaSave = SaveData.newGame(Data.field.boot) +check(vanillaSave.player.map == "PALLET_TOWN" and vanillaSave.player.x == 5 + and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down", + "the seeded boot config reproduces the Pallet spawn") +check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE" + and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start") +check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5 + and vanillaSave.lastHeal.y == 6, "heal point defaults to the spawn") +-- an absent config is still the vanilla new game +local bareSave = SaveData.newGame() +check(bareSave.player.map == "PALLET_TOWN" and bareSave.money == 3000 + and bareSave.lastHeal.map == "PALLET_TOWN", + "newGame without a boot config is unchanged") + +local bootData, bootLoader = withMod("total_conversion", [[ +return function(mod) + mod.content.field:patch("boot", { + startMap = "SABLE_COVE", startX = 3, startY = 4, startFacing = "up", + playerName = "ALEX", startMoney = 0, + namePresets = { player = { "ALEX", "SAM" } }, + }) +end +]]) +check(#bootLoader.errors == 0, "field.boot patch loads cleanly: " + .. table.concat(bootLoader.errors, "; ")) +local tcSave = SaveData.newGame(bootData.field.boot) +check(tcSave.player.map == "SABLE_COVE" and tcSave.player.x == 3 + and tcSave.player.y == 4 and tcSave.player.facing == "up", + "field.boot override moves the new-game spawn") +check(tcSave.player.name == "ALEX", "field.boot override renames the player") +check(tcSave.player.rival == "BLUE", "an unpatched boot key keeps its vanilla value") +check(tcSave.money == 0, "a zero startMoney is honored, not treated as absent") +check(tcSave.lastHeal.map == "SABLE_COVE" and tcSave.lastHeal.x == 3 + and tcSave.lastHeal.y == 4, "the heal point follows the new spawn") +local seededPresets = #Data.field.boot.namePresets.player +check(#bootData.field.boot.namePresets.player == seededPresets + 2 + and bootData.field.boot.namePresets.player[seededPresets + 1] == "ALEX" + and bootData.field.boot.namePresets.rival[1] == "BLUE", + "a patched preset list extends its own side and leaves the other alone") +check(bootData.field.boot.screens.title == "TitleState", + "the boot-screen chain survives a spawn patch") +check(bootData.field.hiddenItems.CERULEAN_CAVE_1F ~= nil + and #bootData.field.flyOrder == #Data.field.flyOrder, + "sibling field keys are intact after a boot patch") +check(Data.field.boot.startMap == "PALLET_TOWN", + "the boot merge never touched the live Data") + +-- the save table is a copy: writing to it cannot reach back into Data +tcSave.lastHeal.map = "ELSEWHERE" +check(bootData.field.boot.startMap == "SABLE_COVE", + "the new-game save never aliases the boot config") + +-- ------- validation and cross-references + +local badTypeData, badTypeLoader, badTypeOk = withMod("bad_constant", [[ +return function(mod) + mod.content.constants:patch("partyMax", "six") +end +]]) +check(badTypeOk == false, "a mistyped constant fails an api 2 mod") +local badTypeError = table.concat(badTypeLoader.errors, "\n") +check(badTypeError:find("constants.partyMax", 1, true) ~= nil, + "the schema error names the constant") +check(badTypeData.constants.partyMax == 6, + "the rejected patch leaves the constant untouched") + +local badRowLoader = select(2, withMod("bad_badge_row", [[ +return function(mod) + mod.content.constants:patch("badges", { { name = "NO ID" } }) +end +]])) +check(#badRowLoader.errors > 0, "a badge row without an id fails") +check(table.concat(badRowLoader.errors, "\n"):find("badges", 1, true) ~= nil, + "the badge row error names the key") + +local danglingLoader = select(2, withMod("phantom_badge", [[ +return function(mod) + mod.content.constants:patch("badges", { { id = "PHANTOM_BADGE" } }) +end +]])) +check(#danglingLoader.errors > 0, "a badge with no item record fails") +check(table.concat(danglingLoader.errors, "\n"):find("PHANTOM_BADGE", 1, true) ~= nil, + "the cross-reference error names the missing item") + +-- a key the catalog does not describe is a mod's own data, not an error +local stashData, stashLoader = withMod("stash", [[ +return function(mod) + mod.content.field:patch("questBoard", { chapters = { "one", "two" } }) +end +]]) +check(#stashLoader.errors == 0, "an undescribed field key is accepted") +check(stashData.field.questBoard.chapters[2] == "two", + "the mod's own field data merges through") + +-- ------- removal + +local removeData, removeLoader = withMod("no_ledges", [[ +return function(mod) + mod.content.field:remove("ledges") +end +]]) +check(#removeLoader.errors == 0, "removing a field key loads cleanly") +check(removeData.field.ledges == nil, "remove tombstones the top-level key") +check(removeData.field.hiddenItems ~= nil, "sibling keys survive the removal") + +S.finish() diff --git a/tests/mod_examples_tests.lua b/tests/mod_examples_tests.lua new file mode 100644 index 00000000..80eaa77a --- /dev/null +++ b/tests/mod_examples_tests.lua @@ -0,0 +1,358 @@ +-- The shipped example gallery (25-community-and-ecosystem.md 1): every +-- 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 +-- 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 +love = love or require("tests.love_stub") + +local Data = require("src.core.Data") +local FsIo = require("tests.fs_io") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Runtime = require("src.mods.Runtime") + +local S = require("tests.harness").suite("example gallery (M15B)") +local check, eq = S.check, S.eq + +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", +} + +-- the closed vocabulary from 25 3.1; GAMEPLAY is the accepted v1 alias +local CATEGORIES = { + TWEAK = true, BALANCE = true, CONTENT = true, QUEST = true, + MECHANIC = true, GRAPHICS = true, AUDIO = true, UI = true, TOOL = true, + TOTAL_CONVERSION = true, OTHER = true, GAMEPLAY = true, +} + +local function exists(path) + local handle = io.open(path, "rb") + if handle then handle:close() return true end + return false +end + +-- ------- an imported dataset of this suite's own +-- Data:load() folds into the singleton and require() hands back cached +-- module tables, so a mod merge through either would leak into every other +-- suite in this process. loadfile skips the cache, which is the same door +-- POKEPORT_DATA_DIR uses. + +-- methods only: inheriting the Data singleton wholesale would let a +-- namespace an earlier suite merged into it (Data.balls, Data.commands) +-- show through as this dataset's base, and the engine's own registrations +-- would then collide with themselves +local function dataMethods() + local methods = {} + for key, value in pairs(Data) do + if type(value) == "function" then methods[key] = value end + end + return methods +end + +-- the namespaces are whatever the importer wrote; discovering them beats +-- restating Data's module list, which would drift the moment one is added +local function freshImported() + local set = setmetatable({}, { __index = dataMethods() }) + local pipe = io.popen("ls -1 data/generated/*.lua 2>/dev/null") + if not pipe then return nil end + local loaded = 0 + for path in pipe:lines() do + local name = path:match("([^/]+)%.lua$") + local chunk = name and loadfile(path) + if chunk then + set[name] = chunk() + loaded = loaded + 1 + end + end + pipe:close() + if set.pokemon == nil or set.maps == nil or loaded < 10 then return nil end + Data.seedDefaults(set) + return set +end + +-- ------- static metadata: true with or without an imported dataset + +for _, id in ipairs(IDS) do + local dir = GALLERY_ROOT .. "/" .. id + local prefix = id .. ": " + + local manifestBody = io.open(dir .. "/manifest.json", "rb") + if not check(manifestBody ~= nil, prefix .. "ships a manifest.json") then + manifestBody = nil + end + if manifestBody then + local body = manifestBody:read("*a") + manifestBody:close() + local manifest = require("src.link.Json").decode(body) + local ok, parsed = pcall(Manifest.validate, manifest, dir) + check(ok, prefix .. "the manifest validates (" .. tostring(parsed) .. ")") + if ok then + eq(parsed.id, id, prefix .. "the manifest id matches the directory") + eq(parsed.api, 2, prefix .. "is an api 2 mod") + check(CATEGORIES[parsed.category], + prefix .. "category " .. tostring(parsed.category) .. " is in the taxonomy") + check(parsed.game_version ~= nil and parsed.game_version ~= "", + prefix .. "declares a game_version range") + check(parsed.description ~= "", prefix .. "the manifest carries a description") + end + end + + check(exists(dir .. "/README.md"), prefix .. "ships a README.md") + check(exists(dir .. "/CHANGELOG.md"), prefix .. "ships a CHANGELOG.md") + check(exists(dir .. "/tests/" .. id .. "_test.lua"), + prefix .. "ships its own test suite") + + -- the card is Lua, never read by the merge; it must still parse and meet + -- the 25 3.2 shape or the manager detail pane has nothing to draw + local cardChunk = loadfile(dir .. "/mod.card") + if check(cardChunk ~= nil, prefix .. "ships a parseable mod.card") then + local okCard, card = pcall(cardChunk) + if check(okCard and type(card) == "table", prefix .. "mod.card returns a table") then + check(type(card.summary) == "string" and #card.summary > 0 + and #card.summary <= 100, prefix .. "summary is 1..100 chars") + check(type(card.author) == "string" and card.author ~= "", + prefix .. "author is present and non-empty") + check(type(card.tags) == "table" and #card.tags > 0, + prefix .. "carries at least one tag") + for _, tag in ipairs(card.tags or {}) do + check(tag == tag:lower() and not tag:find("%s"), + prefix .. "tag " .. tostring(tag) .. " is lowercase kebab") + end + check(type(card.differences) == "table" + and type(card.differences.changed) == "table" + and type(card.differences.added) == "table" + and type(card.differences.known) == "table", + prefix .. "declares a changed/added/known differences ledger") + check(type(card.credits) == "table" and #card.credits > 0, + prefix .. "names at least one credit") + for _, entry in ipairs(card.credits or {}) do + check(type(entry.who) == "string" and type(entry.for_) == "string", + prefix .. "every credit says who and what for") + end + check(type(card.compat) == "table" and card.compat.modApi == 2, + prefix .. "compat declares modApi 2") + end + end +end + +-- the legacy entry keeps its v1 manifest and gains only a card +do + local cardChunk = loadfile("mods/example_mew_starter/mod.card") + if check(cardChunk ~= nil, "example_mew_starter: ships a mod.card") then + local ok, card = pcall(cardChunk) + check(ok and type(card) == "table", "example_mew_starter: the card parses") + check(ok and card.compat and card.compat.modApi == 1, + "example_mew_starter: the card declares api 1, not 2") + end + local body = io.open("mods/example_mew_starter/manifest.json", "rb") + if check(body ~= nil, "example_mew_starter: manifest is readable") then + local manifest = require("src.link.Json").decode(body:read("*a")) + body:close() + check(manifest.api == nil, "example_mew_starter: stays an api 1 manifest") + eq(manifest.category, "GAMEPLAY", + "example_mew_starter: keeps the legacy category value") + local ok = pcall(Manifest.validate, manifest, "mods/example_mew_starter") + check(ok, "example_mew_starter: the v1 manifest still validates") + end +end + +-- ------- disabled by default +-- Loader:_discover walks one level below "mods". The gallery sits a level +-- deeper, so a fresh install discovers none of it and the merged data of a +-- mod-free boot is unchanged -- the parity invariant, held by construction. + +do + local fs = FsIo.new(".") + local top = {} + for _, name in ipairs(fs.getDirectoryItems("mods")) do + if fs.getInfo("mods/" .. name .. "/manifest.json") then top[name] = true end + end + for _, id in ipairs(IDS) do + check(not top[id], id .. " is not discoverable at the mods/ root") + end + check(top.example_mew_starter, + "the legacy example is still discovered at the mods/ root") +end + +-- ------- the gallery loads + +local data = freshImported() +if not data then + print("modkit: example gallery load skipped -- no imported dataset in " + .. "data/generated/ to fold the examples against") + return S.finish() +end + +-- The transform in example_shiny_palette reads the imported cache and +-- writes under save/mod-derived/. The io-backed harness filesystem has no +-- createDirectory, so this run exercises the no-cache path instead: the +-- transform must degrade to writing nothing, not fail the mod. +local function galleryFs(ids) + local inner = FsIo.new(".") + local overlay = {} + local hidden = "assets/generated/" + + local function map(path) + if path == nil then return path end + for _, id in ipairs(ids) do + local mount = "mods/" .. id + if path == mount then return GALLERY_ROOT .. "/" .. id end + if path:sub(1, #mount + 1) == mount .. "/" then + return GALLERY_ROOT .. "/" .. id .. path:sub(#mount + 1) + end + end + return path + end + + local fs = { root = inner.root } + function fs.read(path) + if path:sub(1, #hidden) == hidden then return nil end + return overlay[path] or inner.read(map(path)) + end + function fs.write(path, body) overlay[path] = body return true end + function fs.createDirectory() return true end + function fs.load(path) return inner.load(map(path)) end + function fs.getInfo(path) + if path == "mods" then return { type = "directory" } end + if path:sub(1, #hidden) == hidden then return nil end + if overlay[path] then return { type = "file" } end + return inner.getInfo(map(path)) + end + function fs.getDirectoryItems(path) + if path == "mods" then + local names = {} + for i, id in ipairs(ids) do names[i] = id end + table.sort(names) + return names + end + return inner.getDirectoryItems(map(path)) + end + return fs +end + +local saved = { events = Runtime.events, hooks = Runtime.hooks, + errors = Runtime.errors } +local loader = Loader.new({ fs = galleryFs(IDS) }) +local ok, err = pcall(loader.load, loader, data) +check(ok, "the whole gallery loads without raising (" .. tostring(err) .. ")") + +for _, message in ipairs(loader.errors) do + check(false, "loader error: " .. tostring(message)) +end +eq(#loader.errors, 0, "the gallery loads with zero loader errors") + +local status = loader:status() +eq(#status.loaded, #IDS, "every gallery entry reached the loaded state") +for _, id in ipairs(IDS) do + local mod = loader.mods[id] + check(mod ~= nil, id .. " was discovered") + eq(mod and mod.state, "loaded", id .. " reached the loaded state") +end + +-- ------- each entry's stated effect landed in the merged data + +-- #1 tweaker: patch and each +eq(data.pokemon.VENUSAUR.baseStats.speed, 100, "#1 patched VENUSAUR speed") +check(#data.pokemon.VENUSAUR.learnset > 0, "#1 patch left the learnset alone") +eq(data.items.TM_TOXIC.price, 2000, "#1 halved a TM price through each()") +eq(data.items.POTION.price, 300, "#1 left non-TM prices alone") +eq(data.encounters.ROUTE_1.grass.rate, 20, "#1 re-slotted Route 1") + +-- #2 artist: palette records and the trueColor opt-out +check(data.palettes.palettes.EXAMPLE_SHINY ~= nil, "#2 registered a palette record") +eq(#data.palettes.palettes.PALLET, 4, "#2 overrode PALLET with four colors") +eq(data.sprites.SPRITE_RED.trueColor, true, "#2 opted SPRITE_RED into trueColor") +check(data.sprites.SPRITE_RED.image ~= nil, + "#2 patched only the flag; the sheet path survived") + +-- #3 musician: an authored program, a cry and a hook +local song = data.audio.songs.Music_ExamplePalletRain +check(type(song) == "table" and type(song.chip) == "table", + "#3 registered an authored chip song") +check(#song.chip.blob > 0, "#3 the song assembled to a non-empty blob") +check(type(data.audio.cries.MEW) == "table" and data.audio.cries.MEW.chip, + "#3 replaced the MEW cry with a chip program") +eq(Runtime.call("music.select", function(chosen) return chosen end, + "Music_PalletTown", { reason = "map", mapId = "PALLET_TOWN" }), + "Music_ExamplePalletRain", "#3 music.select swaps the Pallet Town theme") +eq(Runtime.call("music.select", function(chosen) return chosen end, + "Music_Routes1", { reason = "map", mapId = "ROUTE_1" }), + "Music_Routes1", "#3 every other map defers to the vanilla choice") + +-- #4 quest author: compose semantics, a verb, a token, an item +check(data.items.EXAMPLE_LOST_PARCEL_PARCEL ~= nil, "#4 registered the parcel item") +check(data.tokens.EXAMPLE_PARCEL_REWARD ~= nil, "#4 registered its text token") +check(data.commands["example_lost_parcel:count_ask"] ~= nil, "#4 registered its verb") +check(data.commands.show_text ~= nil, "#4 the engine's own verbs are untouched") +local viridian = data.map_scripts and data.map_scripts.VIRIDIAN_CITY +check(viridian and #viridian > 0, "#4 composed into the VIRIDIAN_CITY chain") +check(viridian and viridian[1].talk + and viridian[1].talk.TEXT_VIRIDIANCITY_GAMBLER1 ~= nil, + "#4 the talk contribution addresses a real TEXT constant") +-- talk dispatch is single-winner, so the branches the quest does not own +-- have to replay the base handler rather than re-resolve its TEXT constant +check(data.commands["example_lost_parcel:base_nerd_chat"] ~= nil, + "#4 registered the verb that replays the overridden base conversation") +local pewterTalk = data.map_scripts and data.map_scripts.PEWTER_CITY + and data.map_scripts.PEWTER_CITY[1].talk.TEXT_PEWTERCITY_SUPER_NERD1 +local fallback = pewterTalk and pewterTalk[#pewterTalk] +eq(fallback and fallback[1], "example_lost_parcel:base_nerd_chat", + "#4 the vanilla branch ends in that verb, not a truncating show_text") + +-- #5 mechanic designer: a ruleset, a status and a gated damage hook +local weather = data.rulesets.example_weather_battles +check(weather ~= nil, "#5 registered a selectable ruleset") +eq(weather.randMax, data.rulesets.gen1_faithful.randMax, + "#5 the derived ruleset kept the vanilla rules") +check(data.statuses.EXAMPLE_RAIN ~= nil, "#5 registered the rain status record") +local function damage(ruleset, moveType) + return Runtime.call("battle.damage", function() return 100, { crit = false } end, + { ruleset = ruleset, move = { type = moveType } }) +end +Runtime.emit("battle.started", { battle = { ruleset = data.rulesets.gen1_faithful } }) +eq(damage(data.rulesets.gen1_faithful, "WATER"), 100, + "#5 gen1_faithful is unchanged with the mod installed") +Runtime.emit("battle.started", { battle = { ruleset = weather } }) +eq(damage(weather, "WATER"), 150, "#5 rain boosts WATER under its own ruleset") +eq(damage(weather, "FIRE"), 50, "#5 rain dampens FIRE under its own ruleset") +Runtime.emit("battle.ended", {}) + +-- #6 tool builder: exports, options and the start-menu wrap +local exports = loader.exports.example_dexnav +check(type(exports.countSeen) == "function", "#6 published a countSeen export") +eq(loader.optionSchemas.example_dexnav and #loader.optionSchemas.example_dexnav, 2, + "#6 defined two option rows") +local menu = Runtime.call("ui.start_menu.items", function(_, items) return items end, + { data = data }, { { label = "POKéDEX" }, { label = "SAVE" } }) +eq(#menu, 3, "#6 added exactly one start-menu row") +eq(menu[2].label, "DEXNAV", "#6 anchored the row before SAVE") + +-- #7 total conversion: boot, constants, species, map +eq(data.field.boot.startMap, "SABLE_COVE", "#7 owns the boot spawn") +eq(data.field.boot.startFacing, "down", "#7 patch left unnamed boot keys alone") +eq(data.constants.dexSize, 3, "#7 shrank the dex") +eq(#data.constants.badges, 1, "#7 override replaced the badge list") +eq(data.constants.partyMax, 6, "#7 left unpatched constants alone") +check(data.pokemon.SABLE_EMBERKIT ~= nil, "#7 registered its own species") +check(data.audio.cries.SABLE_EMBERKIT ~= nil, "#7 gave it a cry") +check(data.icons.bySpecies.SABLE_EMBERKIT ~= nil, "#7 gave it an icon") +check(data.maps.SABLE_COVE ~= nil, "#7 registered its map") +eq(#data.maps.SABLE_COVE.blocks, + data.maps.SABLE_COVE.width * data.maps.SABLE_COVE.height, + "#7 the map's block array matches its size") + +-- co-existence: the gallery does not fight over ids +check(data.pokemon.MEW ~= nil, "vanilla species survive the whole gallery") +check(data.maps.PALLET_TOWN ~= nil, "vanilla maps survive the whole gallery") + +Runtime.events, Runtime.hooks, Runtime.errors = + saved.events, saved.hooks, saved.errors +Runtime.currentMod = nil + +S.finish() diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua new file mode 100644 index 00000000..4a119255 --- /dev/null +++ b/tests/mod_graphics_tests.lua @@ -0,0 +1,870 @@ +-- Graphics and assets (M10): the asset search path and its central cache, +-- the invalidate() contract every image cache now exposes, animated tiles +-- as tileset data, the trueColor opt-out on battle pics and palette zones, +-- the font page / charmap consumption, the transitions registry plus the +-- transition.style hook, and the asset-transform sandbox. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("mod graphics") +local check = S.check + +-- ------- love stubs +-- Pixel-level image data (the base stub has no love.image at all) plus a +-- graphics recorder, so the zone blit and the battle pic quantize can be +-- asserted on rather than assumed. + +local love = _G.love or require("tests.love_stub") +_G.love = love + +local savedGraphics, savedImage = love.graphics, love.image +-- the in-memory stub filesystem and the mod buses are shared with every +-- other suite in the run, so everything this file touches is put back +local savedFiles = {} +local writtenFiles = {} +local function seedFile(path, content) + if savedFiles[path] == nil then + savedFiles[path] = love.filesystem.read(path) or false + writtenFiles[#writtenFiles + 1] = path + end + love.filesystem.write(path, content) +end + +local ImageData = {} +ImageData.__index = ImageData + +local function newImageData(a, b) + local self = setmetatable({ pixels = {} }, ImageData) + if type(a) == "string" then + self.path, self.w, self.h = a, 2, 2 + -- a pixel no 4-shade quantize would leave alone + self:setPixel(0, 0, 0.4, 0.7, 0.9, 1) + self:setPixel(1, 0, 1, 1, 1, 1) + self:setPixel(0, 1, 0, 0, 0, 1) + self:setPixel(1, 1, 0, 0, 0, 1) + else + self.w, self.h = a, b + for y = 0, self.h - 1 do + for x = 0, self.w - 1 do self:setPixel(x, y, 0, 0, 0, 0) end + end + end + return self +end + +function ImageData:getDimensions() return self.w, self.h end +function ImageData:getWidth() return self.w end +function ImageData:getHeight() return self.h end +function ImageData:setPixel(x, y, r, g, b, a) + self.pixels[y * self.w + x] = { r, g, b, a } +end +function ImageData:getPixel(x, y) + local p = self.pixels[y * self.w + x] or { 0, 0, 0, 0 } + return p[1], p[2], p[3], p[4] +end +function ImageData:mapPixel(fn) + for y = 0, self.h - 1 do + for x = 0, self.w - 1 do + self:setPixel(x, y, fn(x, y, self:getPixel(x, y))) + end + end +end +function ImageData:paste(source, dx, dy, sx, sy, w, h) + for y = 0, h - 1 do + for x = 0, w - 1 do + self:setPixel(dx + x, dy + y, source:getPixel(sx + x, sy + y)) + end + end +end +function ImageData:encode() return "png-bytes" end + +local Image = {} +Image.__index = Image +function Image:getDimensions() return self.w, self.h end +function Image:getWidth() return self.w end +function Image:getHeight() return self.h end + +-- what the recorder collects between resets +local log = { shader = {}, draws = {} } +local function resetLog() + log.shader, log.draws = {}, {} +end + +local Shader = {} +Shader.__index = Shader +function Shader:send(name, value) self.sent[name] = value end + +local function noop() end + +love.image = { newImageData = newImageData } + +love.graphics = { + newImage = function(what) + if type(what) == "table" then + return setmetatable({ w = what.w, h = what.h, data = what }, Image) + end + return setmetatable({ w = 128, h = 128, path = what }, Image) + end, + newQuad = function(x, y, w, h) return { x = x, y = y, w = w, h = h } end, + newCanvas = function(w, h) + return setmetatable({ w = w, h = h, setFilter = noop, + getWidth = function(s) return s.w end, + getHeight = function(s) return s.h end }, Image) + end, + newShader = function() return setmetatable({ sent = {} }, Shader) end, + newSpriteBatch = function(image, size) + local batch = { image = image, sprites = {} } + function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end + function batch:setTexture(tex) self.texture = tex end + return batch + end, + setShader = function(shader) + log.shader[#log.shader + 1] = shader or false + end, + draw = function(what) + log.draws[#log.draws + 1] = { what = what, shader = log.shader[#log.shader] } + end, + rectangle = noop, setColor = noop, clear = noop, setCanvas = noop, + setDefaultFilter = noop, print = noop, push = noop, pop = noop, + translate = noop, scale = noop, rotate = noop, origin = noop, + setScissor = noop, getColor = function() return 1, 1, 1, 1 end, + getDimensions = function() return 640, 576 end, +} + +-- Fresh copies of the modules that cache a compiled shader or a page set +-- at first use, so they see the recorder above -- and so the originals +-- every other suite holds never see it. GBCFX is in the list because +-- Renderer:endFrame reaches it and parity_gbcfx asserts on its unset +-- shader cache. +-- The tile/sprite renderers join them because they report trueColor rects +-- to PaletteFX and resolve through Assets, and the loader because it holds +-- Assets as an upvalue: an earlier suite's copy of any of the three would +-- talk to the module instance this one just replaced. +local savedLoaded = {} +for _, name in ipairs({ "src.render.PaletteFX", "src.render.Renderer", + "src.render.Font", "src.render.Assets", + "src.render.GBCFX", "src.render.SpriteRenderer", + "src.render.TileRenderer", "src.mods.Loader" }) do + savedLoaded[name] = package.loaded[name] + package.loaded[name] = nil +end + +local Assets = require("src.render.Assets") +local AssetTransform = require("src.mods.AssetTransform") +local BattleState = require("src.battle.BattleState") +local BattleTransition = require("src.render.BattleTransition") +local Events = require("src.mods.Events") +local Font = require("src.render.Font") +local Hooks = require("src.mods.Hooks") +local HudTiles = require("src.render.HudTiles") +local PaletteFX = require("src.render.PaletteFX") +local Registry = require("src.mods.Registry") +local Renderer = require("src.render.Renderer") +local Runtime = require("src.mods.Runtime") +local Schemas = require("src.mods.Schemas") +local SpriteRenderer = require("src.render.SpriteRenderer") +local TileRenderer = require("src.render.TileRenderer") +local Transition = require("src.render.Transition") + +-- ------- asset resolution + +check(Assets.loader == nil, "no loader installed by default") +check(Assets.resolve("assets/generated/tilesets/overworld.png") + == "assets/generated/tilesets/overworld.png", + "resolve is the identity with no loader (the no-op proof)") +check(Assets.resolve("mods/x/art.png") == "mods/x/art.png", + "a non-generated path is never rewritten") + +seedFile("mods/skin/overrides/tilesets/overworld.png", "png") +seedFile("save/mod-derived/skin/battle/back/redb.png", "png") + +Assets.installLoader({ + loaded = { { manifest = { id = "skin" }, path = "mods/skin" } }, +}) + +check(Assets.resolve("assets/generated/tilesets/overworld.png") + == "mods/skin/overrides/tilesets/overworld.png", + "an overrides/ file shadows the generated path with no record edit") +check(Assets.resolve("assets/generated/battle/back/redb.png") + == "save/mod-derived/skin/battle/back/redb.png", + "a transform's derived output resolves under the override dir") +check(Assets.resolve("assets/generated/fonts/font.png") + == "assets/generated/fonts/font.png", + "no override means the generated path, unchanged") + +-- highest priority (last loaded) wins the lookup, like the record merge +seedFile("mods/hat/overrides/tilesets/overworld.png", "png") +Assets.installLoader({ + loaded = { { manifest = { id = "skin" }, path = "mods/skin" }, + { manifest = { id = "hat" }, path = "mods/hat" } }, +}) +check(Assets.resolve("assets/generated/tilesets/overworld.png") + == "mods/hat/overrides/tilesets/overworld.png", + "the later-loaded mod wins the asset search") + +Assets.installLoader(nil) +check(Assets.resolve("assets/generated/tilesets/overworld.png") + == "assets/generated/tilesets/overworld.png", + "uninstalling the loader restores the vanilla path") + +-- ------- the cache-invalidation contract + +for name, module in pairs({ Assets = Assets, TileRenderer = TileRenderer, + SpriteRenderer = SpriteRenderer, Font = Font, HudTiles = HudTiles, + BattleState = BattleState }) do + check(type(module.invalidate) == "function", + name .. " exposes invalidate()") +end +check(type(require("src.world.MapLoader").invalidateAll) == "function", + "MapLoader keeps its wave-1 invalidateAll") +check(type(require("src.core.Sound").invalidate) == "function", + "Sound keeps its wave-1 invalidate") + +-- the central cache hands back one image per resolved path, and flush +-- fans out to every registered downstream cache +local first = Assets.image("assets/generated/fonts/font.png") +check(Assets.image("assets/generated/fonts/font.png") == first, + "the central cache returns the same image for a repeated path") +local fanout = 0 +Assets.register(function() fanout = fanout + 1 end) +Assets.flush() +check(fanout == 1, "flush() fans out to registered invalidators") +check(Assets.image("assets/generated/fonts/font.png") ~= first, + "flush() drops the central cache so the next load re-resolves") + +-- an invalidator that throws must not strand the ones behind it +local reached = false +Assets.register(function() error("boom") end) +Assets.register(function() reached = true end) +Assets.flush() +check(reached, "a throwing invalidator does not stop the fan-out") + +-- ------- animated tiles as tileset data + +local overworld = TileRenderer.defaultAnimatedTiles( + { id = "OVERWORLD", animation = "TILEANIM_WATER_FLOWER" }) +check(#overworld == 2, "TILEANIM_WATER_FLOWER derives water + flower entries") +check(overworld[1].tile == 0x14 and overworld[1].kind == "hshift", + "water is an hshift entry on tile $14") +check(table.concat(overworld[1].offsets, ",") == "1,2,3,2,1,0,7,0", + "the water entry reproduces WATER_OFFSETS exactly") +check(overworld[1].period == 20, "water advances every 20 ticks") +check(overworld[2].tile == 0x03 and overworld[2].kind == "frames", + "flower is a frames entry on tile $03") +check(table.concat(overworld[2].sequence, ",") == "1,2,3,1,1,2,3,1", + "the flower entry reproduces FLOWER_FRAMES exactly") +check(#overworld[2].images == 3, "the flower entry names its 3 frame images") + +local waterOnly = TileRenderer.defaultAnimatedTiles( + { id = "PLATEAU", animation = "TILEANIM_WATER" }) +check(#waterOnly == 1, "TILEANIM_WATER derives water alone") +local none = TileRenderer.defaultAnimatedTiles( + { id = "HOUSE", animation = "TILEANIM_NONE" }) +check(#none == 0, "a tileset with no animation derives nothing") + +local gym = TileRenderer.defaultAnimatedTiles( + { id = "GYM", animation = "TILEANIM_NONE" }) +check(#gym == 1 and gym[1].kind == "toggle", + "a spinner tileset derives its toggle entry") +check(gym[1].gate == "spinning", "the spinner toggle is gated on spinning") +check(gym[1].stripOffsets[0x3c] == 1 and gym[1].stripOffsets[0x4c] == 0, + "the spinner toggle carries the asm's strip offsets") + +-- a custom tileset declares its own animation and the engine consumes it +local customTiles = {} +for i = 1, 16 do customTiles[i] = 0x2f end +local map = { + def = { width = 1, height = 1, tileset = "AQUA", borderBlock = 0 }, + tileset = { id = "AQUA", image = "assets/generated/tilesets/aqua.png", + tilesPerRow = 16, blocks = { customTiles }, + animatedTiles = { + { tile = 0x2f, kind = "frames", period = 12, + sequence = { 1, 2, 3, 2 }, + images = { "mods/aqua/w1.png", "mods/aqua/w2.png", + "mods/aqua/w3.png" } }, + } }, + blockAt = function() return 0 end, +} +local renderer = TileRenderer.new(map) +check(#renderer.anims == 1, "a declared animatedTiles entry builds one anim") +check(renderer.anims[1].period == 12, "the declared period is honored") +check(#renderer.anims[1].textures == 3, "the declared frame images load") +check(renderer.anims[1].batch ~= nil, "the animated tile collected cells") + +-- the vanilla water cycle, driven through the same data path: eight +-- shifted variants stepped every 20 ticks in WATER_OFFSETS order. The +-- suite before this one built OVERWORLD under a stub with no love.image +-- and cached the miss, so this doubles as proof that invalidate() lets a +-- cache repopulate from a changed search path. +TileRenderer.invalidate() +local waterTiles = {} +for i = 1, 16 do waterTiles[i] = 0x14 end +local sea = TileRenderer.new({ + def = { width = 1, height = 1, tileset = "OVERWORLD", borderBlock = 0 }, + tileset = { id = "OVERWORLD", image = "assets/generated/tilesets/overworld.png", + tilesPerRow = 16, blocks = { waterTiles }, + animation = "TILEANIM_WATER" }, + blockAt = function() return 0 end, +}) +check(#sea.anims == 1, "an OVERWORLD tileset animates its water with no record edit") +check(#sea.anims[1].textures == 8, "the water entry builds 8 shifted variants") + +local seen = {} +for step = 1, 8 do + sea:drawAnimated(0, 0) + local texture = sea.anims[1].batch.texture + for i, candidate in ipairs(sea.anims[1].textures) do + if candidate == texture then seen[step] = i end + end + for _ = 1, 20 do TileRenderer.tick() end +end +-- WATER_OFFSETS + 1, as texture indices; the phase depends on how many +-- ticks the process has run, so any rotation of it is the right cycle +local want = { 2, 3, 4, 3, 2, 1, 8, 1 } +local rotated = false +for offset = 0, 7 do + local match = true + for i = 1, 8 do + if seen[i] ~= want[(i - 1 + offset) % 8 + 1] then match = false break end + end + if match then rotated = true break end +end +check(rotated, "the water cycle steps through WATER_OFFSETS in order") + +-- a gate name nothing registered is always on; a registered one decides +TileRenderer.registerGate("test_gate", function() return false end) +check(TileRenderer.GATES.test_gate() == false, "a gate predicate is registered") +check(TileRenderer.GATES.spinning() == false, + "the spinning gate is shut while nothing is spinning") + +-- ------- trueColor: the battle pic quantize opt-out + +BattleState.invalidate() +local monPalette = { { 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 }, { 0, 0, 0 } } +local picData = { + pokemon = { + SHADED = { spriteFront = "assets/generated/battle/front/shaded.png", + spriteBack = "assets/generated/battle/back/shaded.png" }, + FULLCOLOR = { spriteFront = "assets/generated/battle/front/full.png", + spriteBack = "assets/generated/battle/back/full.png", + trueColor = true }, + }, + palettes = { palettes = { GRAYMON = monPalette }, pokemon = {} }, +} +local battle = setmetatable({ data = picData }, BattleState) + +local shaded = battle:speciesSprite("SHADED", false) +local r, g, b = shaded.data:getPixel(0, 0) +-- r = 0.4 lands in shade bucket 2 (> 0.17), the palette's third color +check(r == 0 and g == 0 and b == 1, + "a 4-shade pic is palette-quantized onto its shade bucket") + +local full = battle:speciesSprite("FULLCOLOR", false) +r, g, b = full.data:getPixel(0, 0) +check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6 + and math.abs(b - 0.9) < 1e-6, + "a trueColor pic keeps a pixel no 4-shade palette contains") + +-- ------- trueColor: the colors == false zone sentinel + +check(PaletteFX.zone(nil, 0, 0, 1, 1) == nil, "nil colors is still no zone") +local bare = PaletteFX.trueColorZone(0, 0, 19, 17) +check(bare ~= nil and bare.colors == false, + "colors == false survives as a real zone") +check(bare.w == 160 and bare.h == 144, "the trueColor zone covers the screen") +check(PaletteFX.ensureZones({ bare })[1] == bare, + "a trueColor-only zone list is left alone") + +Renderer:init() +Renderer:beginFrame(false) +resetLog() +Renderer:endFrame({ PaletteFX.whole(PaletteFX.GRAYS) }) +local shadedDraw = nil +for _, d in ipairs(log.draws) do + if d.what and d.what.w == 160 then shadedDraw = d end +end +check(shadedDraw and shadedDraw.shader, + "an ordinary zone blits through the shade-remap shader") + +Renderer:beginFrame(false) +resetLog() +Renderer:endFrame({ bare }) +local bareDraw = nil +for _, d in ipairs(log.draws) do + if d.what and d.what.w == 160 then bareDraw = d end +end +check(bareDraw, "the trueColor zone still blits its rect") +check(bareDraw.shader == false, + "a colors == false zone blits with no shader bound") + +-- ------- trueColor: a record's rect reaching the frame's zone list +-- The state that returns the zone list knows nothing about which records +-- the frame drew, so the renderers report the rect a trueColor record +-- covered and endFrame splices it in. Driven through the real draw path +-- rather than by handing endFrame a hand-built zone. + +local GRAYS = PaletteFX.GRAYS +local function canvasDraws(canvas) + local drawn = {} + for _, d in ipairs(log.draws) do + if d.what == canvas then drawn[#drawn + 1] = d end + end + return drawn +end + +local function fullWorldZones() + local vw, vh = Renderer:worldViewSize() + return { { colors = GRAYS, x = 0, y = 0, w = vw, h = vh } } +end + +-- the flag reaches the renderer on a real record, registered and merged +-- through the public API rather than hand-built here +local spriteReg = Registry.new("sprites", Schemas.REGISTRIES.sprites) +spriteReg:register("SPRITE_TITLE_LOGO", + { image = "mods/logo/logo.png", frames = 1, + trueColor = true }, "logo_mod") +local logoDef = spriteReg:get("SPRITE_TITLE_LOGO") +check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO", + logoDef, "register"), + "a trueColor sprites record validates against the catalog schema") +check(logoDef.trueColor == true, "and keeps the flag through the merge") + +Renderer:init() +local plainSprite = SpriteRenderer.new( + { image = "assets/generated/sprites/red.png", frames = 1 }) +local litSprite = SpriteRenderer.new(logoDef) + +Renderer:beginFrame(true) +check(#PaletteFX.trueColorRects("ui") == 0 + and #PaletteFX.trueColorRects("world") == 0, + "beginFrame drops the previous frame's rects") +Renderer:beginWorldPass() +plainSprite:draw(32, 32, 0, 0, "down", 0, false) +check(#PaletteFX.trueColorRects("world") == 0, + "a vanilla sprite reports nothing, so the zone list is untouched") +Renderer:endWorldPass() +resetLog() +Renderer:endFrame({ PaletteFX.whole(GRAYS) }, fullWorldZones()) +local worldDrawn = canvasDraws(Renderer.worldCanvas) +check(#worldDrawn == 1 and worldDrawn[1].shader, + "the vanilla world pass blits its one zone through the shader") + +Renderer:beginFrame(true) +Renderer:beginWorldPass() +litSprite:draw(32, 32, 0, 0, "down", 0, false) +local spriteRects = PaletteFX.trueColorRects("world") +check(#spriteRects == 1 and spriteRects[1].colors == false, + "a trueColor sprite reports a colors == false zone") +check(spriteRects[1].x == 32 and spriteRects[1].y == 28 + and spriteRects[1].w == 16 and spriteRects[1].h == 16, + "the zone covers the 16x16 cell the sprite drew into") +Renderer:endWorldPass() +resetLog() +Renderer:endFrame({ PaletteFX.whole(GRAYS) }, fullWorldZones()) +worldDrawn = canvasDraws(Renderer.worldCanvas) +check(#worldDrawn == 2, "the reported zone joins the world list endFrame blits") +check(worldDrawn[1].shader and worldDrawn[2].shader == false, + "the colorized pass runs first, then the sprite's rect with no shader") + +-- the same path on the UI canvas, which is where a full-color title logo +-- or menu portrait lands +Renderer:beginFrame(false) +litSprite:draw(16, 16, 0, 0, "down", 0, false) +resetLog() +Renderer:endFrame({ PaletteFX.whole(GRAYS) }) +local uiDrawn = canvasDraws(Renderer.canvas) +check(#uiDrawn == 2 and uiDrawn[2].shader == false, + "a trueColor sprite renders unshaded on the UI pass too") + +-- a pass with no zone list already blits the whole canvas bare, which is +-- what the rect wanted, so nothing is added +Renderer:beginFrame(true) +Renderer:beginWorldPass() +litSprite:draw(32, 32, 0, 0, "down", 0, false) +Renderer:endWorldPass() +resetLog() +Renderer:endFrame(nil, nil) +worldDrawn = canvasDraws(Renderer.worldCanvas) +check(#worldDrawn == 1 and not worldDrawn[1].shader, + "an empty zone list is left alone (the whole canvas is already bare)") + +-- tilt's upright canvas composites with no zone list of its own, so a +-- rect drawn there has nowhere to land +Renderer:beginFrame(true) +Renderer:beginWorldPass() +Renderer:beginUprightPass() +litSprite:draw(32, 32, 0, 0, "down", 0, false) +Renderer:endUprightPass() +check(#PaletteFX.trueColorRects("world") == 0, + "the upright pass drops its rects instead of misplacing them") +Renderer:endWorldPass() +Renderer:endFrame({ PaletteFX.whole(GRAYS) }, fullWorldZones()) + +-- a trueColor tileset claims the extent it painted, ring and all +local litTiles = {} +for i = 1, 16 do litTiles[i] = 0x00 end +local tilesetReg = Registry.new("tilesets", Schemas.REGISTRIES.tilesets) +tilesetReg:register("AQUA", + { id = "AQUA", image = "assets/generated/tilesets/aqua.png", + tilesPerRow = 16, blocks = { litTiles }, + animation = "TILEANIM_NONE", trueColor = true }, "aqua_mod") +local aquaDef = tilesetReg:get("AQUA") +check(Schemas.check(Schemas.REGISTRIES.tilesets, "tilesets", "AQUA", + aquaDef, "register"), + "a trueColor tilesets record validates against the catalog schema") +local litMap = { + def = { width = 2, height = 2, tileset = "AQUA", borderBlock = 0 }, + tileset = aquaDef, + blockAt = function() return 0 end, +} +local litRenderer = TileRenderer.new(litMap) + +Renderer:beginFrame(true) +Renderer:beginWorldPass() +litRenderer:draw(0, 0) +local tileRects = PaletteFX.trueColorRects("world") +check(#tileRects == 1 and tileRects[1].colors == false, + "a trueColor tileset reports a colors == false zone") +check(tileRects[1].x == -96 and tileRects[1].y == -96 + and tileRects[1].w == 8 * 32 and tileRects[1].h == 8 * 32, + "the zone covers the map body plus its 3-block border ring") +litRenderer:drawMapOnly(0, 0) +check(#tileRects == 2 and tileRects[2].w == 2 * 32, + "a connected-map strip claims the body only") +Renderer:endWorldPass() +resetLog() +Renderer:endFrame({ PaletteFX.whole(GRAYS) }, fullWorldZones()) +worldDrawn = canvasDraws(Renderer.worldCanvas) +check(#worldDrawn == 3 and worldDrawn[2].shader == false + and worldDrawn[3].shader == false, + "both tileset rects blit unshaded over the colorized pass") + +litMap.tileset.trueColor = nil +Renderer:beginFrame(true) +Renderer:beginWorldPass() +TileRenderer.new(litMap):draw(0, 0) +check(#PaletteFX.trueColorRects("world") == 0, + "the same tileset without the flag reports nothing") +Renderer:endWorldPass() +Renderer:endFrame({ PaletteFX.whole(GRAYS) }, fullWorldZones()) + +-- ------- font pages and charmap ordering + +Font.load({ + font = { + image = "assets/generated/fonts/font.png", mainBase = 0x80, + imageExtra = "assets/generated/fonts/font_extra.png", extraBase = 0x60, + glyphsPerRow = 16, + -- deliberately shortest-first: load() must not trust this order + charmap = { { code = 0x80, seq = "A" }, { code = 0x81, seq = "AB" } }, + }, +}) +check(Font.encode("AB")[1] == 0x81, + "charmap buckets are sorted longest-first by load(), not the extractor") +check(#Font.encode("AB") == 1, "the longer sequence consumes both bytes") +check(Font.advanceOf(0x80) == 8, "a page with no advance stays 8px monospace") + +-- a registered page joins the legacy two and takes its own code range +Font.load({ + font = { + image = "assets/generated/fonts/font.png", mainBase = 0x80, + imageExtra = "assets/generated/fonts/font_extra.png", extraBase = 0x60, + glyphsPerRow = 16, charmap = {}, + border = { tl = 0x11 }, + pages = { + kana = { image = "mods/jp/kana.png", base = 0x100, glyphsPerRow = 16, + advance = 6, charmap = { { code = 0x100, seq = "\227\129\130" } } }, + }, + }, +}) +check(Font.encode("\227\129\130")[1] == 0x100, + "a page's own charmap entries merge into the greedy matcher") +check(Font.advanceOf(0x100) == 6, "a page's advance drives the pen") +check(Font.advanceOf(0x80) == 8, "sibling pages keep their own advance") +check(Font.BORDER.tl == 0x11, "data.font.border rethemes the box glyphs") +check(Font.BORDER.br == Font.DEFAULT_BORDER.br, + "an unthemed border glyph keeps its default") + +resetLog() +check(Font.draw("\227\129\130", 0, 0) == 6, + "draw() advances the pen by the page's own width") + +-- ------- palettes registry consumption + +local palData = { palettes = { palettes = { MODMON = monPalette }, + pokemon = { TESTMON = "MODMON" } } } +check(PaletteFX.pal(palData, "MODMON") == monPalette, + "PaletteFX.pal reads the merged palettes table") +check(PaletteFX.monPal(palData, "TESTMON") == monPalette, + "a pokemon: mapping steers monPal") +check(PaletteFX.monPal(palData, "UNKNOWN") == nil, + "an unmapped species falls through to MEWMON (absent here)") + +-- ------- the transitions registry + +local transitions = Registry.new("transitions", Schemas.REGISTRIES.transitions) +BattleTransition.registerInto(transitions, nil, "engine") +for _, id in ipairs({ "doublecircle", "spiralin", "circle", "spiralout", + "hstripes", "shrink", "vstripes", "split" }) do + check(transitions:get(id) ~= nil, "the engine registers wipe " .. id) +end +check(transitions:get("warp_fade").frames == 12, + "the warp fade registers as a transitions record") +check(transitions:get("white_flash").frames == 7, + "the white flash registers as a transitions record") + +-- every registered record still validates against the catalog schema +for id, record in transitions:each() do + local ok, err = Schemas.check(Schemas.REGISTRIES.transitions, "transitions", + id, record, "register") + check(ok, ("transitions.%s validates (%s)"):format(id, tostring(err))) +end + +-- the merged record retimes a fade without an engine change +local retimed = { transitions = { warp_fade = { kind = "fade", frames = 30 } } } +check(Transition.new({ data = retimed }).frames == 30, + "a patched warp_fade record changes the fade length") +check(Transition.new({ data = { transitions = {} } }).frames == 12, + "an unregistered id falls back to the built-in 12 frames") + +-- ------- the transition.style hook + +local stack = { pop = function() end } +local vanilla = BattleTransition.new({ stack = stack }, nil, + { trainer = true, stronger = true }) +check(vanilla.style == "spiralout", + "the vanilla 3-bit select is the hook's default (trainer+stronger)") +check(vanilla.wipeLen == 40, "the selected wipe brings its own length") + +local savedRuntime = { events = Runtime.events, hooks = Runtime.hooks, + errors = Runtime.errors } +local events, hooks = Events.new(), Hooks.new() +Runtime.install(events, hooks, {}) +local seenCtx +hooks:wrap("transition.style", function(nextLink, ctx) + seenCtx = ctx + return "hstripes" +end, 0, "test") +local hooked = BattleTransition.new({ stack = stack }, nil, { trainer = true }) +check(hooked.style == "hstripes", "a transition.style hook picks the wipe") +check(hooked.wipeLen == 24, "the hooked style brings its own length") +check(seenCtx.trainer == true and seenCtx.stronger == nil, + "the hook receives the selection bits as context") + +hooks:wrap("transition.style", function() return "no_such_style" end, 10, "test") +local fallback = BattleTransition.new({ stack = stack }, nil, {}) +check(fallback.style == "doublecircle", + "a hook naming an unregistered style falls back to the vanilla bits") +Runtime.install(Events.new(), Hooks.new(), {}) + +-- ------- asset transforms + +local function seedTransform(id, source) + seedFile("mods/" .. id .. "/transforms.lua", source) + return { path = "mods/" .. id, + manifest = { id = id, assets_transforms = "transforms.lua" } } +end + +seedFile("assets/generated/battle/front/mew.png", "png") +seedFile("rom-cache.complete", "rom-cache-v5:abc") + +local events2 = Events.new() +Runtime.install(events2, Hooks.new(), {}) +local transformed +events2:on("assets.transformed", function(ev) transformed = ev end, 0, "test") + +local recolorMod = seedTransform("recolor_mod", [[ +return function(ctx) + if not ctx.exists("battle/front/mew.png") then error("source root wrong") end + local src = ctx.readImage("battle/front/mew.png") + ctx.writeImage(ctx.recolor(src, { {40,80,200}, {70,120,230}, + {150,190,255}, {255,255,255} }), + "battle/front/mew.png") +end +]]) +local ok, reason = AssetTransform.runFor(recolorMod, love.filesystem) +check(ok, "the recolor transform runs: " .. tostring(reason)) +check(love.filesystem.read("save/mod-derived/recolor_mod/battle/front/mew.png") + ~= nil, "the transform wrote under save/mod-derived//") +check(love.filesystem.read("save/mod-derived/recolor_mod/.stamp") ~= nil, + "a stamp records that the recipe ran") +check(transformed and transformed.modId == "recolor_mod", + "assets.transformed names the mod") +check(transformed.count == 1, "assets.transformed counts the files written") + +-- the stamp gates the re-run: the output is not rebuilt until it changes +seedFile("save/mod-derived/recolor_mod/battle/front/mew.png", nil) +check(AssetTransform.runFor(recolorMod, love.filesystem), + "a stamped transform reports current without re-running") +check(love.filesystem.read("save/mod-derived/recolor_mod/battle/front/mew.png") + == nil, "the stamped run did no work") +check(AssetTransform.runFor(recolorMod, love.filesystem, true), + "force re-runs a stamped transform") +check(love.filesystem.read("save/mod-derived/recolor_mod/battle/front/mew.png") + ~= nil, "the forced run rebuilt the derived asset") + +-- a changed cache marker invalidates the stamp +seedFile("rom-cache.complete", "rom-cache-v5:def") +seedFile("save/mod-derived/recolor_mod/battle/front/mew.png", nil) +check(AssetTransform.runFor(recolorMod, love.filesystem), + "a re-imported cache re-runs the transform") +check(love.filesystem.read("save/mod-derived/recolor_mod/battle/front/mew.png") + ~= nil, "the re-import rebuilt the derived asset") + +-- write sandbox: nothing may climb out of save/mod-derived// +local escapee = seedTransform("escape_mod", [[ +return function(ctx) + ctx.writeImage(ctx.blank(1, 1), "../../assets/generated/tilesets/hack.png") +end +]]) +ok, reason = AssetTransform.runFor(escapee, love.filesystem) +check(not ok, "a transform writing outside its derived root is rejected") +check(reason:find("root", 1, true), "the rejection names the root: " .. reason) +check(love.filesystem.read("assets/generated/tilesets/hack.png") == nil, + "nothing was written outside the derived root") + +-- read sandbox: the source root is the imported cache and nothing above it +local peeker = seedTransform("peek_mod", [[ +return function(ctx) ctx.readImage("../../mods/peek_mod/transforms.lua") end +]]) +ok = AssetTransform.runFor(peeker, love.filesystem) +check(not ok, "a transform reading outside assets/generated is rejected") + +-- no require, no love, no io: the recipe runs in a bare sandbox +local breakout = seedTransform("breakout_mod", [[ +return function(ctx) return require("src.core.Data") end +]]) +ok = AssetTransform.runFor(breakout, love.filesystem) +check(not ok, "the sandbox has no require") + +local loveReach = seedTransform("love_mod", [[ +return function(ctx) return love.filesystem.write("pwned", "x") end +]]) +ok = AssetTransform.runFor(loveReach, love.filesystem) +check(not ok, "the sandbox has no love") +check(love.filesystem.read("pwned") == nil, "the breakout wrote nothing") + +-- a throwing recipe is isolated and attributed, never fatal +local thrower = seedTransform("throw_mod", [[ +return function(ctx) error("recipe exploded") end +]]) +ok, reason = AssetTransform.runFor(thrower, love.filesystem) +check(not ok, "a throwing transform is caught") +check(reason:find("recipe exploded", 1, true), + "the failure carries the recipe's message") +check(love.filesystem.read("save/mod-derived/throw_mod/.stamp") == nil, + "a failed transform leaves no stamp, so it retries next boot") + +-- a recipe that is not a function(ctx) is a load error, not a crash +local shapeless = seedTransform("shape_mod", "return 42") +ok, reason = AssetTransform.runFor(shapeless, love.filesystem) +check(not ok, "a recipe that returns a non-function is rejected") + +-- a mod with no assets_transforms is nothing to run +check(AssetTransform.runFor({ path = "mods/plain", manifest = { id = "plain" } }, + love.filesystem), + "a mod without a transform is trivially current") + +-- the loader-level runner keeps a failing recipe off everything else +local errors = {} +local ran = AssetTransform.run({ + fs = love.filesystem, errors = errors, + loaded = { thrower, seedTransform("good_mod", [[ + return function(ctx) ctx.writeImage(ctx.blank(1, 1), "out.png") end + ]]) }, +}) +check(ran == 1, "the good recipe ran even though its neighbor failed") +check(#errors == 1 and errors[1]:find("throw_mod", 1, true), + "the failure is attributed to its mod in the loader error feed") +check(love.filesystem.read("save/mod-derived/good_mod/out.png") ~= nil, + "the good recipe's output landed") + +-- the boot path itself runs the recipe: a mod that only declares a +-- transform still ends up with derived art on disk, resolvable through the +-- asset search path, without anyone calling the runner by hand +local Loader = require("src.mods.Loader") + +local bootFiles = { + ["rom-cache.complete"] = "rom-cache-v5:abc", + ["assets/generated/battle/front/mew.png"] = "png", + ["mods/boot_skin/manifest.json"] = + '{"id":"boot_skin","name":"boot skin","version":"1.0.0","api":2,' + .. '"entry":"main.lua","assets_transforms":"transforms.lua"}', + ["mods/boot_skin/main.lua"] = "return function(mod) end", + ["mods/boot_skin/transforms.lua"] = [[ +return function(ctx) + ctx.writeImage(ctx.recolor(ctx.readImage("battle/front/mew.png"), + { {40,80,200}, {70,120,230}, + {150,190,255}, {255,255,255} }), + "battle/front/mew.png") +end +]], +} +local bootfs = { + write = function(name, content) bootFiles[name] = content return true end, + read = function(name) return bootFiles[name] end, + getInfo = function(name) + if bootFiles[name] then return { type = "file" } end + local prefix = name .. "/" + for key in pairs(bootFiles) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(name) + if not bootFiles[name] then return nil, "no file: " .. name end + return load(bootFiles[name], name) + end, + getDirectoryItems = function(name) + local seen, items = {}, {} + local prefix = name .. "/" + for key in pairs(bootFiles) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, +} + +-- the same boot is what hands the resolver the live mod set; seeded on the +-- love filesystem because that is where Assets.resolve stats for overrides +seedFile("mods/boot_skin/overrides/tilesets/overworld.png", "png") +Assets.installLoader(nil) + +local booted = Loader.new({ fs = bootfs }) +check(booted:load({}) == true, + "the mod boots: " .. table.concat(booted.errors, "; ")) +check(Assets.loader ~= nil, + "loading mods installed the asset search path with no explicit call") +check(Assets.resolve("assets/generated/tilesets/overworld.png") + == "mods/boot_skin/overrides/tilesets/overworld.png", + "so the booted mod's overrides/ file shadows the generated path") +local derived = "save/mod-derived/boot_skin/battle/front/mew.png" +check(bootFiles[derived] ~= nil, + "loading a mod ran its declared transform with no explicit call") +check(bootFiles["save/mod-derived/boot_skin/.stamp"] ~= nil, + "the boot-time run stamped itself") + +-- and that stamp is what keeps the second boot from paying for it again +bootFiles[derived] = nil +check(Loader.new({ fs = bootfs }):load({}) == true, "the mod boots again") +check(bootFiles[derived] == nil, "the next boot re-ran nothing") + +-- ------- restore +-- Nothing this suite touched may reach the next one in the run: the mod +-- buses, the stub filesystem, the love facade and the module instances +-- all go back to what they were. + +Runtime.install(savedRuntime.events, savedRuntime.hooks, savedRuntime.errors) +for _, path in ipairs(writtenFiles) do + local before = savedFiles[path] + love.filesystem.write(path, before ~= false and before or nil) +end +love.graphics, love.image = savedGraphics, savedImage +for name, module in pairs(savedLoaded) do package.loaded[name] = module end + +S.finish() diff --git a/tests/mod_link_tests.lua b/tests/mod_link_tests.lua new file mode 100644 index 00000000..b5f8d75d --- /dev/null +++ b/tests/mod_link_tests.lua @@ -0,0 +1,800 @@ +-- M12 link compatibility: fingerprint determinism, the v2 handshake and its +-- verdicts, the negotiated trade subset, the extra bag, ppUps on the wire, +-- and desync attribution over a loopback lockstep battle. Self-contained +-- like the other mod suites: own bootstrap, assert-based checks, error() on +-- failure. Chained from tests/run_link_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +require("src.render.Font").load(Data) + +local Events = require("src.mods.Events") +local Fingerprint = require("src.link.Fingerprint") +local Handshake = require("src.link.Handshake") +local Hooks = require("src.mods.Hooks") +local Input = require("src.core.Input") +local Json = require("src.link.Json") +local LinkBattle = require("src.link.LinkBattle") +local Net = require("src.link.Net") +local Pokemon = require("src.pokemon.Pokemon") +local Protocol = require("src.link.Protocol") +local Runtime = require("src.mods.Runtime") + +local S = require("tests.harness").suite("mod link") +local check, eq = S.check, S.eq + +-- a message as the peer sees it: through the same encoder the wire uses +local function wire(msg) + return Json.decode(Json.encode(msg)) +end + +local function copy(record) + local out = {} + for k, v in pairs(record) do out[k] = v end + return out +end + +-- a merged-data view with its own id maps, so a fixture can retune a record +-- without touching the shared catalog +local function cloneData(base) + local out = { pokemon = {}, moves = {}, type_chart = base.type_chart, + constants = base.constants } + for id, record in pairs(base.pokemon) do out.pokemon[id] = record end + for id, record in pairs(base.moves) do out.moves[id] = record end + return out +end + +local function fakeGame(data, name) + return { data = data, save = { player = { name = name } } } +end + +-- ------- fingerprint determinism + +local vanilla = cloneData(Data) +local first = Fingerprint.compute(vanilla, {}) +eq(Fingerprint.compute(vanilla, {}), first, "fingerprint is stable across calls") +eq(#first, 16, "fingerprint is a 64-bit hex digest") + +-- the same records reached through a differently built map: the digest walks +-- a sorted id list, so table layout can never leak into it +local reordered = { pokemon = {}, moves = {}, type_chart = Data.type_chart, + constants = Data.constants } +local ids = {} +for id in pairs(Data.pokemon) do ids[#ids + 1] = id end +table.sort(ids) +for i = #ids, 1, -1 do reordered.pokemon[ids[i]] = Data.pokemon[ids[i]] end +local moveIds = {} +for id in pairs(Data.moves) do moveIds[#moveIds + 1] = id end +table.sort(moveIds) +for i = #moveIds, 1, -1 do reordered.moves[moveIds[i]] = Data.moves[moveIds[i]] end +eq(Fingerprint.surface(reordered, {}), Fingerprint.surface(vanilla, {}), + "insertion order does not reach the canonical stream") +eq(Fingerprint.compute(reordered, {}), first, "reordered data fingerprints the same") + +-- a record rebuilt with its subtable keys in another order still hashes the +-- same, and a stat edit does not +local restated = cloneData(Data) +local pidgey = copy(Data.pokemon.PIDGEY) +pidgey.baseStats = { special = Data.pokemon.PIDGEY.baseStats.special, + speed = Data.pokemon.PIDGEY.baseStats.speed, + defense = Data.pokemon.PIDGEY.baseStats.defense, + attack = Data.pokemon.PIDGEY.baseStats.attack, + hp = Data.pokemon.PIDGEY.baseStats.hp } +restated.pokemon.PIDGEY = pidgey +eq(Fingerprint.compute(restated, {}), first, "subtable key order is irrelevant") + +local buffed = cloneData(Data) +local strongPidgey = copy(Data.pokemon.PIDGEY) +strongPidgey.baseStats = copy(Data.pokemon.PIDGEY.baseStats) +strongPidgey.baseStats.attack = strongPidgey.baseStats.attack + 1 +buffed.pokemon.PIDGEY = strongPidgey +check(Fingerprint.compute(buffed, {}) ~= first, "a baseStats edit moves the digest") + +-- ------- path independence (excluded fields) + +local repathed = cloneData(Data) +local movedSprites = copy(Data.pokemon.PIDGEY) +movedSprites.spriteFront = "assets/generated/other/machine/pidgey.png" +movedSprites.spriteBack = "assets/generated/other/machine/pidgeyb.png" +movedSprites.source = "ROM:BaseStats[999]" +movedSprites.dexEntry = { kind = "TINY BIRD", heightFt = 1, heightIn = 0, + weight = 4.0, text = "different flavour" } +movedSprites.learnset = {} +repathed.pokemon.PIDGEY = movedSprites +eq(Fingerprint.compute(repathed, {}), first, + "sprite paths, source, dex entry and learnset stay out of the digest") + +local retuned = cloneData(Data) +local strongTackle = copy(Data.moves.TACKLE) +strongTackle.power = 60 +retuned.moves.TACKLE = strongTackle +check(Fingerprint.compute(retuned, {}) ~= first, "a move power edit moves the digest") + +-- the affects-link mod set is folded in, so a logic-only change that ships +-- as a new version moves the digest even with identical records +local withMod = { { id = "rijon", version = "1.2.0", affectsLink = true } } +check(Fingerprint.compute(vanilla, withMod) ~= first, "affects-link mods fold in") +Fingerprint.forget(vanilla) +eq(Fingerprint.compute(vanilla, { { id = "rijon", version = "1.2.0", + affectsLink = false } }), first, + "a mod that declares it stays link-compatible does not") + +-- the hook lets a total conversion widen or narrow the surface +local hooks = Hooks.new() +local events = Events.new() +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +Runtime.install(events, hooks, {}) +hooks:wrap("link.fingerprint", function(nxt, data, mods) + return "ff" .. nxt(data, mods):sub(3) +end, 0, "test") +Fingerprint.forget(vanilla) +eq(Fingerprint.compute(vanilla, {}):sub(1, 2), "ff", "link.fingerprint hook applies") +hooks:removeOwner("test") +Fingerprint.forget(vanilla) +eq(Fingerprint.compute(vanilla, {}), first, "unwrapping restores the vanilla digest") + +-- ------- linkModified: the cheap answer to "can I link with an old peer?" + +-- the loader surface the discovery walk needs, backed by a path->text table +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local pika = { name = "PIKA", baseStats = { hp = 1, attack = 1, defense = 1, + speed = 1, special = 1 }, types = { "NORMAL" }, catchRate = 1, + baseExp = 1, growthRate = "MEDIUM_FAST" } + +local function loadMods(files) + local data = { pokemon = { PIKA = pika }, moves = {}, audio = {} } + local loader = require("src.mods.Loader").new({ fs = memfs(files) }) + loader:load(data) + -- the loader claims the process-wide buses on load; this suite wants its own + Runtime.install(events, hooks, {}) + return { mods = loader, data = data, save = { player = { name = "RED" } } } +end + +local bareGame = loadMods({}) +eq(Handshake.linkModified(bareGame), false, "no mods means an unmodified link") +eq(#Handshake.mods(bareGame), 0, "and an empty mod array on the wire") + +local tweakGame = loadMods({ + ["mods/tweak/manifest.json"] = + '{"id":"tweak","name":"tweak","version":"1.0.0","entry":"main.lua"}', + ["mods/tweak/main.lua"] = [[ +return function(mod) + mod.content.pokemon:patch("PIKA", { catchRate = 40 }) +end +]], +}) +eq(Handshake.linkModified(tweakGame), true, + "a content mod writing a link-surface record modifies the link") +eq(Handshake.mods(tweakGame)[1].affectsLink, false, + "but a content pack does not claim the fingerprint by itself") + +local overhaulGame = loadMods({ + ["mods/rijon/manifest.json"] = + '{"id":"rijon","name":"rijon","version":"1.2.0","entry":"main.lua","profile":"overhaul"}', + ["mods/rijon/main.lua"] = "return function(mod) end", +}) +eq(Handshake.linkModified(overhaulGame), true, "an overhaul modifies the link") +eq(Handshake.mods(overhaulGame)[1].affectsLink, true, "and rides the hello") +eq(Handshake.hello(tweakGame, "trade").linkModified, true, + "the hello carries the flag a v1 peer is judged against") + +-- ------- builtin records are private per dataset + +-- two independent loads must not share record tables: an edit through one +-- dataset (hot reload, a suite loading twice) must never reach the other, +-- nor the module statics the engine falls back on when it has no loader +local isoA, isoB = loadMods({}), loadMods({}) +check(not rawequal(isoA.data.type_chart.types.NORMAL, + isoB.data.type_chart.types.NORMAL), + "builtin type records are private per dataset") +check(not rawequal(isoA.data.type_chart.types.NORMAL, + require("src.battle.TypeChart").TYPES.NORMAL), + "and are not the module's own table") +eq(isoA.data.type_chart.types.NORMAL.category, "physical", + "the copy still carries the vanilla value") +isoA.data.type_chart.types.NORMAL.category = "special" +eq(isoB.data.type_chart.types.NORMAL.category, "physical", + "an edit through one dataset stays in it") +check(not rawequal(isoA.data.statuses.BRN, isoB.data.statuses.BRN), + "status records are private per dataset") +check(rawequal(isoA.data.statuses.BRN.residual, isoB.data.statuses.BRN.residual), + "handler functions ride the copy by reference") + +-- ------- link_fields: a declared extra field that forces agreement + +local plainGame = loadMods({}) +eq(plainGame.data.link_fields, nil, + "an unregistered link_fields namespace never reaches Data") +local plainPrint = Fingerprint.compute(plainGame.data, {}) + +-- the held-item mod from the design, registered verbatim +local heldGame = loadMods({ + ["mods/held_items/manifest.json"] = + '{"id":"held_items","name":"held items","version":"1.0.0","entry":"main.lua",' + .. '"api":2,"affects_link":true}', + ["mods/held_items/main.lua"] = [[ +return function(mod) + mod.content.link_fields:register("held_item", { + rev = 1, + pack = function(mon) return mon.heldItem end, + unpack = function(mon, v) mon.heldItem = v end, + }) +end +]], +}) +eq(#heldGame.mods.errors, 0, "a link_fields registration loads clean") +check(heldGame.mods.content.link_fields ~= nil, + "the loader builds content.link_fields from the catalog") +eq(heldGame.data.link_fields.held_item.rev, 1, "and the merge lands the record") +eq(type(heldGame.data.link_fields.held_item.pack), "function", + "the codec rides along for the wire") +eq(Handshake.linkModified(heldGame), true, "a declared field modifies the link") +local heldPrint = Fingerprint.compute(heldGame.data, {}) +check(heldPrint ~= plainPrint, "and moves the fingerprint off vanilla") + +-- rev is what an author bumps when the codec's meaning changes, so a peer +-- on the old revision lands in subset instead of desyncing mid-battle +heldGame.data.link_fields.held_item.rev = 2 +Fingerprint.forget(heldGame.data) +check(Fingerprint.compute(heldGame.data, {}) ~= heldPrint, "bumping rev moves it again") + +-- two mods can ship different bodies under one rev, so the digest must not +-- pretend it can see them +heldGame.data.link_fields.held_item.rev = 1 +heldGame.data.link_fields.held_item.pack = function() return nil end +Fingerprint.forget(heldGame.data) +eq(Fingerprint.compute(heldGame.data, {}), heldPrint, + "swapping the codec body alone does not") + +local revlessGame = loadMods({ + ["mods/revless/manifest.json"] = + '{"id":"revless","name":"revless","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/revless/main.lua"] = [[ +return function(mod) + mod.content.link_fields:register("held_item", { pack = function() end }) +end +]], +}) +check(table.concat(revlessGame.mods.errors, "\n") + :find("link_fields.held_item", 1, true) ~= nil, + "a field with no rev is rejected by name") +eq(revlessGame.data.link_fields, nil, "and leaves no residue") + +-- ------- handshake verdicts + +local helloA = Handshake.hello(fakeGame(vanilla, "RED"), "trade") +local helloB = Handshake.hello(fakeGame(cloneData(Data), "BLUE"), nil) +eq(helloA.protocol, 2, "hello announces the protocol revision") +eq(helloA.apiVersion, require("src.core.Version").modApi, "hello carries the api version") +eq(helloA.linkModified, false, "no mods means an unmodified link surface") +eq(helloA.fingerprint, helloB.fingerprint, "identical data fingerprints alike") +eq(Handshake.checkCompat(helloA, wire(helloB)), "full", "matching peers get full") +check(Handshake.battleAllowed("full"), "full allows lockstep") +check(Handshake.strict("full"), "full negotiates strictly") + +-- side B adds a species and retunes a move: same engine, different surface +local modded = cloneData(Data) +local zorua = copy(Data.pokemon.RATTATA) +zorua.id, zorua.name, zorua.dex = "ZORUA", "ZORUA", 152 +modded.pokemon.ZORUA = zorua +local moddedTackle = copy(Data.moves.TACKLE) +moddedTackle.power = 60 +modded.moves.TACKLE = moddedTackle +local helloMod = Handshake.hello(fakeGame(modded, "BLUE"), nil) +helloMod.mods = { { id = "rijon", version = "1.2.0", affectsLink = true } } +eq(Handshake.checkCompat(helloA, wire(helloMod)), "subset", + "differing fingerprints negotiate a subset") +check(not Handshake.battleAllowed("subset"), "subset refuses lockstep") +check(Handshake.tradeAllowed("subset"), "subset still trades") + +-- v1 interop: no protocol field at all +eq(Handshake.checkCompat(helloA, { type = "hello", name = "OLD", mode = "trade" }), + "vanilla_peer", "a v1 peer is compatible with an unmodified game") +local modifiedLocal = { linkModified = true, engineVersion = helloA.engineVersion, + fingerprint = "deadbeefdeadbeef", mods = {} } +eq(Handshake.checkCompat(modifiedLocal, { type = "hello", name = "OLD" }), + "refused", "a v1 peer is refused by a link-modified game") +check(not Handshake.strict(nil), "no verdict keeps the v1 unpack path") + +-- a different engine major is refused outright +local nextEngine = Handshake.hello(fakeGame(vanilla, "BLUE"), nil) +nextEngine.engineVersion = "2.0.0" +eq(Handshake.checkCompat(helloA, nextEngine), "refused", "engine major mismatch refuses") + +local lines = Handshake.describe(helloA, wire(helloMod), "subset", "battle") +check(#lines > 0, "the incompatibility screen has something to say") +local joined = table.concat(lines, " ") +check(joined:find("RIJON", 1, true) ~= nil, "the report names the missing mod") +check(joined:find("battle", 1, true) ~= nil, "the report says battle is unavailable") +for _, line in ipairs(lines) do + check(#line <= 20, "report line fits the screen: " .. line) +end +local refusedLines = Handshake.describe(modifiedLocal, + { type = "hello", name = "OLD" }, "refused", "trade") +check(#refusedLines > 0, "a refusal explains itself too") +for _, line in ipairs(refusedLines) do + check(#line <= 20, "refusal line fits the screen: " .. line) +end + +-- ------- ppUps and the extra bag on the wire + +local ppMon = Pokemon.new(Data, "PIDGEY", 20) +local base = Data.moves[ppMon.moves[1].id].pp +ppMon.moves[1].ppUps = 3 +ppMon.moves[1].pp = base + 3 * math.floor(base / 5) +ppMon.extra = { held_items = { held_item = "LEFTOVERS", count = 2, on = true }, + bogus = print } +local packedPP = wire(Protocol.packMon(ppMon)) +eq(packedPP.moves[1].ppUps, 3, "ppUps reaches the wire") +local restored = Protocol.unpackMon(Data, packedPP, { strict = true }) +eq(restored.moves[1].ppUps, 3, "ppUps survives the round trip") +eq(restored.moves[1].pp, base + 3 * math.floor(base / 5), + "PP clamps to the PP-Up-adjusted maximum, not base PP") +eq(restored.extra.held_items.held_item, "LEFTOVERS", "the extra bag round-trips") +eq(restored.extra.held_items.count, 2, "extra numbers round-trip") +eq(restored.extra.held_items.on, true, "extra booleans round-trip") +eq(restored.extra.bogus, nil, "a function in the extra bag is stripped") + +local plainMon = Pokemon.new(Data, "PIDGEY", 20) +local packedPlain = Protocol.packMon(plainMon) +eq(packedPlain.moves[1].ppUps, nil, "a mon without PP Ups sends no ppUps key") +eq(packedPlain.extra, nil, "a mon without extra data sends no bag") +eq(Protocol.unpackMon(Data, wire(packedPlain)).moves[1].ppUps, nil, + "an absent ppUps stays absent") + +-- ------- negotiated rejection replaces the silent fallbacks + +local orphan = { species = "PIDGEY", level = 20, + moves = { { id = "NOT_A_MOVE", pp = 10 } } } +local rebuilt = Protocol.unpackMon(Data, orphan) +eq(rebuilt.moves[1].id, "TACKLE", "the v1 path keeps the TACKLE substitute") +local rejected, why = Protocol.unpackMon(Data, orphan, { strict = true }) +eq(rejected, nil, "strict mode rejects a mon with no shared moves") +eq(why, "no shared moves", "and says why") +local unknown, unknownWhy = Protocol.unpackMon(Data, + { species = "ZORUA", level = 20, moves = {} }, { strict = true }) +eq(unknown, nil, "strict mode rejects an unknown species") +check(unknownWhy ~= nil, "an unknown species is reported") + +-- ------- subset trade: only mons both games rebuild identically + +local partyVanilla = { Pokemon.new(Data, "PIDGEY", 12), + Pokemon.new(Data, "RATTATA", 12) } +partyVanilla[1].moves = { { id = "GUST", pp = Data.moves.GUST.pp } } +partyVanilla[2].moves = { { id = "TACKLE", pp = Data.moves.TACKLE.pp } } +local partyModded = { Pokemon.new(modded, "ZORUA", 12), + Pokemon.new(modded, "RATTATA", 12), + Pokemon.new(modded, "PIDGEY", 12) } +partyModded[1].moves = { { id = "GUST", pp = modded.moves.GUST.pp } } +partyModded[2].moves = { { id = "TACKLE", pp = modded.moves.TACKLE.pp } } +partyModded[3].moves = { { id = "GUST", pp = modded.moves.GUST.pp } } + +local sessionA = Protocol.TradeSession.new(Data, partyVanilla, + { subset = true, strict = true, peerName = "BLUE" }) +local sessionB = Protocol.TradeSession.new(modded, partyModded, + { subset = true, strict = true, peerName = "RED" }) +local recordsA, recordsB = wire(sessionA:opening()), wire(sessionB:opening()) +eq(recordsA.type, "records", "a subset trade opens with the record hashes") +local partyMsgA = wire(sessionA:handle(recordsB)) +local partyMsgB = wire(sessionB:handle(recordsA)) +eq(#partyMsgA.mons, 1, "only the agreed mons leave the vanilla game") +eq(#partyMsgB.mons, 1, "only the agreed mons leave the modded game") +eq(partyMsgA.mons[1].species, "PIDGEY", "the clean PIDGEY is eligible") +eq(partyMsgB.mons[1].species, "PIDGEY", "the modded game sends its clean PIDGEY") +check(sessionA:canPick(1), "a mon with shared data can be picked") +check(not sessionA:canPick(2), "a mon knowing a retuned move cannot") +check(sessionA.reasons[2] ~= nil, "the ineligible mon carries a reason") +check(not sessionB:canPick(1), "a species the other game lacks cannot be picked") +eq(sessionB.reasons[1], "not on the other game", "and says so") + +sessionA:handle(partyMsgB) +sessionB:handle(partyMsgA) +eq(sessionA.stage, "picking", "both parties arrived") +local pickA = wire(sessionA:pick(1)) -- real slot 1 +local pickB = wire(sessionB:pick(3)) -- real slot 3, wire slot 1 +eq(pickB.index, 1, "the wire index is a position in the filtered list") +sessionA:handle(pickB) +sessionB:handle(pickA) +eq(sessionA.stage, "confirming", "both picks landed") +sessionA:handle(wire(sessionB:confirm(true))) +sessionB:handle(wire(sessionA:confirm(true))) +eq(sessionA.stage, "done", "the subset trade completes") +local received = sessionA:apply(nil) +eq(received.species, "PIDGEY", "the vanilla game received the agreed mon") +eq(partyVanilla[1], received, "and it landed in the slot that was given") + +-- a full-verdict session sends the whole party and indexes it directly +local fullSession = Protocol.TradeSession.new(Data, partyVanilla, { strict = true }) +eq(fullSession.stage, "waitParty", "a full session skips the record exchange") +eq(#fullSession:opening().mons, 2, "a full session sends the whole party") +eq(fullSession:wireIndex(2), 2, "and its wire indices are party slots") + +-- ------- the state machine: hello promoted to pairing, verdict branch + +local LinkState = require("src.link.LinkState") + +local function mkInput() + local stub = { pressed = {} } + function stub:wasPressed(key) return self.pressed[key] == true end + return stub +end + +local function linkGame(name, species, data) + local save = require("src.core.SaveData").newGame() + save.player.name = name + table.insert(save.party, Pokemon.new(Data, species, 20)) + local stack = { list = {} } + function stack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function stack:pop() return table.remove(self.list) end + function stack:top() return self.list[#self.list] end + return { data = data or Data, save = save, stack = stack, input = mkInput() } +end + +-- two paired states, host already listening and guest already dialling +local function pairStates(gameA, gameB) + local netA, netB = Net.loopbackPair() + local host, guest = LinkState.new(gameA), LinkState.new(gameB) + host.net, guest.net = netA, netB + host.stage, guest.stage = "hosting", "joining" + gameA.stack:push(host) + gameB.stack:push(guest) + return host, guest +end + +local function pump(a, b, gameA, gameB, times) + for _ = 1, (times or 1) do + a:update(1 / 60) + b:update(1 / 60) + gameA.input.pressed = {} + gameB.input.pressed = {} + end +end + +local gameHost, gameGuest = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA") +local host, guest = pairStates(gameHost, gameGuest) +pump(host, guest, gameHost, gameGuest, 2) +eq(host.stage, "modeSelect", "the host reaches mode select") +eq(guest.stage, "waitMode", "the guest waits for the mode") +check(guest.myHello ~= nil and guest.myHello.protocol == 2, + "the guest announces itself the moment it pairs") +check(host.peerHello ~= nil, "the host has the peer hello before it picks") + +gameHost.input.pressed = { a = true } +host:update(1 / 60) +gameHost.input.pressed = {} +eq(host.verdict, "full", "two identical games agree") +eq(host.stage, "trade", "and go straight into the mode") +pump(host, guest, gameHost, gameGuest, 3) +eq(guest.verdict, "full", "the guest reaches the same verdict") +eq(host.trade.stage, "picking", "the host's trade session is ready") +eq(guest.trade.stage, "picking", "the guest's trade session is ready") +check(host.trade.strict, "a v2 verdict unpacks strictly") + +-- a v1 peer sends the raw {name, mode} hello and nothing else +local gameOld = linkGame("RED", "PIDGEY") +local oldNet, peerNet = Net.loopbackPair() +local v1guest = LinkState.new(gameOld) +v1guest.net = oldNet +v1guest.stage = "joining" +gameOld.stack:push(v1guest) +v1guest:update(1 / 60) +peerNet:send({ type = "hello", name = "OLD", mode = "trade" }) +peerNet:send({ type = "party", + mons = Protocol.packParty({ Pokemon.new(Data, "MACHOKE", 20) }) }) +v1guest:update(1 / 60) +eq(v1guest.verdict, "vanilla_peer", "an old peer is accepted by an unmodified game") +v1guest:update(1 / 60) +eq(v1guest.trade.stage, "picking", "and the v1 trade runs as it always did") +check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules") + +-- the host talking to a peer that never says hello falls back after the +-- grace period, and its own hello still carries the v1 fields +local gameLone = linkGame("RED", "PIDGEY") +local loneNet, silentNet = Net.loopbackPair() +local v1host = LinkState.new(gameLone) +v1host.net = loneNet +v1host.stage = "hosting" +gameLone.stack:push(v1host) +v1host:update(1 / 60) +gameLone.input.pressed = { a = true } +v1host:update(1 / 60) +gameLone.input.pressed = {} +eq(v1host.stage, "waitHello", "the host waits for the peer's hello") +for _ = 1, 200 do v1host:update(1 / 60) end +eq(v1host.verdict, "vanilla_peer", "silence means a pre-mod peer") +eq(v1host.stage, "trade", "and the v1 path runs") +local sawHello = false +for _, msg in ipairs(silentNet:poll()) do + if msg.type == "hello" then + sawHello = true + eq(msg.mode, "trade", "the hello still carries the mode a v1 guest reads") + eq(msg.name, "RED", "and the name") + end +end +check(sawHello, "the host's hello went out") + +-- mismatched surfaces: the screen explains, trade continues in subset mode +local gameVan, gameMod = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA", modded) +local vanHost, modGuest = pairStates(gameVan, gameMod) +pump(vanHost, modGuest, gameVan, gameMod, 2) +gameVan.input.pressed = { a = true } +vanHost:update(1 / 60) +gameVan.input.pressed = {} +eq(vanHost.verdict, "subset", "differing data lands in subset") +eq(vanHost.stage, "notice", "and shows the incompatibility screen") +check(#vanHost.noticeLines > 0, "the screen has lines to draw") +vanHost:draw() -- smoke: the report renders under the headless stub +check(not vanHost.noticeExits, "a subset trade may continue") +pump(vanHost, modGuest, gameVan, gameMod, 2) +eq(modGuest.stage, "notice", "the guest sees the same screen") +gameVan.input.pressed = { a = true } +gameMod.input.pressed = { a = true } +vanHost:update(1 / 60) +modGuest:update(1 / 60) +gameVan.input.pressed = {} +gameMod.input.pressed = {} +eq(vanHost.trade.stage, "waitRecords", "continuing opens a subset session") +pump(vanHost, modGuest, gameVan, gameMod, 3) +eq(vanHost.trade.stage, "picking", "the record exchange completes") +check(vanHost.trade.subset, "the session negotiated a subset") +vanHost:draw() -- smoke: the ineligible rows render too + +-- the same mismatch on the battle side refuses instead +local gameVan2, gameMod2 = linkGame("RED", "PIDGEY"), linkGame("BLUE", "RATTATA", modded) +local vanHost2, modGuest2 = pairStates(gameVan2, gameMod2) +pump(vanHost2, modGuest2, gameVan2, gameMod2, 2) +vanHost2.index = 2 -- BATTLE +gameVan2.input.pressed = { a = true } +vanHost2:update(1 / 60) +gameVan2.input.pressed = {} +eq(vanHost2.stage, "notice", "a mismatched link battle stops at the screen") +check(vanHost2.noticeExits, "and the screen is the end of it") +vanHost2:draw() -- smoke: the battle-refusal wording renders too +gameVan2.input.pressed = { a = true } +vanHost2:update(1 / 60) +gameVan2.input.pressed = {} +check(gameVan2.stack:top() ~= vanHost2, "acknowledging leaves link play") + +-- ------- lockstep battle: refusal, the turn-order hook, desync attribution + +-- pinned DVs keep the lockstep run reproducible: the shared seed only fixes +-- the RNG stream, and rolled DVs would move the whole battle underneath it +local function fixedMon(species, level) + local mon = Pokemon.new(Data, species, level) + mon.dvs = { hp = 8, attack = 8, defense = 8, speed = 8, special = 8 } + mon.statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } + mon.stats = require("src.pokemon.Stats").calc(Data.pokemon[species], level, + mon.dvs, mon.statExp) + mon.hp = mon.stats.hp + return mon +end + +local function makeFakeGame(leadSpecies) + local save = require("src.core.SaveData").newGame() + table.insert(save.party, fixedMon(leadSpecies, 50)) + local stack = { list = {} } + function stack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function stack:pop() table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end + end + return { data = Data, input = Input, stack = stack, save = save } +end + +Input:init() + +local refused, refusedWhy = LinkBattle.newHost(makeFakeGame("CHARIZARD"), + select(1, Net.loopbackPair()), + { myParty = Protocol.packParty(partyVanilla), + theirParty = Protocol.packParty(partyVanilla), verdict = "subset" }) +eq(refused, nil, "a subset verdict refuses to build a link battle") +check(refusedWhy ~= nil, "and reports why") +local strictRefused = LinkBattle.newHost(makeFakeGame("CHARIZARD"), + select(1, Net.loopbackPair()), + { myParty = { { species = "ZORUA", level = 20, moves = {} } }, + theirParty = Protocol.packParty(partyVanilla), + verdict = "full", strict = true }) +eq(strictRefused, nil, "a strict battle refuses a mon the peer cannot rebuild") + +-- ------- pokemon.received on the link-battle unpack + +-- the held-item validator from the design's mod-author example: it must get +-- the same shot at a link battle's mons that it gets at a traded one +local function heldMon(species, level, item) + local mon = fixedMon(species, level) + mon.extra = { held_items = { held_item = item } } + return mon +end + +local mine = { heldMon("CHARIZARD", 50, "LEFTOVERS"), fixedMon("PIDGEY", 20) } +local theirs = { heldMon("BLASTOISE", 50, "BAD_ITEM") } +local packedMine, packedTheirs = Protocol.packParty(mine), Protocol.packParty(theirs) + +local seen +events:on("pokemon.received", function(payload) + seen[#seen + 1] = payload + payload.mon.nickname = "CHECKED" + local bag = payload.mon.extra and payload.mon.extra.held_items + if bag and bag.held_item == "BAD_ITEM" then bag.held_item = nil end +end, 0, "test") + +seen = {} +local hostSide = LinkBattle.newHost(makeFakeGame("CHARIZARD"), + select(1, Net.loopbackPair()), + { myParty = packedMine, theirParty = packedTheirs, theirName = "BLUE", + seed = 11, verdict = "full", strict = true }) +check(hostSide ~= nil, "the host side builds") +eq(#seen, 3, "pokemon.received fires once per mon on both parties") +eq(seen[1].from, "link", "the payload names the link as the source") +eq(seen[1].peerName, "BLUE", "and carries the peer name") +eq(seen[1].mon.species .. "," .. seen[2].mon.species .. "," .. seen[3].mon.species, + "CHARIZARD,PIDGEY,BLASTOISE", "the host announces its own party first") +eq(hostSide.player.name, "CHECKED", + "a listener's edit reaches the battler it is built from") +eq(hostSide.enemy.mon.extra.held_items.held_item, nil, + "an unrecognised held item is stripped before the simulation sees it") + +-- the guest holds the same two parties the other way round and has to walk +-- them in the same order, or a mutating validator desyncs the lockstep +seen = {} +local guestSide = LinkBattle.newGuest(makeFakeGame("BLASTOISE"), + select(1, Net.loopbackPair()), + { myParty = packedTheirs, theirParty = packedMine, theirName = "RED", + seed = 11, verdict = "full", strict = true }) +check(guestSide ~= nil, "the guest side builds") +eq(#seen, 3, "and sees the same three mons") +eq(seen[1].mon.species .. "," .. seen[2].mon.species .. "," .. seen[3].mon.species, + "CHARIZARD,PIDGEY,BLASTOISE", "in the same host-first order as the host") + +events:removeOwner("test") +seen = {} +local unhooked = LinkBattle.newHost(makeFakeGame("CHARIZARD"), + select(1, Net.loopbackPair()), + { myParty = packedMine, theirParty = packedTheirs, theirName = "BLUE", + seed = 11, verdict = "full", strict = true }) +eq(#seen, 0, "nothing is emitted once the listener is gone") +eq(unhooked.enemy.mon.extra.held_items.held_item, "BAD_ITEM", + "and the unpacked mon is untouched with nobody subscribed") + +-- both sides run the same modded ordering rule and the same asymmetric +-- stage bump; the first proves the link path honours the battle hooks M6 +-- landed, the second proves a divergence is caught and named +local orderCalls = 0 +hooks:wrap("battle.turn_order", function(nxt, a, aMove, b, bMove, ctx) + orderCalls = orderCalls + 1 + return nxt(a, aMove, b, bMove, ctx) +end, 0, "test") + +local turnStarts = 0 +local desync = nil +events:on("battle.turn_started", function(payload) + turnStarts = turnStarts + 1 + check(payload.turn ~= nil, "battle.turn_started carries the turn number") +end, 0, "test") +events:on("link.desync", function(payload) desync = desync or payload end, 0, "test") + +local gameA, gameB = makeFakeGame("CHARIZARD"), makeFakeGame("BLASTOISE") +gameB.save.player.name = "BLUE" +local netA, netB = Net.loopbackPair() +local packedA = Protocol.packParty(gameA.save.party) +local packedB = Protocol.packParty(gameB.save.party) +local battleA = LinkBattle.newHost(gameA, netA, { + myParty = packedA, theirParty = packedB, theirName = "BLUE", seed = 424242, + verdict = "full", strict = true }) +local battleB = LinkBattle.newGuest(gameB, netB, { + myParty = packedB, theirParty = packedA, theirName = "RED", seed = 424242, + verdict = "full", strict = true }) +check(battleA ~= nil and battleB ~= nil, "a full verdict builds both sides") + +-- only the host's simulation gets the extra boost, so the two states must +-- disagree on the actives component +events:on("battle.turn_started", function(payload) + if payload.battle == battleA and payload.turn == 2 then + local stages = payload.battle.player.stages + stages.attack = (stages.attack or 0) + 2 + end +end, 0, "test") + +local resA, resB +battleA.onFinish = function(result) resA = result end +battleB.onFinish = function(result) resB = result end +gameA.stack:push(battleA) +gameB.stack:push(battleB) +local guard = 0 +while (resA == nil or resB == nil) and guard < 60000 do + guard = guard + 1 + Input.pressed = { a = true } + gameA.stack:update(1 / 60) + gameB.stack:update(1 / 60) +end +check(orderCalls > 0, "the link path routes turn order through battle.turn_order") +check(turnStarts > 0, "the link path emits battle.turn_started") +eq(resA, "draw", "the host ends the desynced match as a draw") +eq(resB, "draw", "the guest ends the desynced match as a draw") +check(desync ~= nil, "link.desync fired") +eq(desync.component, "actives", "the report names the diverging component") +eq(desync.turn, 2, "and the turn it happened on") +check(desync.localHash ~= desync.remoteHash, "the two component hashes differ") +check(battleA.localHashes[2]:find("^%u+:%d+:") ~= nil, + "the hash message keeps the v1 value shape a pre-mod peer compares") + +-- a verified turn stays recorded, so a finished battle's whole hash trail +-- can be swept; consuming compared turns left 0-1 entries behind +for turn = 1, battleA.turnCount do + check(battleA.localHashes[turn] ~= nil and battleA.localParts[turn] ~= nil, + "the host retains the turn " .. turn .. " hash record") +end +eq(battleA.localHashes[1], battleB.localHashes[1], + "the retained pre-desync hashes agree across peers") + +-- a pre-mod peer sends no parts at all, so the combined value has to stay +-- the comparison of record +desync = nil +local gameOldPeer = makeFakeGame("CHARIZARD") +local oldNetA = select(1, Net.loopbackPair()) +local battleOld = LinkBattle.newHost(gameOldPeer, oldNetA, { + myParty = Protocol.packParty(gameOldPeer.save.party), + theirParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party), + theirName = "OLD", seed = 7 }) +gameOldPeer.stack:push(battleOld) +battleOld.localHashes[1] = "CHARIZARD:100:nil|BLASTOISE:100:nil" +table.insert(oldNetA.inbox, { type = "hash", turn = 1, + value = "CHARIZARD:100:nil|BLASTOISE:100:nil" }) +gameOldPeer.stack:update(1 / 60) +eq(battleOld.result, nil, "a matching v1 hash is not a desync") +eq(desync, nil, "and nothing is reported") +check(battleOld.localHashes[1] ~= nil, "a matching v1 hash stays recorded") +table.insert(oldNetA.inbox, { type = "hash", turn = 2, value = "elsewhere" }) +battleOld.localHashes[2] = "CHARIZARD:90:nil|BLASTOISE:80:nil" +gameOldPeer.stack:update(1 / 60) +eq(battleOld.result, "draw", "a differing v1 hash still ends the match") +check(desync ~= nil and desync.component == "state", + "and is attributed to the whole state, the only thing a v1 peer sends") + +events:removeOwner("test") +hooks:removeOwner("test") +Runtime.install(savedEvents, savedHooks, nil) + +S.finish() diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua new file mode 100644 index 00000000..7c827919 --- /dev/null +++ b/tests/mod_loader_tests.lua @@ -0,0 +1,244 @@ +-- Headless mod-loader tests over an injected in-memory filesystem: +-- discovery, dependency order, merge, rollback, unseal, emit isolation, +-- the no-love run, and the no-mod lifecycle parity (mods.loaded / +-- game.ready fire once). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Loader = require("src.mods.Loader") +local Events = require("src.mods.Events") +local Runtime = require("src.mods.Runtime") +local Logger = require("src.core.Logger") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local S = require("tests.harness").suite("headless mod loader") +local check = S.check + +local function logged(fragmentA, fragmentB) + for _, line in ipairs(Logger.history) do + if line:find(fragmentA, 1, true) and line:find(fragmentB, 1, true) then + return true + end + end + return false +end + +-- the fs surface the loader needs, backed by a flat path->content table +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function manifestJson(id, deps) + return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua","dependencies":%s}]]) + :format(id, id, deps or "[]") +end + +-- ------- no love global: the loader runs on opts.fs alone. +-- run_tests installs a stub love before chaining this file, so the global +-- is stashed and nilled to prove nothing on the load path reaches for it. +local savedLove = love +love = nil +local headlessOk, headlessErr = pcall(function() + local headlessFiles = { + -- a stale entry for an uninstalled mod must survive the round-trip + ["options.lua"] = "return { mods = { ghost = false } }", + ["mods/solo/manifest.json"] = manifestJson("solo"), + ["mods/solo/main.lua"] = [[ +return function(mod) + mod.content.pokemon:register("SOLOMON", { name = "SOLOMON" }) +end +]], + } + local headlessFs = memfs(headlessFiles) + headlessFs.write = function(path, content) + headlessFiles[path] = content + return true + end + local headlessData = { pokemon = {} } + local headlessLoader = Loader.new({ fs = headlessFs }) + check(headlessLoader:load(headlessData) == true, + "load runs with no love global") + check(headlessData.pokemon.SOLOMON ~= nil, + "no-love load merges registered content") + check(headlessLoader:setEnabled("solo", false) == true, + "enable toggle works with no love global") + check(headlessFiles["options.lua"]:find("solo = false", 1, true) ~= nil, + "enable state persists through the injected fs") + check(headlessFiles["options.lua"]:find("ghost = false", 1, true) ~= nil, + "existing options entries survive the state write") +end) +love = savedLove +if not headlessOk then error(headlessErr) end + +love = love or require("tests.love_stub") + +-- ------- discovery, dependency order, merge +-- "addon" sorts before "base" so only the dependency edge can order them +_G.MOD_TEST_ORDER = {} +local files = { + ["mods/addon/manifest.json"] = manifestJson("addon", '["base"]'), + ["mods/addon/main.lua"] = [[ +return function(mod) + _G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "addon" + mod.content.pokemon:override("MODMON", { name = "ADDONMON" }) +end +]], + ["mods/base/manifest.json"] = manifestJson("base"), + ["mods/base/main.lua"] = [[ +return function(mod) + _G.MOD_TEST_ORDER[#_G.MOD_TEST_ORDER + 1] = "base" + mod.content.pokemon:register("MODMON", { name = "BASEMON" }) + mod.content.music:register("MOD_SONG", { file = "song.ogg" }) +end +]], +} +local data = { pokemon = { PIKA = { name = "PIKA" } }, audio = {} } +local loader = Loader.new({ fs = memfs(files) }) +check(loader:load(data) == true, "headless load succeeds with injected fs") +check(loader.mods.addon ~= nil and loader.mods.base ~= nil, + "discovery finds both mods") +check(_G.MOD_TEST_ORDER[1] == "base" and _G.MOD_TEST_ORDER[2] == "addon", + "topo-sort runs the dependency before its dependent") +check(data.pokemon.MODMON ~= nil and data.pokemon.MODMON.name == "ADDONMON", + "registered content merges into data") +check(data.pokemon.PIKA.name == "PIKA", "base records untouched by the merge") +check(data.audio.songs ~= nil and data.audio.songs.MOD_SONG ~= nil, + "music registrations merge into data.audio.songs") + +-- content froze at the merge boundary; the buses stayed open +check(not pcall(function() loader.content.pokemon:register("LATE", {}) end), + "content registries freeze after the merge loop") +local heard = 0 +loader.events:on("post.boot", function() heard = heard + 1 end, 0, "test") +loader.events:emit("post.boot") +check(heard == 1, "runtime subscription after load succeeds (unsealed)") + +-- ------- rollback: a failing entry chunk leaves zero residue +local rollbackFiles = { + ["mods/base/manifest.json"] = manifestJson("base"), + ["mods/base/main.lua"] = [[ +return function(mod) + mod.content.pokemon:register("SHARED", { name = "BASE" }) +end +]], + ["mods/crasher/manifest.json"] = manifestJson("crasher", '["base"]'), + ["mods/crasher/main.lua"] = [[ +return function(mod) + mod.content.pokemon:register("CRASHMON", { name = "CRASH" }) + mod.content.pokemon:override("SHARED", { name = "CRASHED" }) + mod.events:on("mods.loaded", function() end) + mod.hooks:wrap("battle.damage", function(next, ...) return next(...) end) + error("crasher entry failed") +end +]], + ["mods/survivor/manifest.json"] = manifestJson("survivor"), + ["mods/survivor/main.lua"] = [[ +return function(mod) + mod.content.items:register("SURVIVOR_ITEM", { price = 5 }) +end +]], +} +local rollbackData = { pokemon = {}, items = {} } +local rollbackLoader = Loader.new({ fs = memfs(rollbackFiles) }) +check(rollbackLoader:load(rollbackData) == false, "load reports the failing mod") +check(rollbackData.pokemon.SHARED ~= nil + and rollbackData.pokemon.SHARED.name == "BASE", + "failed override rolled back to the earlier mod's value") +check(rollbackData.pokemon.CRASHMON == nil, + "failed registration never reaches merged data") +check(rollbackLoader.content.pokemon.ops.CRASHMON == nil + and rollbackLoader.content.pokemon.owners.CRASHMON == nil, + "failed registration leaves no registry residue") +check(rollbackLoader.content.pokemon.owners.SHARED == "base", + "registry owner restored on rollback") +check(rollbackLoader.events.listeners["mods.loaded"] == nil, + "failed mod's event subscription removed") +check(rollbackLoader.hooks.chains["battle.damage"] == nil, + "failed mod's hook wrap removed") +check(rollbackData.items.SURVIVOR_ITEM ~= nil, + "unrelated mod still loads after a failure") + +-- ------- safe emit: a throwing listener never breaks the emitting path +local isoFiles = { + ["mods/noisy/manifest.json"] = manifestJson("noisy"), + ["mods/noisy/main.lua"] = [[ +return function(mod) + mod.events:on("mods.loaded", function() error("noisy listener blew up") end) +end +]], +} +local isoLoader = Loader.new({ fs = memfs(isoFiles) }) +check(isoLoader:load({ pokemon = {} }) == true, + "a throwing listener does not fail the load") +check(logged("[noisy]", "mods.loaded"), + "listener failure attributed to the subscribing mod") + +-- ------- no-mod parity: an empty mods dir merges nothing, adds nothing +local pristine = { pokemon = { A = { hp = 1 } }, moves = {} } +local emptyLoader = Loader.new({ fs = memfs({}) }) +local loadedCount = 0 +emptyLoader.events:on("mods.loaded", function() loadedCount = loadedCount + 1 end, + 0, "test") +check(emptyLoader:load(pristine) == true, "empty load succeeds") +check(loadedCount == 1, "mods.loaded fires exactly once with mods absent") +check(pristine.pokemon.A.hp == 1 and next(pristine.moves) == nil, + "no-mod load leaves data untouched") +-- the engine's own registrations create their namespaces on every boot; +-- nothing else may appear +local engineRoots = require("src.mods.Builtins").namespaceRoots() +for key in pairs(pristine) do + check(key == "pokemon" or key == "moves" or engineRoots[key], + "no-mod load adds only engine namespaces (saw " .. key .. ")") +end + +-- ------- full boot: game.ready and mods.loaded fire exactly once each. +-- Events.emit is patched at the metatable so both buses are counted. +local counts = {} +local realEmit = Events.emit +Events.emit = function(self, name, payload) + counts[name] = (counts[name] or 0) + 1 + return realEmit(self, name, payload) +end +local Game = require("src.core.Game") +Game:load() +Events.emit = realEmit +check(counts["mods.loaded"] == 1, "boot emits mods.loaded exactly once") +check(counts["game.ready"] == 1, "boot emits game.ready exactly once") + +-- leave shared singletons the way we found them for later chained tests +local StateStack = require("src.core.StateStack") +while StateStack:top() do StateStack:pop() end +require("src.core.Music").stop() +Runtime.install(savedEvents, savedHooks) +_G.MOD_TEST_ORDER = nil + +S.finish() diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua new file mode 100644 index 00000000..db505b24 --- /dev/null +++ b/tests/mod_manifest_tests.lua @@ -0,0 +1,470 @@ +-- Manifest v2 and lifecycle v2 over an injected in-memory filesystem: +-- semver ranges, game_version enforcement, conflict refusal, disabled and +-- version-mismatched dependencies, cycle isolation, inter-mod exports/find, +-- the rest of the v2 mod object, and the dev-mode permissions tripwire. +package.path = "./?.lua;./?/init.lua;" .. package.path +-- run_tests installs the stub before chaining this file; standalone runs get +-- their own so mod.assets:image has a graphics context either way +love = love or require("tests.love_stub") + +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Semver = require("src.mods.Semver") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.Version") +local Logger = require("src.core.Logger") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local S = require("tests.harness").suite("mod manifest v2") +local check = S.check + +local function logged(fragmentA, fragmentB) + for _, line in ipairs(Logger.history) do + if line:find(fragmentA, 1, true) + and (not fragmentB or line:find(fragmentB, 1, true)) then + return true + end + end + return false +end + +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +-- fields is a table of extra manifest json fragments, e.g. {api = "2"} +local function manifestJson(id, extra) + local parts = { + ('"id":"%s"'):format(id), ('"name":"%s"'):format(id), + '"version":"1.0.0"', '"entry":"main.lua"', + } + for key, value in pairs(extra or {}) do + parts[#parts + 1] = ('"%s":%s'):format(key, value) + end + return "{" .. table.concat(parts, ",") .. "}" +end + +local NOOP = "return function(mod) end\n" + +local function statusById(loader) + local byId = {} + for _, entry in ipairs(loader:status().available) do byId[entry.id] = entry end + return byId +end + +-- ------- semver +check(Semver.satisfies("1.0.0", ">=1.0 <2.0"), "range: 1.0.0 in >=1.0 <2.0") +check(not Semver.satisfies("2.0.0", ">=1.0 <2.0"), "range: 2.0.0 out of >=1.0 <2.0") +check(Semver.satisfies("1.4.7", "^1.4"), "caret accepts a later patch") +check(not Semver.satisfies("2.0.0", "^1.4"), "caret stops at the next major") +check(Semver.satisfies("0.2.9", "^0.2") and not Semver.satisfies("0.3.0", "^0.2"), + "caret pins the leftmost non-zero component") +check(Semver.satisfies("2.1.0", "^1.4 || ^2.0"), "|| offers alternatives") +check(Semver.compare("1.0.0-beta", "1.0.0") == -1, "a pre-release sorts first") +check(Semver.compare("1.10.0", "1.9.0") == 1, "components compare numerically") +local ok, err = Semver.satisfies("banana", ">=1.0") +check(ok == false and err ~= nil, "an unparsable version reports a reason") +local rangeOk, rangeErr = Semver.validRange(">>1.0") +check(rangeOk == false and rangeErr ~= nil, "a malformed range reports a reason") +check(Semver.parse("1").minor == 0 and Semver.parse("1.2").patch == 0, + "absent version components default to 0") + +-- ------- manifest v2 fields +local full = Manifest.validate({ + id = "full", name = "Full", version = "1.0.0", entry = "main.lua", + api = 2, profile = "overhaul", permissions = { "network" }, + dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" }, + options_schema = "options.lua", assets_transforms = "transforms.lua", +}, "mods/full") +check(full.api == 2 and full.profile == "overhaul", "api and profile parse") +check(full.affects_link == true, "overhaul defaults to affecting link play") +check(full.permissionSet.network == true, "permissions normalize to a set") +check(full.dependencySpecs[1].id == "colorlib" + and full.dependencySpecs[1].range == "^1.2", "dependency pins parse as id@range") +check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range == nil, + "a bare conflict entry has no range") +check(full.options_schema == "options.lua" + and full.assets_transforms == "transforms.lua", "declared files are kept") + +local v1 = Manifest.validate({ + id = "v1", name = "V1", version = "1.0.0", entry = "main.lua", +}, "mods/v1") +check(v1.api == 1, "an absent api means 1") +check(v1.profile == "content" and v1.affects_link == false, + "a content profile is the default and does not claim link relevance") +check(#v1.permissions == 0 and next(v1.permissionSet) == nil, + "no permissions declared means none granted") + +check(not pcall(Manifest.validate, { + id = "future", name = "Future", version = "1.0.0", entry = "main.lua", + api = Version.modApi + 1, +}, "mods/future"), "an api newer than the engine fails validation") +check(not pcall(Manifest.validate, { + id = "badprofile", name = "Bad", version = "1.0.0", entry = "main.lua", + api = 2, profile = "nonsense", +}, "mods/badprofile"), "an unknown profile fails for api 2") +local coerced = Manifest.validate({ + id = "oldprofile", name = "Old", version = "1.0.0", entry = "main.lua", + profile = "nonsense", +}, "mods/oldprofile") +check(coerced.profile == "content", "an unknown profile coerces for api 1") +check(logged("[oldprofile]", "unknown profile"), "the coercion is attributed") +check(not pcall(Manifest.validate, { + id = "badperm", name = "Bad", version = "1.0.0", entry = "main.lua", + api = 2, permissions = { "root" }, +}, "mods/badperm"), "an unknown permission fails for api 2") +check(pcall(Manifest.validate, { + id = "oldperm", name = "Old", version = "1.0.0", entry = "main.lua", + permissions = { "root" }, +}, "mods/oldperm"), "an unknown permission only warns for api 1") +check(not pcall(Manifest.validate, { + id = "badrange", name = "Bad", version = "1.0.0", entry = "main.lua", + game_version = ">>1.0", +}, "mods/badrange"), "a malformed game_version fails validation") +check(not pcall(Manifest.validate, { + id = "baddep", name = "Bad", version = "1.0.0", entry = "main.lua", + dependencies = { "other@nonsense" }, +}, "mods/baddep"), "a malformed dependency range fails validation") + +-- ------- game_version against the engine +local versionLoader = Loader.new({ fs = memfs({ + ["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }), + ["mods/future/main.lua"] = "return function(mod) mod.content.items:register('NOPE', {}) end", + ["mods/current/manifest.json"] = manifestJson("current", { game_version = '">=1.0 <2.0"' }), + ["mods/current/main.lua"] = NOOP, +}) }) +local versionData = { items = {} } +check(versionLoader:load(versionData) == false, "a game_version miss fails the load") +local versionStatus = statusById(versionLoader) +check(versionStatus.future.state == "invalid", + "the mod that outranges the engine is refused") +check(versionStatus.future.error:find(Version.engine, 1, true) ~= nil, + "the refusal names the engine version") +check(versionData.items.NOPE == nil, "a refused mod never runs its entry chunk") +check(versionStatus.current.state == "loaded", + "a satisfied game_version range still loads") + +local shelvedLoader = Loader.new({ fs = memfs({ + ["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }), + ["mods/future/main.lua"] = NOOP, + ["options.lua"] = "return { mods = { future = false } }", +}) }) +check(shelvedLoader:load({}) == true, + "a switched-off mod that could not load is not a boot problem") +check(statusById(shelvedLoader).future.state == "disabled", + "a switched-off mod reports as disabled, not as invalid") + +-- ------- conflicts refuse to co-enable +local conflictLoader = Loader.new({ fs = memfs({ + ["mods/noon/manifest.json"] = manifestJson("noon", { conflicts = '["dusk"]' }), + ["mods/noon/main.lua"] = NOOP, + ["mods/dusk/manifest.json"] = manifestJson("dusk", { conflicts = '["noon"]' }), + ["mods/dusk/main.lua"] = NOOP, + ["mods/bystander/manifest.json"] = manifestJson("bystander"), + ["mods/bystander/main.lua"] = NOOP, +}) }) +check(conflictLoader:load({}) == false, "mutual conflicts fail the load") +local conflictStatus = statusById(conflictLoader) +check(conflictStatus.noon.state == "conflict" and conflictStatus.dusk.state == "conflict", + "two mods declaring each other both refuse to co-enable") +check(conflictStatus.noon.error:find("conflicts with dusk", 1, true) ~= nil, + "the conflict message names the other mod") +check(conflictStatus.bystander.state == "loaded", "an unrelated mod still loads") + +local onesidedLoader = Loader.new({ fs = memfs({ + ["mods/picky/manifest.json"] = manifestJson("picky", { conflicts = '["plain@^1.0"]' }), + ["mods/picky/main.lua"] = NOOP, + ["mods/plain/manifest.json"] = manifestJson("plain"), + ["mods/plain/main.lua"] = NOOP, +}) }) +onesidedLoader:load({}) +local onesidedStatus = statusById(onesidedLoader) +check(onesidedStatus.picky.state == "conflict" and onesidedStatus.plain.state == "loaded", + "the declaring mod loses a one-sided conflict") + +-- ------- dependency enabled-ness, version ranges, and missing deps +local depFiles = { + ["mods/lib/manifest.json"] = manifestJson("lib"), + ["mods/lib/main.lua"] = NOOP, + ["mods/user/manifest.json"] = manifestJson("user", { dependencies = '["lib"]' }), + ["mods/user/main.lua"] = "return function(mod) mod.content.items:register('DEP_ITEM', {}) end", + ["options.lua"] = "return { mods = { lib = false } }", +} +local depData = { items = {} } +local depLoader = Loader.new({ fs = memfs(depFiles) }) +check(depLoader:load(depData) == false, "a disabled dependency fails the load") +local depStatus = statusById(depLoader) +check(depStatus.user.state == "blocked_dependency" + and depStatus.user.error:find("dependency lib is disabled", 1, true) ~= nil, + "a mod whose hard dependency is disabled fails with a manager-visible error") +check(depData.items.DEP_ITEM == nil, "the blocked mod never registered anything") + +local rangeLoader = Loader.new({ fs = memfs({ + ["mods/lib/manifest.json"] = manifestJson("lib"), + ["mods/lib/main.lua"] = NOOP, + ["mods/user/manifest.json"] = manifestJson("user", { dependencies = '["lib@^2.0"]' }), + ["mods/user/main.lua"] = NOOP, +}) }) +rangeLoader:load({}) +check(statusById(rangeLoader).user.error:find("needs lib@^2.0, found 1.0.0", 1, true) ~= nil, + "a version-mismatched dependency names the range and the version found") + +local missingLoader = Loader.new({ fs = memfs({ + ["mods/user/manifest.json"] = manifestJson("user", { dependencies = '["ghost"]' }), + ["mods/user/main.lua"] = NOOP, +}) }) +missingLoader:load({}) +check(statusById(missingLoader).user.error:find("missing dependency: ghost", 1, true) ~= nil, + "a missing dependency is named") + +-- a mod whose dependency crashes must not run on top of a rolled-back mod +local transitiveLoader = Loader.new({ fs = memfs({ + ["mods/broken/manifest.json"] = manifestJson("broken"), + ["mods/broken/main.lua"] = "return function(mod) error('entry blew up') end", + ["mods/onbroken/manifest.json"] = manifestJson("onbroken", { dependencies = '["broken"]' }), + ["mods/onbroken/main.lua"] = "return function(mod) mod.content.items:register('LATE', {}) end", +}) }) +local transitiveData = { items = {} } +transitiveLoader:load(transitiveData) +local transitiveStatus = statusById(transitiveLoader) +check(transitiveStatus.broken.state == "failed", "a throwing entry chunk fails its mod") +check(transitiveStatus.onbroken.state == "blocked_dependency" + and transitiveStatus.onbroken.error:find("failed to load", 1, true) ~= nil, + "a dependent of a crashed mod is stopped before it runs") +check(transitiveData.items.LATE == nil, "the dependent registered nothing") + +-- ------- a cycle disables only its own members +local cycleLoader = Loader.new({ fs = memfs({ + ["mods/alpha/manifest.json"] = manifestJson("alpha", { dependencies = '["beta"]' }), + ["mods/alpha/main.lua"] = NOOP, + ["mods/beta/manifest.json"] = manifestJson("beta", { dependencies = '["alpha"]' }), + ["mods/beta/main.lua"] = NOOP, + ["mods/innocent/manifest.json"] = manifestJson("innocent"), + ["mods/innocent/main.lua"] = "return function(mod) mod.content.items:register('FINE', {}) end", +}) }) +local cycleData = { items = {} } +check(cycleLoader:load(cycleData) == false, "a cycle is reported, not raised") +local cycleStatus = statusById(cycleLoader) +check(cycleStatus.alpha.state == "blocked_dependency" + and cycleStatus.beta.state == "blocked_dependency", + "both cycle members are disabled") +check(cycleStatus.alpha.error:find("circular dependency", 1, true) ~= nil, + "the cycle members are told why") +check(cycleStatus.innocent.state == "loaded" and cycleData.items.FINE ~= nil, + "a mod beside the cycle loads normally") + +-- ------- inter-mod exports and find +_G.MOD_FIND_RESULTS = {} +local exportLoader = Loader.new({ fs = memfs({ + ["mods/colorlib/manifest.json"] = manifestJson("colorlib"), + ["mods/colorlib/main.lua"] = [[ +return function(mod) + mod.exports = { tint = function(name) return "tinted:" .. name end } +end +]], + ["mods/radio/manifest.json"] = manifestJson("radio"), + ["mods/radio/main.lua"] = NOOP, + ["mods/daynight/manifest.json"] = manifestJson("daynight", { + dependencies = '["colorlib@^1.0"]', optional_dependencies = '["radio","absent_radio"]', + }), + ["mods/daynight/main.lua"] = [[ +return function(mod) + local results = _G.MOD_FIND_RESULTS + local color = mod.find("colorlib") + results.depVersion = color.version + results.tint = color.exports.tint("dusk") + results.optional = mod.find("radio") ~= nil + results.absent = mod.find("absent_radio") + results.disabled = mod.find("shelved") + results.method = mod:find("colorlib") ~= nil +end +]], + ["mods/shelved/manifest.json"] = manifestJson("shelved"), + ["mods/shelved/main.lua"] = NOOP, + ["options.lua"] = "return { mods = { shelved = false } }", +}) }) +check(exportLoader:load({}) == true, "the export fixture loads clean") +local found = _G.MOD_FIND_RESULTS +check(found.tint == "tinted:dusk", "find returns the other mod's live export table") +check(found.depVersion == "1.0.0", "the handle carries the other mod's version") +check(found.optional == true, "an enabled optional dependency is findable") +check(found.absent == nil, "find returns nil for a mod that is not installed") +check(found.disabled == nil, "find returns nil for a disabled mod") +check(found.method == true, "mod:find is tolerated alongside mod.find") +check(exportLoader.order[1] == "colorlib", + "a hard dependency executes before its dependent") +_G.MOD_FIND_RESULTS = nil + +-- ------- the rest of the v2 mod object +_G.MOD_OBJECT_PROBE = {} +local objectLoader = Loader.new({ fs = memfs({ + ["mods/probe/manifest.json"] = manifestJson("probe", { + api = "2", description = '"probing"', priority = "3", + }), + ["mods/probe/data.txt"] = "hello from the mod dir", + ["mods/probe/main.lua"] = [[ +return function(mod) + local probe = _G.MOD_OBJECT_PROBE + probe.id, probe.version, probe.path = mod.id, mod.version, mod.path + probe.manifestApi = mod.manifest.api + mod.manifest.api = 99 + probe.read = mod:read("data.txt") + probe.assetPath = mod.assets:path("sprites/x.png") + probe.aliasedRegistry = mod.assets.pokemon ~= nil + probe.image = mod.assets:image("sprites/x.png") + probe.imageCached = mod.assets:image("sprites/x.png") == probe.image + + mod.options:define({ { key = "dusk_hour", type = "number", default = 18 } }) + probe.optionDefault = mod.options:get("dusk_hour") + probe.optionStored = mod.options:get("volume") + probe.saveDefault = mod.save:get("clock_hour", 12) + mod.save:set("clock_hour", 6) + probe.saveRoundTrip = mod.save:get("clock_hour", 12) + + mod.commands:register("do_thing", function() return "done" end) + mod.migrations:add("1.0.0", function() end) + + local fired = 0 + mod.events:once("mod.probe.ping", function() fired = fired + 1 end) + mod.events:on("mod.probe.ping", function() probe.stillHeard = true end) + mod.events:emit("mod.probe.ping", {}) + mod.events:emit("mod.probe.ping", {}) + probe.onceCount = fired + probe.forgery = select(2, pcall(function() mod.events:emit("battle.started", {}) end)) +end +]], + ["options.lua"] = "return { modOptions = { probe = { volume = 4 } } }", +}) }) +check(objectLoader:load({ pokemon = {} }) == true, "the mod object fixture loads clean") +local probe = _G.MOD_OBJECT_PROBE +check(probe.id == "probe" and probe.version == "1.0.0" and probe.path == "mods/probe", + "identity fields are present") +check(probe.manifestApi == 2 and objectLoader.mods.probe.manifest.api == 2, + "mod.manifest is a copy: writing to it cannot reach the loader") +check(probe.read == "hello from the mod dir", "mod:read still reads the mod dir") +check(probe.assetPath == "mods/probe/sprites/x.png", "assets:path builds a virtual path") +check(probe.aliasedRegistry == true, "assets keeps the v1 alias to the registries") +check(probe.image ~= nil and probe.imageCached == true, + "assets:image loads from the mod dir and caches per path") +check(probe.optionDefault == 18, "options:get falls back to the declared default") +check(probe.optionStored == 4, "options:get prefers the stored value") +check(objectLoader.optionSchemas.probe[1].key == "dusk_hour", + "the options schema is recorded for the manager") +check(probe.saveDefault == 12 and probe.saveRoundTrip == 6, + "save:get honours the default and reads back what set wrote") +-- the sugar writes straight into the commands registry now that M4 has +-- declared it, so the verb is owned there rather than in the holding table +check(objectLoader.content.commands:get("do_thing") ~= nil + and objectLoader.content.commands.owners.do_thing == "probe", + "commands:register records the verb against its mod") +check(objectLoader.migrations.probe[1].since == "1.0.0", "migrations are recorded") +check(probe.onceCount == 1 and probe.stillHeard == true, + "events:once fires once and does not skip the listener behind it") +check(tostring(probe.forgery):find("may only emit", 1, true) ~= nil, + "a mod cannot emit outside its own event namespace") +_G.MOD_OBJECT_PROBE = nil + +-- a failing entry chunk takes its exports, commands and migrations with it +local residueLoader = Loader.new({ fs = memfs({ + ["mods/messy/manifest.json"] = manifestJson("messy"), + ["mods/messy/main.lua"] = [[ +return function(mod) + mod.exports = { hello = true } + mod.commands:register("messy_verb", function() end) + mod.migrations:add("1.0.0", function() end) + error("messy failed late") +end +]], +}) }) +residueLoader:load({}) +check(residueLoader.exports.messy == nil, "a failed mod publishes no exports") +check(residueLoader.content.commands:get("messy_verb") == nil, + "a failed mod leaves no command") +check(residueLoader.migrations.messy == nil, "a failed mod leaves no migration") + +-- ------- dev-mode permissions tripwire +local devFiles = { + ["mods/nosy/manifest.json"] = manifestJson("nosy"), + ["mods/nosy/main.lua"] = [[ +return function(mod) + pcall(require, "src.battle.BattleState") +end +]], + ["mods/declared/manifest.json"] = manifestJson("declared", { + api = "2", permissions = '["engine_internals"]', + }), + ["mods/declared/main.lua"] = [[ +return function(mod) + pcall(require, "src.battle.BattleState") +end +]], +} +local devLoader = Loader.new({ fs = memfs(devFiles), dev = true }) +local historyMark = #Logger.history +check(devLoader:load({}) == true, "the dev fixture loads clean") +local sawUndeclared, sawDeclared = false, false +for index = historyMark + 1, #Logger.history do + local line = Logger.history[index] + if line:find("[nosy]", 1, true) + and line:find("undeclared engine_internals require: src.battle.BattleState", 1, true) then + sawUndeclared = true + end + if line:find("[declared]", 1, true) and line:find("undeclared", 1, true) then + sawDeclared = true + end +end +check(sawUndeclared, + "an undeclared private engine require is attributed to the mod that made it") +check(not sawDeclared, "a mod that declared engine_internals is not warned about") +check(Runtime.currentMod == nil, "no mod frame is left open after the load") + +-- engine requires outside a mod frame stay silent +local engineMark = #Logger.history +require("src.core.Data") +for index = engineMark + 1, #Logger.history do + check(not Logger.history[index]:find("undeclared", 1, true), + "an engine require outside a mod frame is not warned about") +end + +-- ------- parity: with no mod nothing new appears and nothing is refused +local pristine = { pokemon = { A = { hp = 1 } }, items = {} } +local emptyLoader = Loader.new({ fs = memfs({}) }) +check(emptyLoader:load(pristine) == true, "an empty mods dir still loads clean") +check(#emptyLoader:status().errors == 0, "no mods means no diagnostics") +check(#emptyLoader.order == 0, "no mods means an empty load order") +check(pristine.pokemon.A.hp == 1 and next(pristine.items) == nil, + "no-mod load leaves data untouched") + +Runtime.install(savedEvents, savedHooks) + +S.finish() diff --git a/tests/mod_registry_tests.lua b/tests/mod_registry_tests.lua new file mode 100644 index 00000000..343dcadd --- /dev/null +++ b/tests/mod_registry_tests.lua @@ -0,0 +1,577 @@ +-- Registry & merge v2 over the headless loader: patch/remove/each +-- semantics, the tombstone delete pass, schema validation (api 2 errors, +-- api 1 warns), namespace creation for base-less registries, v1 alias +-- deprecation, and the structural-identity parity gate with no mod. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Loader = require("src.mods.Loader") +local Registry = require("src.mods.Registry") +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") +local Logger = require("src.core.Logger") + +local S = require("tests.harness").suite("registry merge v2") +local check = S.check + +local function loggedCount(fragmentA, fragmentB) + local count = 0 + for _, line in ipairs(Logger.history) do + if line:find(fragmentA, 1, true) and line:find(fragmentB, 1, true) then + count = count + 1 + end + end + return count +end + +local function logged(fragmentA, fragmentB) + return loggedCount(fragmentA, fragmentB) > 0 +end + +-- the fs surface the loader needs, backed by a flat path->content table +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function manifestJson(id, opts) + opts = opts or {} + return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua","dependencies":%s%s}]]) + :format(id, id, opts.deps or "[]", opts.api and (',"api":' .. opts.api) or "") +end + +-- internally consistent so the cross-reference pass finds every id +local function fixtureData() + return { + pokemon = { + PIKACHU = { id = "PIKACHU", name = "PIKACHU", dex = 25, + types = { "ELECTRIC" }, + baseStats = { hp = 35, attack = 55, defense = 30, speed = 90, special = 50 }, + catchRate = 190, baseExp = 82, + level1Moves = { "THUNDERSHOCK", "GROWL" }, + growthRate = "MEDIUM_FAST", + learnset = { { level = 9, move = "THUNDER_WAVE" } }, + evolutions = { { method = "ITEM", item = "THUNDER_STONE", species = "RAICHU" } }, + spriteFront = "pikachu_front.png", spriteBack = "pikachu_back.png", + frontSize = 5 }, + RAICHU = { id = "RAICHU", name = "RAICHU", dex = 26, + types = { "ELECTRIC" }, + baseStats = { hp = 60, attack = 90, defense = 55, speed = 110, special = 90 }, + catchRate = 75, baseExp = 122, + level1Moves = { "THUNDERSHOCK" }, + growthRate = "MEDIUM_FAST", + learnset = {}, evolutions = {}, + spriteFront = "raichu_front.png", spriteBack = "raichu_back.png", + frontSize = 6 }, + }, + moves = { + THUNDERSHOCK = { id = "THUNDERSHOCK", name = "THUNDERSHOCK", type = "ELECTRIC", + power = 40, accuracy = 100, pp = 30, effect = "PARALYZE_SIDE_EFFECT1" }, + GROWL = { id = "GROWL", name = "GROWL", type = "NORMAL", + power = 0, accuracy = 100, pp = 40, effect = "ATTACK_DOWN1_EFFECT" }, + THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", type = "ELECTRIC", + power = 0, accuracy = 100, pp = 20, effect = "PARALYZE_EFFECT" }, + }, + items = { + POTION = { id = "POTION", name = "POTION", price = 300 }, + THUNDER_STONE = { id = "THUNDER_STONE", name = "THUNDER STONE", price = 2100 }, + }, + } +end + +local function deepEqual(a, b, path) + path = path or "root" + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false, path end + for k, v in pairs(a) do + local ok, where = deepEqual(v, b[k], path .. "." .. tostring(k)) + if not ok then return false, where end + end + for k in pairs(b) do + if a[k] == nil then return false, path .. "." .. tostring(k) end + end + return true +end + +-- ------- parity: with no mod, Data is structurally identical after load +local pristine = fixtureData() +local snapshot = Merge.deepCopy(pristine) +local emptyLoader = Loader.new({ fs = memfs({}) }) +check(emptyLoader:load(pristine) == true, "empty load succeeds") +-- the engine seeds its own registries on every boot, so the namespaces +-- those write are expected; every pre-existing table must be untouched +local engineRoots = require("src.mods.Builtins").namespaceRoots() +local carried = {} +for key, value in pairs(pristine) do + if snapshot[key] ~= nil then + carried[key] = value + else + check(engineRoots[key], "no-mod merge creates only engine namespaces (saw " + .. key .. ")") + end +end +local same, where = deepEqual(carried, snapshot) +check(same, "no-mod merge keeps Data structurally identical (differs at " .. + tostring(where) .. ")") + +-- ------- patch: field-precise, stacking in load order, DELETE sentinel +local patchFiles = { + ["mods/tweak_a/manifest.json"] = manifestJson("tweak_a", { api = 2 }), + ["mods/tweak_a/main.lua"] = [[ +return function(mod) + mod.content.pokemon:patch("PIKACHU", { baseStats = { attack = 120 } }) +end +]], + ["mods/tweak_b/manifest.json"] = manifestJson("tweak_b", { api = 2, deps = '["tweak_a"]' }), + ["mods/tweak_b/main.lua"] = [[ +return function(mod) + mod.content.pokemon:patch("PIKACHU", { + catchRate = 45, + level1Moves = { "GROWL" }, + evolutions = mod.DELETE, + }) +end +]], +} +local patchData = fixtureData() +local patchLoader = Loader.new({ fs = memfs(patchFiles) }) +check(patchLoader:load(patchData) == true, "patch mods load cleanly") +local pika = patchData.pokemon.PIKACHU +check(pika.baseStats.attack == 120, "patched field applied") +check(pika.baseStats.hp == 35 and pika.baseStats.speed == 90, + "sibling stats intact after patch") +check(pika.catchRate == 45, "later mod's patch stacks on the earlier one") +check(pika.name == "PIKACHU" and pika.spriteFront == "pikachu_front.png", + "unrelated fields intact after patch") +check(pika.learnset[1].move == "THUNDER_WAVE", "nested list intact after patch") +check(#pika.level1Moves == 1 and pika.level1Moves[1] == "GROWL", + "arrays replace wholesale in record patches") +check(pika.evolutions == nil, "DELETE sentinel unsets a field") +check(patchData.pokemon.RAICHU.baseStats.attack == 90, + "other records untouched by patch") +check(patchLoader.content.pokemon:get("PIKACHU").baseStats.attack == 120, + "registry get returns the folded record") + +-- each() unions base and op ids +local ids = {} +for id in patchLoader.content.pokemon:each() do ids[id] = true end +check(ids.PIKACHU and ids.RAICHU, "each() yields base and patched ids") + +-- ------- patch typo: near-match unknown field is a named load error +local typoFiles = { + ["mods/typo/manifest.json"] = manifestJson("typo", { api = 2 }), + ["mods/typo/main.lua"] = [[ +return function(mod) + mod.content.pokemon:patch("PIKACHU", { base_stats = { attack = 10 } }) +end +]], +} +local typoData = fixtureData() +local typoLoader = Loader.new({ fs = memfs(typoFiles) }) +check(typoLoader:load(typoData) == false, "typo'd patch fails the mod") +local typoError = table.concat(typoLoader.errors, "\n") +check(typoError:find('did you mean "baseStats"', 1, true) ~= nil, + "typo error suggests the schema field") +check(typoData.pokemon.PIKACHU.baseStats.attack == 55, + "failed patch leaves the record untouched") + +-- ------- remove: tombstones survive the merge as deletes; re-register +-- after remove resurrects the id +local removeFiles = { + ["mods/pruner/manifest.json"] = manifestJson("pruner", { api = 2 }), + ["mods/pruner/main.lua"] = [[ +return function(mod) + mod.content.items:remove("POTION") + mod.content.items:remove("THUNDER_STONE") + mod.content.items:register("THUNDER_STONE", + { id = "THUNDER_STONE", name = "THUNDER STONE", price = 9999 }) + mod.content.items:register("NEW_ITEM", { id = "NEW_ITEM", name = "NEW ITEM", price = 10 }) +end +]], +} +local removeData = fixtureData() +removeData.pokemon.PIKACHU.evolutions = {} -- fixture no longer references the stone +local removeLoader = Loader.new({ fs = memfs(removeFiles) }) +check(removeLoader:load(removeData) == true, "remove mod loads cleanly") +check(removeData.items.POTION == nil, "tombstone deletes the key from Data") +check(removeLoader.content.items:get("POTION") == nil, + "registry get treats a tombstoned id as absent") +check(removeLoader.content.items:has("POTION") == false, + "has() is false for a tombstoned id") +check(removeData.items.THUNDER_STONE ~= nil + and removeData.items.THUNDER_STONE.price == 9999, + "register after remove resurrects the id") +local itemIds = {} +for id in removeLoader.content.items:each() do itemIds[#itemIds + 1] = id end +table.sort(itemIds) +check(#itemIds == 2 and itemIds[1] == "NEW_ITEM" and itemIds[2] == "THUNDER_STONE", + "each() skips tombstones and includes op-only ids") +for id in pairs(removeData.items) do + check(id ~= "POTION", "no stale tombstoned key while iterating Data") +end + +-- content is frozen after the merge; reads still work +check(not pcall(function() + removeLoader.content.items:patch("NEW_ITEM", { price = 1 }) +end), "patch refused after freeze") +check(not pcall(function() + removeLoader.content.items:remove("NEW_ITEM") +end), "remove refused after freeze") +check(removeLoader.content.items:get("NEW_ITEM").price == 10, + "frozen registry still readable") + +-- ------- schema fail (api 2): named load error, no residue +local badFiles = { + ["mods/strict_pack/manifest.json"] = manifestJson("strict_pack", { api = 2 }), + ["mods/strict_pack/main.lua"] = [[ +return function(mod) + mod.content.pokemon:register("BADMON", { name = "BADMON" }) +end +]], +} +local badData = fixtureData() +local badLoader = Loader.new({ fs = memfs(badFiles) }) +check(badLoader:load(badData) == false, "schema violation fails an api 2 mod") +local badError = table.concat(badLoader.errors, "\n") +check(badError:find("strict_pack", 1, true) ~= nil, + "schema error names the mod") +check(badError:find("pokemon.BADMON", 1, true) ~= nil + and badError:find("missing required field", 1, true) ~= nil, + "schema error names the registry, id and problem") +check(badData.pokemon.BADMON == nil and badLoader.content.pokemon:get("BADMON") == nil, + "rejected registration leaves zero residue") + +-- ------- schema pass (api 2): a valid new record registers and merges +local goodFiles = { + ["mods/adder/manifest.json"] = manifestJson("adder", { api = 2 }), + ["mods/adder/main.lua"] = [[ +return function(mod) + mod.content.pokemon:register("NEWMON", { + id = "NEWMON", name = "NEWMON", dex = 152, + types = { "NORMAL" }, + baseStats = { hp = 50, attack = 50, defense = 50, speed = 50, special = 50 }, + catchRate = 45, baseExp = 100, + level1Moves = { "GROWL" }, + growthRate = "MEDIUM_FAST", + learnset = { { level = 10, move = "THUNDERSHOCK" } }, + evolutions = {}, + spriteFront = "newmon_front.png", spriteBack = "newmon_back.png", + frontSize = 5, + }) +end +]], +} +local goodData = fixtureData() +local goodLoader = Loader.new({ fs = memfs(goodFiles) }) +check(goodLoader:load(goodData) == true, "valid api 2 registration loads") +check(goodData.pokemon.NEWMON ~= nil and goodData.pokemon.NEWMON.dex == 152, + "valid registration merges into Data") + +-- ------- api 1 compat: the same violation downgrades to a warning +local legacyFiles = { + ["mods/legacy_pack/manifest.json"] = manifestJson("legacy_pack"), + ["mods/legacy_pack/main.lua"] = [[ +return function(mod) + mod.content.pokemon:register("BADMON", { name = "BADMON" }) +end +]], +} +local legacyData = fixtureData() +local legacyLoader = Loader.new({ fs = memfs(legacyFiles) }) +check(legacyLoader:load(legacyData) == true, + "api 1 mod loads despite the schema violation") +check(legacyData.pokemon.BADMON ~= nil and legacyData.pokemon.BADMON.name == "BADMON", + "api 1 registration still merges") +check(logged("[legacy_pack]", "missing required field"), + "api 1 violation logged as a mod-attributed warning") + +-- ------- namespace creation: base-less registries merge into created +-- namespaces and data.audio.songs appears when absent +local nsFiles = { + ["mods/screens_pack/manifest.json"] = manifestJson("screens_pack", { api = 2 }), + ["mods/screens_pack/main.lua"] = [[ +return function(mod) + mod.content.screens:register("QuestLog", { new = function() return {} end }) + mod.content.map_scripts:register("PALLET_TOWN", { + talk = { TEXT_TEST = { { "show_text", "HELLO" } } }, + }) + mod.content.music:register("MOD_SONG", { file = "song.ogg" }) +end +]], + ["mods/scripts_pack/manifest.json"] = manifestJson("scripts_pack", + { api = 2, deps = '["screens_pack"]' }), + ["mods/scripts_pack/main.lua"] = [[ +return function(mod) + mod.content.map_scripts:register("PALLET_TOWN", { + onEnter = function() end, + priority = 10, + }) +end +]], +} +local nsData = fixtureData() +local nsLoader = Loader.new({ fs = memfs(nsFiles) }) +check(nsLoader:load(nsData) == true, "namespace mods load cleanly") +check(type(nsData.screens) == "table" and type(nsData.screens.QuestLog) == "table" + and type(nsData.screens.QuestLog.new) == "function", + "screens registration merges into a created namespace") +check(nsLoader.content.screens:get("QuestLog") ~= nil, + "screens value retrievable via content get") +local chain = nsData.map_scripts and nsData.map_scripts.PALLET_TOWN +check(type(chain) == "table" and #chain == 2, + "map_scripts compose chain carries both registrations") +check(type(chain[1].onEnter) == "function", + "higher-priority chain entry sorts first") +check(chain[2].talk and chain[2].talk.TEXT_TEST ~= nil, + "chain keeps the talk registration") +local got = nsLoader.content.map_scripts:get("PALLET_TOWN") +check(got ~= nil and type(got.onEnter) == "function", + "content get returns the top-priority chain entry") +check(nsData.audio and nsData.audio.songs + and nsData.audio.songs.MOD_SONG + and nsData.audio.songs.MOD_SONG.file == "song.ogg", + "data.audio.songs created when the base module is absent") + +-- the interim consumer: data/scripts/init.lua layers merged chains over +-- its built-in table and is untouched with no chains present +love = love or require("tests.love_stub") +local CoreData = require("src.core.Data") +local mapScripts = require("data.scripts.init") +local builtIn = mapScripts.get("PALLET_TOWN") +check(builtIn ~= nil and builtIn.talk ~= nil, "built-in pallet town script present") +CoreData.map_scripts = { PALLET_TOWN = { { talk = { TEXT_TEST = { { "text", "HI" } } } } } } +local layeredTalk = mapScripts.talkScript("PALLET_TOWN", "TEXT_TEST") +check(type(layeredTalk) == "table", "mod talk script layered over the built-ins") +check(mapScripts.talkScript("PALLET_TOWN", "TEXT_PALLETTOWN_OAK") ~= nil, + "built-in talk scripts survive the layering") +local layeredMap = mapScripts.get("PALLET_TOWN") +for key, value in pairs(builtIn) do + if key ~= "talk" then + check(layeredMap[key] == value, "built-in hook preserved: " .. tostring(key)) + end +end +CoreData.map_scripts = nil +check(mapScripts.get("PALLET_TOWN") == builtIn, + "no chains resolves to the built-in table untouched") + +-- ------- v1 aliases: scripts/ui keep working with a one-shot deprecation +-- warning per mod; the audio whole-table registry warns too +local aliasFiles = { + ["mods/v1_pack/manifest.json"] = manifestJson("v1_pack"), + ["mods/v1_pack/main.lua"] = [[ +return function(mod) + mod.content.scripts:register("VIRIDIAN_CITY", { + talk = { TEXT_TEST = { { "text", "HI" } } }, + }) + mod.content.scripts:register("PALLET_TOWN", { + talk = { TEXT_TEST = { { "text", "YO" } } }, + }) + mod.content.ui:register("LegacyScreen", function() return {} end) + mod.content.audio:override("battle", { theme = "X" }) +end +]], +} +local aliasData = fixtureData() +local aliasLoader = Loader.new({ fs = memfs(aliasFiles) }) +check(aliasLoader:load(aliasData) == true, "v1 alias mod loads cleanly") +check(aliasData.map_scripts and aliasData.map_scripts.VIRIDIAN_CITY ~= nil, + "v1 scripts registration lands in map_scripts") +check(type(aliasData.screens.LegacyScreen) == "function", + "v1 ui registration lands in screens") +check(aliasData.audio and aliasData.audio.battle + and aliasData.audio.battle.theme == "X", + "v1 audio whole-key override still works") +check(loggedCount("[v1_pack]", "the scripts registry is deprecated") == 1, + "scripts deprecation warned exactly once per mod") +check(logged("[v1_pack]", "use map_scripts"), "scripts warning names the successor") +check(logged("[v1_pack]", "the ui registry is deprecated"), + "ui deprecation warned") +check(logged("[v1_pack]", "the audio registry is deprecated"), + "audio deprecation warned") + +-- ------- cross-reference pass: a dangling f.id is caught post-merge +local danglingFiles = { + ["mods/dangler/manifest.json"] = manifestJson("dangler", { api = 2 }), + ["mods/dangler/main.lua"] = [[ +return function(mod) + mod.content.pokemon:patch("PIKACHU", { level1Moves = { "MISSING_MOVE" } }) +end +]], +} +local danglingData = fixtureData() +local danglingLoader = Loader.new({ fs = memfs(danglingFiles) }) +check(danglingLoader:load(danglingData) == false, + "dangling reference fails an api 2 mod") +check(table.concat(danglingLoader.errors, "\n") + :find("MISSING_MOVE", 1, true) ~= nil, + "cross-reference error names the missing id") + +-- ------- cross-reference pass: removing an id still referenced by a +-- vanilla record no mod touched is caught and pinned on the remover +local removedRefFiles = { + ["mods/species_pruner/manifest.json"] = manifestJson("species_pruner", { api = 2 }), + ["mods/species_pruner/main.lua"] = [[ +return function(mod) + mod.content.pokemon:remove("PIKACHU") +end +]], +} +local removedRefData = fixtureData() +removedRefData.trainers = { + OPP_TEST = { id = "OPP_TEST", name = "TEST", + parties = { { { level = 5, species = "PIKACHU" } } } }, +} +local removedRefLoader = Loader.new({ fs = memfs(removedRefFiles) }) +check(removedRefLoader:load(removedRefData) == false, + "removing a still-referenced species fails the removing mod") +local removedRefError = table.concat(removedRefLoader.errors, "\n") +check(removedRefError:find("species_pruner", 1, true) ~= nil, + "removal cross-ref error names the removing mod") +check(removedRefError:find("OPP_TEST", 1, true) ~= nil + and removedRefError:find("PIKACHU", 1, true) ~= nil, + "removal cross-ref error names the referencing record and the removed id") + +-- ------- every vanilla record must satisfy its schema, so the shipped +-- example's copy-the-base-record override idiom always validates cleanly +local vanillaSets = { + { "pokemon", require("data.generated.pokemon") }, + { "moves", require("data.generated.moves") }, + { "items", require("data.generated.items") }, + { "maps", require("data.generated.maps") }, + { "tilesets", require("data.generated.tilesets") }, + { "encounters", require("data.generated.encounters") }, + { "trainers", require("data.generated.trainers") }, + { "sprites", require("data.generated.sprites") }, + { "text", require("data.generated.text") }, + { "music", require("data.generated.audio").songs }, +} +for _, pair in ipairs(vanillaSets) do + local name, records = pair[1], pair[2] + local spec = Schemas.REGISTRIES[name] + for id, record in pairs(records) do + local ok, err = Schemas.check(spec, name, id, record, "register") + check(ok, "vanilla record validates: " .. tostring(err)) + end +end + +-- ------- value-schema records keep the extensible top level; the typo +-- guard and nested strictness survive +check(Schemas.check(Schemas.REGISTRIES.map_scripts, "map_scripts", "PALLET_TOWN", + { onEnter = function() end, questFlag = "SOME_QUEST" }, "register") == true, + "unknown top-level field allowed on a value-schema record") +check(Schemas.check(Schemas.REGISTRIES.screens, "screens", "QuestLog", + { new = function() end, sourceMod = "quest_pack" }, "register") == true, + "union rec alternative keeps the top level extensible") +check(Schemas.check(Schemas.REGISTRIES.music, "music", "MOD_SONG", + { file = "song.ogg", composer = "someone" }, "register") == true, + "music union rec keeps the top level extensible") +local typoOk, typoErr = Schemas.check(Schemas.REGISTRIES.map_scripts, + "map_scripts", "PALLET_TOWN", { on_enter = function() end }, "register") +check(typoOk == nil and typoErr:find('did you mean "onEnter"', 1, true) ~= nil, + "near-match typo on a value-schema record still rejected") +local nestedOk, nestedErr = Schemas.check(Schemas.REGISTRIES.map_scripts, + "map_scripts", "PALLET_TOWN", + { talk = { TEXT_TEST = 5 } }, "register") +check(nestedOk == nil and nestedErr ~= nil, + "nested value inside a known field stays strictly typed") + +-- ------- standalone registry fold semantics (no loader) +local reg = Registry.new("pokemon", Schemas.REGISTRIES.pokemon) +local base = { A = { name = "A", nested = { x = 1, y = 2 } } } +reg.base = function() return base end +reg:patch("A", { nested = { x = 9 } }, "m1") +check(reg:get("A").nested.x == 9 and reg:get("A").nested.y == 2, + "standalone patch folds over the base record") +check(base.A.nested.x == 1, "fold never mutates the base record") +reg:remove("A", "m1") +check(reg:get("A") == nil, "standalone remove tombstones") +reg:register("A", { name = "A2" }, "m2") +check(reg:get("A").name == "A2", "register after remove resurrects") +reg:rollback("m2") +check(reg:get("A") == nil, "rollback drops an owner's ops") +reg:rollback("m1") +check(reg:get("A").nested.x == 1, "full rollback restores the base view") + +-- a payload that IS the sentinel folds as a delete, never a value +reg:patch("A", Registry.DELETE, "m3") +check(reg:get("A") == nil, "whole-value DELETE patch tombstones the id") +reg:rollback("m3") +reg:override("A", Registry.DELETE, "m3") +check(reg:get("A") == nil, "whole-value DELETE override tombstones the id") +reg:rollback("m3") +check(reg:get("A").nested.x == 1, "rollback restores after sentinel ops") + +-- ------- deep: lists accumulate so stacked mods never erase each other +local deepReg = Registry.new("text_pointers", Schemas.REGISTRIES.text_pointers) +local deepBase = { Cerulean = { TEXT_MART = { mart = { "POKE_BALL" } } } } +deepReg.base = function() return deepBase end +deepReg:patch("Cerulean", { TEXT_MART = { mart = { "TM_A" } } }, "modA") +deepReg:patch("Cerulean", { TEXT_MART = { mart = { "TM_B" } } }, "modB") +local mart = deepReg:get("Cerulean").TEXT_MART.mart +check(#mart == 3 and mart[1] == "POKE_BALL" and mart[2] == "TM_A" + and mart[3] == "TM_B", + "two patches of one deep list both survive, in load order") +check(#deepBase.Cerulean.TEXT_MART.mart == 1, "appending never mutates the base") +deepReg:override("Cerulean", { TEXT_MART = { mart = { "TM_C" } } }, "modC") +local replaced = deepReg:get("Cerulean").TEXT_MART.mart +check(#replaced == 1 and replaced[1] == "TM_C", + "override still replaces the list wholesale") +deepReg:rollback("modC") +deepReg:rollback("modA") +local afterRollback = deepReg:get("Cerulean").TEXT_MART.mart +check(#afterRollback == 2 and afterRollback[2] == "TM_B", + "rolling one contributor back leaves the other's rows") + +-- record registries keep the replace rule: element-wise merging of a +-- learnset is ambiguous, so a whole list stands in for the old one +local recordReg = Registry.new("pokemon", Schemas.REGISTRIES.pokemon) +recordReg.base = function() return { A = { level1Moves = { "TACKLE" } } } end +recordReg:patch("A", { level1Moves = { "GROWL" } }, "m1") +check(#recordReg:get("A").level1Moves == 1 + and recordReg:get("A").level1Moves[1] == "GROWL", + "a record-registry list still replaces wholesale") + +-- ------- compose: get/items/each surface the same head chain() sorts first +local composeReg = Registry.new("map_scripts", Schemas.REGISTRIES.map_scripts) +composeReg:register("MAP", { onEnter = function() return "low" end }, "modLow") +composeReg:register("MAP", + { onEnter = function() return "high" end, priority = 100 }, "modHigh") +local composeChain = composeReg:chain("MAP") +check(#composeChain == 2 and composeChain[1].onEnter() == "high", + "chain sorts the higher-priority entry first") +check(composeReg:get("MAP").onEnter() == "high", + "compose get returns the chain head") +check(composeReg:items().MAP.onEnter() == "high", + "compose items() folds to the chain head") +for _, value in composeReg:each() do + check(value.onEnter() == "high", "compose each() yields the chain head") +end + +S.finish() diff --git a/tests/mod_runtime_tests.lua b/tests/mod_runtime_tests.lua index e982cdec..e66db768 100644 --- a/tests/mod_runtime_tests.lua +++ b/tests/mod_runtime_tests.lua @@ -4,9 +4,20 @@ local Registry = require("src.mods.Registry") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local Manifest = require("src.mods.Manifest") +local Logger = require("src.core.Logger") +local Version = require("src.core.Version") +local Runtime = require("src.mods.Runtime") -local function check(value, message) - assert(value, message) +local S = require("tests.harness").suite("native mod runtime") +local check = S.check + +local function logged(fragmentA, fragmentB) + for _, line in ipairs(Logger.history) do + if line:find(fragmentA, 1, true) and line:find(fragmentB, 1, true) then + return true + end + end + return false end local registry = Registry.new("pokemon") @@ -14,6 +25,14 @@ registry:register("A", { value = 1 }, "test") registry:override("A", { value = 2 }, "test") check(registry:get("A").value == 2, "registry override") +-- frozen after the boot merge: writes error, reads keep working +registry:freeze() +check(not pcall(function() registry:register("B", { value = 3 }, "test") end), + "frozen registry rejects registration") +check(not pcall(function() registry:override("A", { value = 9 }, "test") end), + "frozen registry rejects override") +check(registry:get("A").value == 2, "frozen registry still readable") + local events = Events.new() local calls = {} events:on("test", function() calls[#calls + 1] = "low" end, 0) @@ -21,6 +40,26 @@ events:on("test", function() calls[#calls + 1] = "high" end, 10) events:emit("test") check(calls[1] == "high" and calls[2] == "low", "event priority") +-- seal is a deprecated no-op; subscription stays legal afterwards +events:seal() +local late = 0 +events:on("late", function() late = late + 1 end) +events:emit("late") +check(late == 1, "subscription legal after seal") + +-- a throwing listener is skipped and attributed; later listeners still run +local reached = 0 +events:on("boom", function() error("listener exploded") end, 10, "bad_mod") +events:on("boom", function() reached = reached + 1 end, 0, "good_mod") +check(pcall(function() events:emit("boom") end), "emit survives a throwing listener") +check(reached == 1, "later listeners run after a failure") +check(logged("[bad_mod]", "boom"), "listener failure logged with mod id") + +-- rollback support: removeOwner drops every subscription a mod made +events:removeOwner("good_mod") +events:emit("boom") +check(reached == 1, "removeOwner drops the listener") + local hooks = Hooks.new() hooks:wrap("double", function(next, value) return next(value) * 2 @@ -31,10 +70,139 @@ end, 10) check(hooks:call("double", function(value) return value end, 3) == 8, "hook chain ordering and next") +-- a throwing link is skipped and the chain continues with the current args +local guarded = Hooks.new() +guarded:wrap("calc", function(next, value) return next(value + 1) end, 10, "outer") +guarded:wrap("calc", function() error("link exploded") end, 5, "broken") +guarded:wrap("calc", function(next, value) return next(value * 2) end, 0, "inner") +check(guarded:call("calc", function(value) return value end, 3) == 8, + "failing hook link skipped, chain continues") +check(logged("[broken]", "calc"), "hook failure logged with mod id") + +-- an error below the chain is the engine's, not a mod's: it propagates and +-- the vanilla function is never re-run +local vanillaRuns = 0 +local okCall, err = pcall(function() + return guarded:call("calc", function() + vanillaRuns = vanillaRuns + 1 + error("vanilla failed") + end, 1) +end) +check(not okCall and tostring(err):find("vanilla failed", 1, true) ~= nil, + "vanilla error propagates through the chain") +check(vanillaRuns == 1, "vanilla runs exactly once when it fails") + +-- a link that throws AFTER its next() returned must not re-walk the chain: +-- vanilla has side effects, so the downstream result is kept and the link's +-- post-processing is discarded +local lateFail = Hooks.new() +lateFail:wrap("calc", function(next, value) return next(value + 1) end, 10, "outer") +lateFail:wrap("calc", function(next, value) + local r = next(value) + error("post-next bug") +end, 5, "late") +local lateRuns = 0 +local lateResult = lateFail:call("calc", function(value) + lateRuns = lateRuns + 1 + return value * 2 +end, 3) +check(lateRuns == 1, "vanilla runs exactly once when a link fails after next") +check(lateResult == 8, "post-next failure keeps the downstream result") +check(logged("[late]", "downstream result kept"), "post-next failure logged with mod id") + +guarded:removeOwner("broken") +guarded:removeOwner("inner") +check(guarded:call("calc", function(value) return value end, 3) == 4, + "removeOwner unwinds hook links") + +check(pcall(function() hooks:seal() end), "hook seal is a deprecated no-op") + +-- a listener retiring mid-dispatch must not shift the entries emit has yet +-- to reach; emit walks a copy so every subscriber still fires +local reentrant = Events.new() +local fired, drop = {}, nil +drop = reentrant:on("tick", function() fired[#fired + 1] = "a" drop() end, 10, "first") +reentrant:on("tick", function() fired[#fired + 1] = "b" end, 5, "second") +reentrant:on("tick", function() fired[#fired + 1] = "c" end, 1, "third") +reentrant:emit("tick") +check(table.concat(fired, ",") == "a,b,c", + "unsubscribing mid-emit does not skip later listeners") + +local onceCount = 0 +reentrant:once("solo", function() onceCount = onceCount + 1 end, 0, "first") +reentrant:emit("solo") +reentrant:emit("solo") +check(onceCount == 1, "once fires exactly once across repeated emits") + local manifest = Manifest.validate({ id = "test_mod", name = "Test Mod", version = "1.0.0", entry = "main.lua" }, "mods/test_mod") check(manifest.id == "test_mod" and manifest.path == "mods/test_mod", "manifest validation") -print("ok native mod runtime") +check(type(Version.engine) == "string" + and Version.engine:match("^%d+%.%d+%.%d+$") ~= nil, + "engine version is a semver triple") +check(Version.modApi == 2, "mod api version is 2") +check(Version.title("X") == "X v" .. Version.engine, + "window title carries the engine version") + +-- null-object runtime: emit/call are safe with no loader installed +local nullEvents, nullHooks = Runtime.events, Runtime.hooks +Runtime.emit("nobody.listens", { probe = true }) +local a, b = Runtime.call("unwired.hook", function(x, y) return y, x end, 1, 2) +check(a == 2 and b == 1, "null-object hook call passes through to vanilla") +check(not Runtime.wants("anything") and not Runtime.wantsHook("anything"), + "null-object buses report no subscribers") + +local liveEvents, liveHooks = Events.new(), Hooks.new() +Runtime.install(liveEvents, liveHooks) +local got +liveEvents:on("runtime.probe", function(payload) got = payload end, 0, "test") +Runtime.emit("runtime.probe", { value = 7 }) +check(got ~= nil and got.value == 7, "installed bus receives Runtime.emit") +check(Runtime.wants("runtime.probe"), "wants sees the live subscription") + +-- the last unsubscribe clears wants, so a hot emit site stops building +-- payloads once nobody listens; a survivor keeps the key alive +local unsubHot = liveEvents:on("hot.emit", function() end, 0, "test") +check(Runtime.wants("hot.emit"), "wants sees the hot subscription") +unsubHot() +check(not Runtime.wants("hot.emit"), "wants clears after the last unsubscribe") +local dropFirst = liveEvents:on("hot.pair", function() end, 0, "test") +local dropSecond = liveEvents:on("hot.pair", function() end, 0, "test") +dropFirst() +check(Runtime.wants("hot.pair"), "wants stays while a listener remains") +dropSecond() +check(not Runtime.wants("hot.pair"), "and clears when the list empties") +-- a stale unsubscribe run again must not clobber a later subscription +local stale = liveEvents:on("hot.stale", function() end, 0, "test") +stale() +local staleHits = 0 +liveEvents:on("hot.stale", function() staleHits = staleHits + 1 end, 0, "test") +stale() +check(Runtime.wants("hot.stale"), "a stale unsubscribe leaves the new list alone") +liveEvents:emit("hot.stale") +check(staleHits == 1, "and the new listener still fires") +Runtime.install(nullEvents, nullHooks) + +-- POKEPORT_DATA_DIR points Data:load at another dataset root; the fixture +-- set is ROM-free, so this is what lets a runner boot with no data/generated +local ffi = require("ffi") +ffi.cdef([[ +int setenv(const char *name, const char *value, int overwrite); +int unsetenv(const char *name); +]]) +ffi.C.setenv("POKEPORT_DATA_DIR", "tests/fixture_data", 1) +local Data = require("src.core.Data") +local fixture = setmetatable({}, { __index = Data }) +local okLoad, loadErr = pcall(Data.load, fixture) +ffi.C.unsetenv("POKEPORT_DATA_DIR") +check(okLoad, "Data:load honours POKEPORT_DATA_DIR (" .. tostring(loadErr) .. ")") +check(okLoad and fixture.pokemon ~= nil and fixture.pokemon.FIXMON_A ~= nil, + "the override serves the fixture dataset") +check(okLoad and fixture.pokemon.PIDGEY == nil, "and not the generated one") +check(okLoad and fixture.constants.partyMax == 6, + "fixture constants pass through seedDefaults") + +S.finish() diff --git a/tests/mod_save_tests.lua b/tests/mod_save_tests.lua new file mode 100644 index 00000000..32a2bc81 --- /dev/null +++ b/tests/mod_save_tests.lua @@ -0,0 +1,553 @@ +-- Save data and migrations: the data-only serializer grammar, the meta +-- stamp, atomic write recovery, the migration registry (core steps + mod +-- chains), the validation/quarantine pass and the per-mod persistence +-- namespaces. Self-contained: own bootstrap, assert-based checks, +-- error() on any failure. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local SaveSerializer = require("src.core.SaveSerializer") +local SaveData = require("src.core.SaveData") +local Version = require("src.core.Version") +local Runtime = require("src.mods.Runtime") +local Events = require("src.mods.Events") +local Hooks = require("src.mods.Hooks") +local Game = require("src.core.Game") + +local S = require("tests.harness").suite("mod save") +local check = S.check + +local function deepEqual(a, b, path) + path = path or "root" + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false, path end + for k, v in pairs(a) do + local ok, where = deepEqual(v, b[k], path .. "." .. tostring(k)) + if not ok then return false, where end + end + for k in pairs(b) do + if a[k] == nil then return false, path .. "." .. tostring(k) end + end + return true +end + +-- a swappable love.filesystem so every write/read below is isolated from +-- the shared stub state other suites touch; remove included, so the +-- atomic-write sequence runs exactly as it does under real LOVE +local function memfs(files) + return { + files = files, + write = function(path, content) files[path] = content return true end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + return nil + end, + } +end + +-- ---------------------------------------------- serializer grammar + +do + local rich = { + money = 3000, + frac = 0.125, + neg = -42, + big = 1e+300, + flag = true, + off = false, + name = 'quo"te [bra{ck}et]\nline2\ttab', + ctrl = "a\r\0b", + list = { "x", "y" }, + nested = { deep = { er = { level = 4 } } }, + [1] = "numkey", + [2.5] = "floatkey", + [true] = "boolkey", + } + local encoded = SaveSerializer.encode(rich) + local back, err = SaveSerializer.decode(encoded) + check(back, "rich table decodes: " .. tostring(err)) + local same, where = deepEqual(rich, back) + check(same, "rich table round-trips exactly (differs at " .. tostring(where) .. ")") + check(SaveSerializer.encode(back) == encoded, "re-encode is byte-identical") + + -- the reader accepts exactly what the old load()-based decode accepted + -- for writer output: same table, byte for byte + local chunk = assert((loadstring or load)(encoded)) + local viaLoad = chunk() + same, where = deepEqual(viaLoad, back) + check(same, "safe parse matches load()-based decode (differs at " .. tostring(where) .. ")") +end + +do + -- code never executes: each of these fails with a byte offset instead + local hostile = { + 'os.execute("rm -rf /")', + 'return ("x"):rep(9)', + "return setmetatable({}, {})", + "return { x = evil() }", + "return 1 + 1", + "return { f = function() end }", + "return {} return {}", + "return { [os.time()] = 1 }", + "return { x = nil }", + "not lua {{{", + } + for _, src in ipairs(hostile) do + local out, err = SaveSerializer.decode(src) + check(out == nil, "rejected: " .. src) + check(type(err) == "string", "error string for: " .. src) + end + check(select(2, SaveSerializer.decode("return { x = evil() }")):match("byte %d+"), + "parse errors carry a byte offset") + local out = SaveSerializer.decode('return 5\n') + check(out == nil, "non-table root rejected") + -- a brace bomb fails closed instead of blowing the stack + local bomb = "return " .. ("{ a = "):rep(4000) .. "1" .. (" }"):rep(4000) + check(SaveSerializer.decode(bomb) == nil, "deep nesting fails closed") +end + +-- ---------------------------------------------- meta stamp + atomic write + +local realFS = love.filesystem + +do + local files = {} + love.filesystem = memfs(files) + + local mods = { + { id = "zeta_mod", version = "0.4.1", api = 2 }, + { id = "alpha_mod", version = "1.2.0", api = 2 }, + } + local save = SaveData.newGame() + save.money = 111 + check(SaveData.save(save, mods), "stamped save writes") + local written = SaveSerializer.decode(files["save.lua"]) + check(written.meta.format == Version.saveFormat, "meta.format stamped") + check(written.meta.engine == Version.engine, "meta.engine stamped") + check(type(written.meta.savedAt) == "number", "meta.savedAt stamped") + check(written.meta.mods[1].id == "alpha_mod" and written.meta.mods[2].id == "zeta_mod", + "meta.mods sorted by id") + check(written.meta.mods[2].version == "0.4.1", "meta.mods carries versions") + check(files["save.lua.tmp"] == nil, "clean write leaves no .tmp") + check(files["save.lua.bak"] == nil, "first write has nothing to back up") + + -- second write rolls the previous save into the .bak + save.money = 222 + check(SaveData.save(save, mods), "second save writes") + local bak = SaveSerializer.decode(files["save.lua.bak"]) + check(bak.money == 111, "backup holds the previous save") + + -- a headless writer with no mod list keeps the stored mod set + save.money = 333 + check(SaveData.save(save), "modless save keeps stamping") + written = SaveSerializer.decode(files["save.lua"]) + check(#written.meta.mods == 2, "nil mods list preserves the stored set") + + -- corrupt main file: load promotes the .bak (no .tmp survives a clean + -- write) and heals save.lua + files["save.lua"] = "return { hacked = os.execute }" + local loaded, recovered = SaveData.load() + check(loaded and loaded.money == 222, "load recovers from .bak") + check(recovered == "bak", "recovery source reported") + check(SaveSerializer.decode(files["save.lua"]).money == 222, + "recovered bytes are written back to save.lua") + + -- crash between remove and rewrite: only the .tmp holds the new bytes + files["save.lua.tmp"] = SaveSerializer.encode({ money = 444, player = {} }) + files["save.lua"] = nil + loaded, recovered = SaveData.load() + check(loaded and loaded.money == 444, "load promotes the .tmp witness") + check(recovered == "tmp", "tmp recovery reported") + + -- everything corrupt or gone: load gives up cleanly + files["save.lua"] = "junk(" + files["save.lua.tmp"] = "junk(" + files["save.lua.bak"] = "junk(" + check(SaveData.load() == nil, "unrecoverable save returns nil") + + love.filesystem = realFS +end + +-- ---------------------------------------------- core migrations + +do + local files = {} + love.filesystem = memfs(files) + + -- a pre-meta (format 1) save with every legacy shape at once + local legacy = { + player = { map = "PALLET_TOWN", x = 5, y = 6, facing = "down", name = "RED" }, + flags = {}, + objectToggles = { ROUTE_12 = { ROUTE12_SNORLAX = false } }, + box = { { species = "PIDGEY", level = 3 } }, + options = { musicVol = 2 }, + inventory = {}, + party = {}, + money = 100, + } + files["save.lua"] = SaveSerializer.encode(legacy) + local loaded = SaveData.load() + check(loaded, "format-1 save loads") + check(type(loaded.player.id) == "number", "player.id backfilled") + check(loaded.flags.EVENT_BEAT_ROUTE12_SNORLAX == true, "Snorlax flag backfilled from toggle") + check(loaded.meta and loaded.meta.format == Version.saveFormat, "meta.format landed at current") + check(#loaded.meta.mods == 0, "old vanilla save records an empty mod set") + check(loaded.boxes and loaded.boxes[1][1].species == "PIDGEY", "box list folded into boxes[1]") + check(loaded.box == nil, "legacy box key gone") + local opts = SaveSerializer.decode(files["options.lua"]) + check(opts and opts.musicVol == 2, "embedded options split into options.lua") + + -- a save already at the current format skips every core step + local current = { meta = { format = Version.saveFormat, mods = {} }, + player = { map = "PALLET_TOWN", x = 1, y = 1 } } + SaveData.runMigrations(current) + check(current.player.id == nil, "format-2 save skips the id backfill") + + love.filesystem = realFS +end + +-- ---------------------------------------------- mod migration chains + +do + local ran = {} + local chains = { + weather = { + { since = "1.0.0", apply = function(modSave) ran[#ran + 1] = "1.0.0"; modSave.v = 2 end }, + { since = "0.9.5", apply = function(modSave) ran[#ran + 1] = "0.9.5"; modSave.v = 1 end }, + }, + } + local active = { { id = "weather", version = "1.0.0" } } + local save = { + meta = { format = Version.saveFormat, + mods = { { id = "weather", version = "0.9.0" } } }, + modData = { weather = { v = 0 } }, + } + SaveData.runMigrations(save, chains, active) + check(#ran == 2 and ran[1] == "0.9.5" and ran[2] == "1.0.0", + "chain replays in semver order from the stored version") + check(save.modData.weather.v == 2, "migrations mutate the mod's namespace") + + -- stored == current: nothing to replay + ran = {} + save.meta.mods[1].version = "1.0.0" + SaveData.runMigrations(save, chains, active) + check(#ran == 0, "up-to-date mod replays nothing") + + -- current version caps the chain + ran = {} + save.meta.mods[1].version = "0.9.0" + SaveData.runMigrations(save, chains, { { id = "weather", version = "0.9.5" } }) + check(#ran == 1 and ran[1] == "0.9.5", "steps past the current version stay dormant") + + -- no modData: nothing to migrate, nothing crashes + SaveData.runMigrations({ meta = { format = 2, mods = {} } }, chains, active) +end + +-- ---------------------------------------------- validation and quarantine + +-- the merged view validation folds against, tiny on purpose +local function fixtureData() + return { + pokemon = { PIDGEY = { dex = 16 }, RATTATA = { dex = 19 } }, + moves = { TACKLE = { pp = 35 }, GUST = { pp = 35 } }, + items = { POTION = {}, POKE_BALL = {} }, + maps = { TOWN = {}, HOUSE = {} }, + constants = { fallbackMove = "TACKLE" }, + field = { boot = { startMap = "TOWN", startX = 1, startY = 2 } }, + } +end + +do + local data = fixtureData() + local save = { + player = { map = "GONE_MAP", x = 9, y = 9, facing = "down", id = 7 }, + party = { + { species = "PIDGEY", level = 5, dvs = { attack = 20 }, + moves = { { id = "GUST", pp = 10 }, { id = "MODMOVE", pp = 5 } } }, + { species = "MODMON", level = 12 }, + }, + boxes = { { { species = "MODMON2", level = 3 } }, {} }, + daycare = { mon = { species = "MODMON3", level = 8 }, steps = 4 }, + inventory = { POTION = 2, MODITEM = 3 }, + pcItems = { MODITEM2 = 1 }, + bagOrder = { "POTION", "MODITEM" }, + lastHeal = { map = "GONE_HEAL", x = 1, y = 1 }, + lastOutdoor = { id = "GONE_MAP", x = 2, y = 2 }, + pokedex = { seen = { PIDGEY = true, MODMON = true }, owned = { PIDGEY = true } }, + hallOfFame = { { { species = "MODMON", level = 50 }, { species = "PIDGEY", level = 40 } } }, + } + local report = SaveData.validate(save, data) + + check(#save.party == 1 and save.party[1].species == "PIDGEY", + "unknown party species quarantined") + check(#save.boxes[1] == 0, "unknown box species quarantined") + check(save.daycare.mon == nil, "unknown daycare species quarantined") + check(#save.orphaned.mons == 3, "all three mons kept in the LOST box") + check(#report.lostMons == 3, "lost mons reported") + check(save.party[1].dvs.attack == 15, "out-of-range dv clamped") + check(#save.party[1].moves == 1 and save.party[1].moves[1].id == "GUST", + "unknown move slot dropped") + check(save.inventory.MODITEM == nil and save.inventory.POTION == 2, + "unknown item removed, known kept") + check(save.pcItems.MODITEM2 == nil, "unknown pc item removed") + check(#report.lostItems == 2, "removed items reported") + check(#save.bagOrder == 1 and save.bagOrder[1] == "POTION", "bag order pruned") + check(save.lastHeal.map == "TOWN" and save.lastHeal.x == 1 and save.lastHeal.y == 2, + "unknown heal map falls back to the boot spawn") + check(save.player.map == "TOWN", "unknown player map falls back to the heal point") + check(save.lastOutdoor == nil, "unknown lastOutdoor dropped") + check(#report.remappedMaps == 3, "map fallbacks reported") + check(save.pokedex.seen.MODMON == nil and save.pokedex.seen.PIDGEY == true, + "unknown dex entry dropped") + check(#save.hallOfFame[1] == 2, "hall of fame roster keeps its size") + check(save.hallOfFame[1][1].species == nil and save.hallOfFame[1][1].level == 50, + "unknown hall of fame species blanked in place") + check(save.hallOfFame[1][2].species == "PIDGEY", "known hall of fame mon untouched") + check(not SaveData.emptyReport(report), "report is non-empty") + + -- a mon whose whole moveset vanished heals with the data-driven fallback + local wiped = { player = { map = "TOWN" }, + party = { { species = "RATTATA", level = 4, + moves = { { id = "MODMOVE", pp = 1 } } } } } + SaveData.validate(wiped, data) + check(wiped.party[1].moves[1].id == "TACKLE" and wiped.party[1].moves[1].pp == 35, + "emptied moveset repaired with constants.fallbackMove") + + -- the mod comes back: quarantine reverses on the next load + data.pokemon.MODMON = { dex = 152 } + data.items.MODITEM = {} + local report2 = SaveData.validate(save, data) + check(#report2.restoredMons == 1, "returned species reclaimed") + local found + for _, box in ipairs(save.boxes) do + for _, mon in ipairs(box) do + if mon.species == "MODMON" then found = true end + end + end + check(found, "reclaimed mon deposited into the PC") + check(#save.orphaned.mons == 2, "still-unknown mons stay quarantined") + check(save.inventory.MODITEM == 3, "returned item reclaimed into the bag") +end + +do + -- vanilla parity: a clean save is returned untouched, byte for byte + local data = fixtureData() + local save = { + meta = { format = Version.saveFormat, mods = {} }, + player = { map = "TOWN", x = 1, y = 2, facing = "down", id = 7, name = "RED" }, + party = { { species = "PIDGEY", level = 5, + dvs = { attack = 10, hp = 4 }, + statExp = { attack = 100 }, + moves = { { id = "GUST", pp = 10 } } } }, + inventory = { POTION = 1 }, + bagOrder = { "POTION" }, + lastHeal = { map = "TOWN", x = 1, y = 2 }, + pokedex = { seen = { PIDGEY = true }, owned = {} }, + modData = {}, + money = 3000, + } + local before = SaveSerializer.encode(save) + local report = SaveData.validate(save, data) + check(SaveData.emptyReport(report), "clean save yields an empty report") + check(save.orphaned == nil, "no orphaned residue on a clean save") + check(SaveSerializer.encode(save) == before, "clean save re-encodes byte-identically") +end + +-- ---------------------------------------------- migrate before validate + +do + -- a mod that renamed a species repairs its data before the scrub, so + -- nothing lands in quarantine on upgrade + local data = fixtureData() + data.pokemon.PIDGEY_MOD = { dex = 300 } + local save = { + meta = { format = Version.saveFormat, + mods = { { id = "renamer", version = "1.0.0" } } }, + player = { map = "TOWN" }, + party = { { species = "OLD_PIDGEY", level = 9 } }, + modData = { renamer = {} }, + } + local chains = { renamer = { { since = "1.1.0", apply = function(_, s) + for _, mon in ipairs(s.party) do + if mon.species == "OLD_PIDGEY" then mon.species = "PIDGEY_MOD" end + end + end } } } + SaveData.runMigrations(save, chains, { { id = "renamer", version = "1.1.0" } }) + local report = SaveData.validate(save, data) + check(save.party[1].species == "PIDGEY_MOD", "migration renamed the species") + check(#report.lostMons == 0, "migrated mon is never quarantined") +end + +-- ---------------------------------------------- mod-set diff + +do + local save = { meta = { format = 2, mods = { + { id = "kept", version = "1.0.0" }, + { id = "gone", version = "2.0.0" }, + { id = "bumped", version = "1.0.0" }, + } } } + local diff = SaveData.modsDiff(save, { + { id = "kept", version = "1.0.0" }, + { id = "bumped", version = "1.1.0" }, + { id = "fresh", version = "0.1.0" }, + }) + check(#diff.added == 1 and diff.added[1] == "fresh", "added mod detected") + check(#diff.removed == 1 and diff.removed[1] == "gone", "removed mod detected") + check(#diff.changed == 1 and diff.changed[1].id == "bumped" + and diff.changed[1].from == "1.0.0" and diff.changed[1].to == "1.1.0", + "version change detected") + local clean = SaveData.modsDiff({ meta = { mods = {} } }, {}) + check(#clean.added == 0 and #clean.removed == 0 and #clean.changed == 0, + "vanilla diff is empty") +end + +do + -- a mod-set diff with nothing quarantined must still surface the report + local report = { lostMons = {}, lostItems = {}, remappedMaps = {}, + restoredMons = {}, restoredItems = {} } + check(SaveData.emptyReport(report), "diff-less report stays empty") + report.modsDiff = { added = {}, removed = {}, changed = {} } + check(SaveData.emptyReport(report), "empty diff keeps the report empty") + report.modsDiff.changed = { { id = "bumped", from = "1.0.0", to = "1.1.0" } } + check(not SaveData.emptyReport(report), "version bump alone makes the report non-empty") + report.modsDiff.changed = {} + report.modsDiff.removed = { "gone" } + check(not SaveData.emptyReport(report), "removed mod alone makes the report non-empty") + + local meta = { mods = { { id = "kept", version = "1.0.0" }, + { id = "gone", version = "2.0.0" } } } + check(SaveData.modsDiffNotice({ added = {}, removed = { "gone" }, changed = {} }, meta) + == "This save was made with 2 mods; 1 is no longer active", + "removed-mod notice matches the design line") + check(SaveData.modsDiffNotice( + { added = { "fresh" }, removed = { "gone", "gone2" }, + changed = { { id = "bumped", from = "1.0.0", to = "1.1.0" } } }, meta) + == "This save was made with 2 mods; 2 are no longer active, 1 changed version, 1 newly active", + "notice lists every category") + check(SaveData.modsDiffNotice({ added = {}, removed = {}, changed = {} }, meta) == nil, + "empty diff yields no notice") + check(SaveData.modsDiffNotice(nil, meta) == nil, "nil diff yields no notice") +end + +-- ---------------------------------------------- per-mod namespaces + +do + -- adoptSave points the loader's mod.save backing at save.modData + local loader = { modSave = { seeded_mod = { counter = 7 } } } + local game = { mods = loader, adoptSave = Game.adoptSave } + local boot = {} + game:adoptSave(boot, true) + check(boot.modData.seeded_mod.counter == 7, "entry-time buckets seed the boot skeleton") + check(loader.modSave == boot.modData, "loader backing aliases save.modData") + + -- writes through the alias land in the save and survive the serializer + loader.modSave.seeded_mod.counter = 8 + local back = SaveSerializer.decode(SaveSerializer.encode(boot)) + check(back.modData.seeded_mod.counter == 8, "mod.save state persists with the save") + + -- NEW GAME replaces the backing without leaking the old session + local fresh = { modData = {} } + game:adoptSave(fresh) + check(fresh.modData.seeded_mod == nil, "no bucket carry-over into a fresh slot") + check(loader.modSave == fresh.modData, "backing follows the new save") + + -- CONTINUE points the backing at the loaded save's persisted state + local restored = { modData = { seeded_mod = { counter = 99 } } } + game:adoptSave(restored) + check(loader.modSave.seeded_mod.counter == 99, "loaded modData becomes the backing") + + -- a loader-less game still normalizes the namespace + local bare = { adoptSave = Game.adoptSave } + local plain = {} + bare:adoptSave(plain) + check(type(plain.modData) == "table", "modData exists without a loader") +end + +do + -- modOptions round-trips deeply: a partial write keeps sibling mods + local files = {} + local fs = memfs(files) + SaveData.saveOptions({ modOptions = { alpha = { x = 1, keep = true } } }, fs) + SaveData.saveOptions({ modOptions = { beta = { y = 2 } } }, fs) + SaveData.saveOptions({ modOptions = { alpha = { x = 5 } } }, fs) + local opts = SaveData.loadOptions(fs) + check(opts.modOptions.alpha.x == 5, "newest value wins per key") + check(opts.modOptions.alpha.keep == true, "sibling keys survive a partial write") + check(opts.modOptions.beta.y == 2, "sibling mods survive a partial write") + + -- vanilla options never grow a modOptions key + local vfiles = {} + local vfs = memfs(vfiles) + SaveData.saveOptions(SaveData.defaultOptions(), vfs) + check(vfiles["options.lua"]:find("modOptions", 1, true) == nil, + "vanilla options.lua carries no modOptions") +end + +-- ---------------------------------------------- save lifecycle wiring + +do + -- save.created seeds a subtable through the live bus; save.new_game + -- reshapes the skeleton; both are no-ops unhooked + local priorEvents, priorHooks, priorErrors = + Runtime.events, Runtime.hooks, Runtime.errors + local events, hooks = Events.new(), Hooks.new() + Runtime.install(events, hooks, {}) + + local files = {} + love.filesystem = memfs(files) + + hooks:wrap("save.new_game", function(nextFn, save) + local out = nextFn(save) + out.money = 9999 + return out + end, nil, "tc_mod") + events:on("save.created", function(ev) + ev.save.modData.weather = { forecast = "rain" } + end, nil, "weather_mod") + + local save = SaveData.newGame({ startMap = "TOWN", startX = 3, startY = 4 }) + check(save.money == 9999, "save.new_game hook reshapes the skeleton") + check(save.player.map == "TOWN" and save.player.x == 3, "field.boot spawn threads through") + + -- the emit sites in Game fire save.created with { save = ... } + Runtime.emit("save.created", { save = save }) + check(save.modData.weather.forecast == "rain", + "save.created listener seeds a save subtable") + + Runtime.install(priorEvents, priorHooks, priorErrors) + love.filesystem = realFS + + -- unhooked parity: the skeleton comes back vanilla + local plain = SaveData.newGame() + check(plain.money == 3000, "unhooked newGame is vanilla") + check(type(plain.modData) == "table" and next(plain.modData) == nil, + "newGame starts an empty modData") + check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0, + "newGame stamps a vanilla meta") +end + +-- a throwing mod migration is skipped, never fatal: an uncaught error here +-- re-raises on every load and locks the player out of the save +do + local save = { + meta = { format = Version.saveFormat, mods = { + { id = "buggy", version = "1.0.0" }, { id = "good", version = "1.0.0" } } }, + modData = { buggy = {}, good = {} }, + } + local chains = { + buggy = { { since = "1.1.0", apply = function() error("migration bug") end }, + { since = "1.2.0", apply = function(ms) ms.reached = true end } }, + good = { { since = "1.1.0", apply = function(ms) ms.migrated = true end } }, + } + local active = { { id = "buggy", version = "2.0.0" }, { id = "good", version = "2.0.0" } } + local ok = pcall(SaveData.runMigrations, save, chains, active) + check(ok, "a throwing mod migration does not fail the load") + check(save.modData.buggy.reached == nil, + "the rest of a failed migration chain is skipped") + check(save.modData.good.migrated == true, + "one mod's failed migration does not block another mod's") +end + +S.finish() diff --git a/tests/mod_scripting_tests.lua b/tests/mod_scripting_tests.lua new file mode 100644 index 00000000..5b1e5cf0 --- /dev/null +++ b/tests/mod_scripting_tests.lua @@ -0,0 +1,959 @@ +-- Scripting v2 (M5): label resolution, the commands registry consumed by +-- dispatch, map_scripts compose semantics, the queueScript FIFO, bounded +-- parallel runners, the tokens registry, mod-field routing, load-time +-- validation and the script events/hook. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Commands = require("src.script.Commands") +local Data = require("src.core.Data") +local Events = require("src.mods.Events") +local Flags = require("src.script.Flags") +local Hooks = require("src.mods.Hooks") +local Loader = require("src.mods.Loader") +local Logger = require("src.core.Logger") +local MapScripts = require("src.script.MapScripts") +local OW = require("src.world.OverworldController") +local Runtime = require("src.mods.Runtime") +local ScriptRunner = require("src.script.ScriptRunner") +local TextBox = require("src.render.TextBox") +local Tokens = require("src.script.Tokens") + +local S = require("tests.harness").suite("scripting v2 (M5)") +local check = S.check + +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function newGame() + return { data = Data, save = { flags = {}, inventory = {} } } +end + +local function drive(runner, frames) + for _ = 1, frames or 200 do + if not runner:isRunning() then return true end + runner:update() + end + return not runner:isRunning() +end + +-- ------- labels + +do + local labeled = { + { "check_flag", "MOD_SCRIPT_L" }, + { "jump_if_true", "yes" }, + { "set_field", "labelPath", "no" }, + { "jump", "end" }, + { "label", "yes" }, + { "set_field", "labelPath", "yes" }, + } + local numbered = { + { "check_flag", "MOD_SCRIPT_L" }, + { "jump_if_true", 5 }, + { "set_field", "labelPath", "no" }, + { "jump", math.huge }, + { "set_field", "labelPath", "yes" }, + } + for _, flagged in ipairs({ false, true }) do + local results = {} + for kind, script in pairs({ labeled = labeled, numbered = numbered }) do + local game = newGame() + if flagged then Flags.set(game.save, "MOD_SCRIPT_L") end + local runner = ScriptRunner.new(game, nil) + runner:run(script, {}) + check(not runner:isRunning(), kind .. " script completes") + results[kind] = game.save.labelPath + end + check(results.labeled == results.numbered, + "label jumps match the hand-numbered equivalent") + check(results.labeled == (flagged and "yes" or "no"), + "label branch picks the right path") + end + + local labels = ScriptRunner.scanLabels(labeled) + check(labels.yes == 5, "scanLabels finds the label row") + + -- a jump to a missing label kills the script instead of skipping + local game = newGame() + local runner = ScriptRunner.new(game, nil) + runner:run({ { "jump", "nowhere" }, { "set_field", "after", 1 } }, {}) + check(not runner:isRunning() and game.save.after == nil, + "missing label kills the script") +end + +-- ------- unknown verbs: v1 skip vs api-2 strict kill + +do + local game = newGame() + local runner = ScriptRunner.new(game, nil) + runner:run({ { "totally_bogus_verb" }, { "set_field", "after", 1 } }, {}) + check(game.save.after == 1, "unknown verb skips for compat scripts") + + game = newGame() + runner = ScriptRunner.new(game, nil) + runner:run({ { "totally_bogus_verb" }, { "set_field", "after", 1 } }, + { source = { modId = "tmod", strict = true } }) + check(not runner:isRunning() and game.save.after == nil, + "unknown verb kills a strict (api 2) script") +end + +-- ------- load-time validation + +do + local problems = ScriptRunner.validate({ + { "label", "a" }, + { "label", "a" }, + { "jump", "missing" }, + { "bogus_verb" }, + { "jump", 99 }, + "not a row", + }) + local text = table.concat(problems, "\n") + check(text:find("duplicate label 'a'", 1, true), "duplicate label reported") + check(text:find("row 2", 1, true), "duplicate names its row") + check(text:find("missing label 'missing'", 1, true), "missing label reported") + check(text:find("unknown command 'bogus_verb'", 1, true), "unknown verb reported") + check(text:find("out of range", 1, true), "numeric jump bounds checked") + check(text:find("row 6 is not", 1, true), "malformed row reported") + + check(#ScriptRunner.validate({ { "set_flag", "X" }, { "jump", "end" } }) == 0, + "a clean script validates clean") + + local findings = MapScripts.validateContribution({ + talk = { TEXT_V = { { "typod_verb" } } }, + scripts = { amb = { { "jump", "gone" } } }, + }) + local joined = table.concat(findings, "\n") + check(joined:find("talk.TEXT_V", 1, true), "contribution findings name the talk key") + check(joined:find("scripts.amb", 1, true), "contribution findings name the script key") +end + +-- ------- commands registry: mod verbs, override wins, collision fails + +do + local fs = memfs({ + ["mods/tmod/manifest.json"] = + '{"id":"tmod","name":"T","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/tmod/main.lua"] = [[ +return function(mod) + mod.commands:register("tmod:mark", function(ctx, value) + ctx.save.marked = value + end) + mod.content.commands:override("give_money", function(ctx, amount) + ctx.save.money = 777 + end) + mod.content.tokens:register("TMOD_X", function() return "42" end) + mod.content.map_scripts:register("MOD_LOADER_MAP", { + talk = { TEXT_T = { { "tmod:mark", 5 } } }, + }) +end +]], + }) + local loader = Loader.new({ fs = fs }) + local data = { pokemon = {}, moves = {}, items = {} } + check(loader:load(data) == true, "scripting fixture mod loads") + check(type(data.commands["tmod:mark"]) == "function", "mod verb merges") + + local game = { data = data, save = { flags = {}, inventory = {}, money = 0 } } + local runner = ScriptRunner.new(game, nil) + runner:run({ { "tmod:mark", 5 }, { "give_money", 10 } }, {}) + check(game.save.marked == 5, "a mod-registered verb dispatches") + check(game.save.money == 777, "a mod override of an engine verb wins dispatch") + + -- the merged chain drives MapScripts through the same store the loader wrote + local savedChains = Data.map_scripts + Data.map_scripts = data.map_scripts + MapScripts.invalidate() + local rows = MapScripts.talkScript("MOD_LOADER_MAP", "TEXT_T") + check(type(rows) == "table" and rows[1][1] == "tmod:mark", + "a registered map_scripts talk entry resolves") + Data.map_scripts = savedChains + MapScripts.invalidate() + + -- tokens registry drives substitution + local tokenGame = { data = data, save = { player = { name = "ASH" } } } + check(TextBox.substitute(tokenGame, "{TMOD_X}/{PLAYER}") == "42/ASH", + "a mod token expands beside the engine set") + + -- register over an engine verb is a collision, not a silent replace + local clash = Loader.new({ fs = memfs({ + ["mods/clash/manifest.json"] = + '{"id":"clash","name":"C","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/clash/main.lua"] = [[ +return function(mod) + mod.commands:register("show_text", function() end) +end +]], + }) }) + check(clash:load({ pokemon = {} }) == false, + "registering over an engine verb fails the mod") +end + +-- ------- foreground metadata and parallel rejection + +do + check(Commands.meta.show_text.foreground and Commands.meta.show_text.blocking, + "show_text carries foreground+blocking metadata") + check(Commands.meta.wait_flag.blocking and not Commands.meta.wait_flag.foreground, + "wait_flag is background-legal") + + local game = newGame() + local runner = ScriptRunner.new(game, nil) + runner.parallel = true + runner:run({ { "show_text", "HI" }, { "set_field", "after", 1 } }, {}) + check(not runner:isRunning() and game.save.after == nil, + "a foreground verb kills a parallel script") + + game = newGame() + runner = ScriptRunner.new(game, nil) + runner.parallel = true + runner:run({ { "set_flag", "MOD_BG_OK" } }, {}) + check(Flags.get(game.save, "MOD_BG_OK"), "background-legal verbs run in parallel") +end + +-- ------- tokens: engine parity and the unknown-token fallback + +do + local game = { save = { player = { name = "ASH" } }, stringBuffer = "POTION" } + check(TextBox.substitute(game, "{PLAYER} got\n{RAM:wStringBuffer}!") + == "ASH got\nPOTION!", "PLAYER and RAM expand as before") + check(TextBox.substitute(game, "{RIVAL}") == "BLUE", "RIVAL keeps its default") + check(TextBox.substitute(game, "A{RAM:wOtherBuffer}B") == "AB", + "an unhandled RAM buffer still drops silently") + + local before = #Logger.history + check(Tokens.expand(game, "A{MOD_NOPE_TOKEN}B{MOD_NOPE_TOKEN}C", + TextBox.TOKENS) == "ABC", "an unknown token is dropped, not rendered") + local warns = 0 + for i = before + 1, #Logger.history do + if Logger.history[i]:find("MOD_NOPE_TOKEN", 1, true) then warns = warns + 1 end + end + check(warns == 1, "the unknown token warns once, not per occurrence") +end + +-- ------- tokens: golden parity sweep over the vanilla text corpus + +do + -- the pre-registry substitute, reproduced verbatim as the oracle: the + -- registry path must render every vanilla string byte-identically, + -- including the {NUM:...} extractor spans the old catch-all left alone + local function oracle(game, text) + local save = game.save + text = text:gsub("{PLAYER}", save.player.name or "RED") + text = text:gsub("{RIVAL}", save.player.rival or "BLUE") + if game.stringBuffer then + text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer) + end + text = text:gsub("{[%w_:]+}", "") + return text + end + local game = { save = { player = { name = "ASH", rival = "GARY" } }, + stringBuffer = "POTION" } + check(type(Data.text) == "table" and next(Data.text) ~= nil, + "the text corpus is loaded") + local swept = 0 + for id, text in pairs(Data.text) do + if type(text) == "string" then + check(TextBox.substitute(game, text) == oracle(game, text), + "token expansion diverges from the oracle on " .. tostring(id)) + swept = swept + 1 + end + end + check(swept > 2000, "the sweep covered the generated corpus") +end + +-- ------- map_scripts compose semantics + +do + local calls = {} + local baseTalk = { { "set_flag", "BASE_TALK" } } + MapScripts.attachBase("MOD_COMPOSE_MAP", { + talk = { TEXT_A = baseTalk, TEXT_B = { { "set_flag", "BASE_B" } } }, + onEnter = function() calls[#calls + 1] = "base" end, + onStep = function() calls[#calls + 1] = "base_step" return false end, + }) + local fastPath = MapScripts.get("MOD_COMPOSE_MAP") + check(fastPath.talk.TEXT_A == baseTalk, + "no chain returns the base table untouched") + + -- the loader writes Registry:chain output: priority descending, then + -- registration order; the view re-ranks equal-priority ties so the + -- later registration wins, and slots base at priority 0 behind mods + local modTalkA = { { "set_flag", "MOD_TALK" } } + local chain = { + { priority = 5, onEnter = function() calls[#calls + 1] = "D" end, + talk = { TEXT_B = false } }, + { onEnter = function() calls[#calls + 1] = "A" end, + talk = { TEXT_A = modTalkA }, + scripts = { amb = { { "set_flag", "AMB" } } } }, + { onEnter = function() calls[#calls + 1] = "B" error("boom") end, + onStep = function() calls[#calls + 1] = "B_step" return true end }, + { priority = -1, onEnter = function() calls[#calls + 1] = "C" end }, + } + local savedChains = Data.map_scripts + Data.map_scripts = { MOD_COMPOSE_MAP = chain } + MapScripts.invalidate() + + local view = MapScripts.get("MOD_COMPOSE_MAP") + check(MapScripts.get("MOD_COMPOSE_MAP") == view, "merged views are cached") + + check(view.talk.TEXT_A == modTalkA, "talk is single-winner: the mod outranks base") + check(view.talk.TEXT_B == nil, "talk false suppresses the base entry") + check(MapScripts.baseTalk("MOD_COMPOSE_MAP", "TEXT_A") == baseTalk, + "baseTalk still reaches the engine handler behind the override") + check(MapScripts.namedScript("MOD_COMPOSE_MAP", "amb") ~= nil, + "scripts entries resolve by MAP/name") + + calls = {} + view.onEnter({}, {}) + check(table.concat(calls, ",") == "D,B,A,base,C", + "onEnter all-run order: priority desc, later-first ties, base behind, " + .. "negative after base, throwing sibling isolated (got " + .. table.concat(calls, ",") .. ")") + + calls = {} + local consumed = view.onStep({}, {}, 0, 0) + check(consumed == true and #calls == 1 and calls[1] == "B_step", + "onStep first truthy return consumes the step") + + Data.map_scripts = savedChains + MapScripts.invalidate() + check(MapScripts.get("MOD_COMPOSE_MAP").talk.TEXT_A == baseTalk, + "dropping the chain restores the base fast path") + + -- a mod talk script on a real map merges beside the vanilla NPCs + local init = require("data.scripts.init") + local clerk = init.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK") + check(clerk ~= nil, "the vanilla clerk script is registered") + Data.map_scripts = { VIRIDIAN_MART = { + { talk = { TEXT_VIRIDIANMART_MODNPC = { { "set_flag", "MOD_HELLO" } } } }, + } } + MapScripts.invalidate() + check(init.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_MODNPC") ~= nil, + "the mod talk entry resolves on an existing map") + check(init.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK") == clerk, + "the vanilla clerk script is not displaced") + Data.map_scripts = savedChains + MapScripts.invalidate() +end + +-- ------- map_scripts:override, the total-conversion escape hatch (09 4.4) + +do + local baseRan = false + local baseTalk = { { "set_flag", "TC_BASE_TALK" } } + MapScripts.attachBase("MOD_TC_MAP", { + talk = { TEXT_TC_BASE = baseTalk, TEXT_TC_KEPT = { { "set_flag", "KEPT" } } }, + onEnter = function() baseRan = true end, + onStep = function() return true end, + snorlaxWake = { script = { { "set_flag", "WAKE" } } }, + }) + + local fs = memfs({ + ["mods/tc/manifest.json"] = + '{"id":"tc","name":"TC","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/tc/main.lua"] = [[ +return function(mod) + -- a plain contribution first: override must clear this one too, not just + -- fold on top of it + mod.content.map_scripts:register("MOD_TC_MAP", { + talk = { TEXT_TC_EARLY = { { "set_flag", "EARLY" } } }, + }) + mod.content.map_scripts:override("MOD_TC_MAP", { + talk = { TEXT_TC_NEW = { { "set_flag", "TC_NEW" } } }, + onEnter = function(game) game.tcRan = true end, + }) + mod.content.map_scripts:register("MOD_TC_ADD_MAP", { + talk = { TEXT_TC_ADD = { { "set_flag", "ADD" } } }, + }) +end +]], + }) + local loader = Loader.new({ fs = fs }) + local data = { pokemon = {}, moves = {}, items = {} } + check(loader:load(data) == true, "total-conversion fixture mod loads") + + local chain = data.map_scripts.MOD_TC_MAP + check(chain.replacesBase == true, "an override stamps replacesBase on the chain") + check(#chain == 1, "override collapses the chain to a single contribution") + + local savedChains = Data.map_scripts + Data.map_scripts = data.map_scripts + MapScripts.invalidate() + + local view = MapScripts.get("MOD_TC_MAP") + check(view.talk.TEXT_TC_NEW ~= nil, "the override's own talk entry resolves") + check(view.talk.TEXT_TC_BASE == nil, + "base talk entries are absent from an overridden map") + check(view.talk.TEXT_TC_KEPT == nil, + "a base TEXT constant the override never redefined does not bleed through") + check(view.talk.TEXT_TC_EARLY == nil, + "override clears lower-precedence mod contributions too") + check(view.snorlaxWake == nil, "legacy base keys are cleared by an override") + check(view.onStep == nil, "base hooks are absent from an overridden map") + + baseRan = false + local probe = {} + view.onEnter(probe, {}) + check(probe.tcRan == true, "the override's onEnter runs") + check(baseRan == false, "base onEnter does not run on an overridden map") + + -- the control: a plain register still composes on top of base + check(MapScripts.get("MOD_TC_ADD_MAP") ~= nil, "a register-only map still merges") + check(data.map_scripts.MOD_TC_ADD_MAP.replacesBase == nil, + "a register-only chain is not flagged as replacing base") + + Data.map_scripts = savedChains + MapScripts.invalidate() + check(MapScripts.get("MOD_TC_MAP").talk.TEXT_TC_BASE == baseTalk, + "dropping the chain restores the untouched base contribution") +end + +-- ------- map_scripts:remove, the whole-map tombstone (09 4.4) + +do + MapScripts.attachBase("MOD_RM_MAP", { + talk = { TEXT_RM_BASE = { { "set_flag", "RM_BASE" } } }, + onEnter = function() error("base onEnter ran on a removed map") end, + snorlaxWake = { script = { { "set_flag", "RM_WAKE" } } }, + }) + local keptTalk = { { "set_flag", "RM_KEPT" } } + MapScripts.attachBase("MOD_RM_KEPT_MAP", { talk = { TEXT_RM_KEPT = keptTalk } }) + MapScripts.attachBase("MOD_RM_BACK_MAP", { + talk = { TEXT_RM_BACK_BASE = { { "set_flag", "RM_BACK_BASE" } } }, + }) + + local fs = memfs({ + ["mods/rmadd/manifest.json"] = + '{"id":"rmadd","name":"Add","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/rmadd/main.lua"] = [[ +return function(mod) + mod.content.map_scripts:register("MOD_RM_MAP", { + talk = { TEXT_RM_ADDED = { { "set_flag", "RM_ADDED" } } }, + onEnter = function(game) game.addedRan = true end, + }) +end +]], + -- depends on rmadd so the remove is guaranteed to land after it + ["mods/rmcut/manifest.json"] = + '{"id":"rmcut","name":"Cut","version":"1.0.0","entry":"main.lua","api":2,' + .. '"dependencies":["rmadd"]}', + ["mods/rmcut/main.lua"] = [[ +return function(mod) + mod.content.map_scripts:remove("MOD_RM_MAP") + mod.content.map_scripts:remove("MOD_RM_BACK_MAP") + mod.content.map_scripts:register("MOD_RM_BACK_MAP", { + talk = { TEXT_RM_BACK_NEW = { { "set_flag", "RM_BACK_NEW" } } }, + }) +end +]], + }) + local loader = Loader.new({ fs = fs }) + local data = { pokemon = {}, moves = {}, items = {} } + check(loader:load(data) == true, "map_scripts remove fixture loads") + + local chain = data.map_scripts.MOD_RM_MAP + check(type(chain) == "table" and #chain == 0, + "a removed map merges as an empty chain, not as a missing key") + check(chain.replacesBase == true, + "the tombstone is stamped so the consumer drops its base contribution") + + local savedChains = Data.map_scripts + Data.map_scripts = data.map_scripts + MapScripts.invalidate() + + local init = require("data.scripts.init") + check(MapScripts.get("MOD_RM_MAP") == nil, + "a removed map has no view: no base talk, no base onEnter, no snorlaxWake") + check(init.get("MOD_RM_MAP") == nil, "the dispatcher sees the map as gone") + check(MapScripts.talkScript("MOD_RM_MAP", "TEXT_RM_BASE") == nil, + "base talk does not survive the removal") + check(MapScripts.baseTalk("MOD_RM_MAP", "TEXT_RM_BASE") ~= nil, + "the base contribution itself is untouched, only excluded") + check(MapScripts.talkScript("MOD_RM_MAP", "TEXT_RM_ADDED") == nil, + "remove clears another owner's contribution too") + + -- registering after the tombstone rebuilds the map from nothing + local backView = MapScripts.get("MOD_RM_BACK_MAP") + check(backView and backView.talk.TEXT_RM_BACK_NEW ~= nil, + "a register after remove resurrects the map id") + check(backView.talk.TEXT_RM_BACK_BASE == nil, + "and base stays out of the resurrected chain") + + check(MapScripts.get("MOD_RM_KEPT_MAP").talk.TEXT_RM_KEPT == keptTalk, + "an untouched map still takes the base fast path") + + Data.map_scripts = savedChains + MapScripts.invalidate() + check(MapScripts.talkScript("MOD_RM_MAP", "TEXT_RM_BASE") ~= nil, + "dropping the chain restores the base contribution") +end + +-- ------- owner attribution through the real dispatch path + +do + local fs = memfs({ + ["mods/srcmod/manifest.json"] = + '{"id":"srcmod","name":"S","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/srcmod/main.lua"] = [[ +return function(mod) + mod.content.map_scripts:register("MOD_SOURCE_MAP", { + talk = { TEXT_S = { { "set_field", "mod:asked_count", 0 } } }, + scripts = { amb = { { "set_flag", "MOD_SRC_AMB" } } }, + }) +end +]], + }) + local loader = Loader.new({ fs = fs }) + local data = { pokemon = {}, moves = {}, items = {} } + check(loader:load(data) == true, "source fixture mod loads") + local chain = data.map_scripts.MOD_SOURCE_MAP + check(chain.owners and chain.owners[1] + and chain.owners[1].modId == "srcmod" and chain.owners[1].strict == true, + "the merged chain carries owner records") + + local savedChains = Data.map_scripts + Data.map_scripts = data.map_scripts + MapScripts.invalidate() + + local source = MapScripts.talkSource("MOD_SOURCE_MAP", "TEXT_S") + check(source and source.modId == "srcmod" and source.strict == true + and source.mapId == "MOD_SOURCE_MAP" and source.hook == "talk", + "talkSource names the owning contribution") + local named = MapScripts.namedSource("MOD_SOURCE_MAP", "amb") + check(named and named.modId == "srcmod" and named.hook == "scripts.amb", + "namedSource names the scripts entry owner") + + -- the real showMapText dispatch: the source rides into the runner, so a + -- mod: field lands in the owner's save.modData bucket instead of killing + -- the script + local fakeGame = { data = Data, save = { flags = {}, inventory = {} } } + local gameIdx, scriptsIdx + local i = 1 + while true do + local name = debug.getupvalue(OW.showMapText, i) + if not name then break end + if name == "Game" then + gameIdx = i + debug.setupvalue(OW.showMapText, i, fakeGame) + elseif name == "mapScripts" then + scriptsIdx = i + debug.setupvalue(OW.showMapText, i, require("data.scripts.init")) + end + i = i + 1 + end + check(gameIdx and scriptsIdx, "showMapText binds Game and mapScripts") + local ow = setmetatable({ + map = { id = "MOD_SOURCE_MAP", def = { label = "ModSourceMap" } }, + }, { __index = OW }) + ow.runner = ScriptRunner.new(fakeGame, ow) + ow:showMapText("TEXT_S", nil, nil) + check(not ow.runner:isRunning(), "the talk rows complete") + check(fakeGame.save.modData and fakeGame.save.modData.srcmod + and fakeGame.save.modData.srcmod.asked_count == 0, + "a mod: field write lands under the owner on the real dispatch path") + + -- a startParallel named ref runs as the owning contribution + ow:startParallel("MOD_SOURCE_MAP/amb") + local queued = ow.parallelQueue and ow.parallelQueue[1] + check(queued and queued.extra and queued.extra.source + and queued.extra.source.modId == "srcmod", + "a named parallel ref carries its owner's source") + + debug.setupvalue(OW.showMapText, gameIdx, require("src.core.Game")) + Data.map_scripts = savedChains + MapScripts.invalidate() +end + +-- ------- hook chains attribute a throwing handler to its owner + +do + local savedEvents, savedHooks, savedErrors = + Runtime.events, Runtime.hooks, Runtime.errors + local errs = {} + Runtime.install(Events.new(), Hooks.new(), errs) + local savedChains = Data.map_scripts + Data.map_scripts = { MOD_ATTR_MAP = { + { onEnter = function() error("attr boom") end }, + owners = { { modId = "attrmod", strict = true } }, + } } + MapScripts.invalidate() + local view = MapScripts.get("MOD_ATTR_MAP") + local before = #Logger.history + view.onEnter({}, {}) -- must be swallowed, not propagate + local named = false + for i = before + 1, #Logger.history do + if Logger.history[i]:find("attrmod", 1, true) then named = true end + end + check(named, "a throwing mod hook logs its owner") + check(#errs == 1 and errs[1]:find("attrmod", 1, true) ~= nil + and errs[1]:find("attr boom", 1, true) ~= nil, + "the failure lands in the runtime error feed") + Data.map_scripts = savedChains + MapScripts.invalidate() + Runtime.install(savedEvents, savedHooks, savedErrors) +end + +-- ------- §4.9 through the real loader: bad rows fail an api 2 mod + +do + local badMain = [[ +return function(mod) + mod.commands:register("badmod:mark", function() end) + mod.content.map_scripts:register("VIRIDIAN_CITY", { + talk = { TEXT_VIRIDIANCITY_GAMBLER1 = { { "totally_bogus_verb_xyz" } } }, + }) +end +]] + local badFiles = { + ["mods/badmod/manifest.json"] = + '{"id":"badmod","name":"B","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/badmod/main.lua"] = badMain, + } + local loader = Loader.new({ fs = memfs(badFiles) }) + local data = { pokemon = {}, moves = {}, items = {} } + check(loader:load(data) == false, "a typo'd verb fails an api 2 mod at load") + local seen + for _, entry in ipairs(loader:status().available) do + if entry.id == "badmod" then seen = entry end + end + check(seen and seen.state == "failed" + and seen.error:find("unknown command 'totally_bogus_verb_xyz'", 1, true) ~= nil + and seen.error:find("VIRIDIAN_CITY", 1, true) ~= nil, + "the manager sees a named load error") + check(data.map_scripts == nil, "the bad contribution never merges") + check(data.commands["badmod:mark"] == nil and data.commands.show_text ~= nil, + "the failed mod's other content rolls back") + + -- the same rows in an api 1 mod keep the v1 runtime skip: warn and load + local softLoader = Loader.new({ fs = memfs({ + ["mods/softmod/manifest.json"] = + '{"id":"softmod","name":"S","version":"1.0.0","entry":"main.lua"}', + ["mods/softmod/main.lua"] = badMain, + }) }) + local softData = { pokemon = {}, moves = {}, items = {} } + local before = #Logger.history + check(softLoader:load(softData) == true, "api 1 findings do not fail the load") + check(softData.map_scripts and softData.map_scripts.VIRIDIAN_CITY ~= nil, + "the api 1 contribution still merges") + local warned = false + for i = before + 1, #Logger.history do + if Logger.history[i]:find("totally_bogus_verb_xyz", 1, true) then warned = true end + end + check(warned, "api 1 findings surface as warnings") + + -- a dependent of the failed mod is unloaded and purged with it + local casFiles = { + ["mods/leech/manifest.json"] = '{"id":"leech","name":"L","version":"1.0.0",' + .. '"entry":"main.lua","api":2,"dependencies":["badmod"]}', + ["mods/leech/main.lua"] = [[ +return function(mod) + mod.commands:register("leech:mark", function() end) +end +]], + } + for path, content in pairs(badFiles) do casFiles[path] = content end + local casLoader = Loader.new({ fs = memfs(casFiles) }) + local casData = { pokemon = {}, moves = {}, items = {} } + check(casLoader:load(casData) == false, "the cascade load fails") + local states = {} + for _, entry in ipairs(casLoader:status().available) do + states[entry.id] = entry.state + end + check(states.badmod == "failed" and states.leech == "blocked_dependency", + "the dependent is taken down with the bad mod") + check(casData.commands["leech:mark"] == nil, + "the dependent's content rolls back too") + check(#casLoader:status().order == 0, "neither mod stays in the load order") +end + +-- ------- queueScript FIFO + +do + local ran = {} + local fakeRunner = { running = false } + function fakeRunner:isRunning() return self.running end + function fakeRunner:run(script) ran[#ran + 1] = script end + function fakeRunner:update() end + local ow = setmetatable({ + scriptMoves = {}, transitioning = false, runner = fakeRunner, + map = { id = "MOD_FIFO_MAP" }, + }, { __index = OW }) + local s1, s2, s3 = { "s1" }, { "s2" }, { "s3" } + ow:queueScript(s1) + ow:queueScript(s2) + ow:queueScript(s3) + check(#ow.pendingScripts == 3, "three scripts queue without clobbering") + ow:drainPendingScripts() + check(#ran == 1 and ran[1] == s1, "one script starts per idle frame, in order") + fakeRunner.running = true + ow:drainPendingScripts() + check(#ran == 1, "a busy runner defers the queue") + fakeRunner.running = false + ow:drainPendingScripts() + ow:drainPendingScripts() + check(ran[2] == s2 and ran[3] == s3, "the FIFO drains head-first") +end + +-- ------- parallel runners: slots, drain, kill, move locks + +do + -- OverworldState's methods close over a module-local Game + local fakeGame = newGame() + local bound = false + local i = 1 + while true do + local name = debug.getupvalue(OW.updateParallel, i) + if not name then break end + if name == "Game" then + debug.setupvalue(OW.updateParallel, i, fakeGame) + bound = true + break + end + i = i + 1 + end + check(bound, "updateParallel binds Game") + + local ow = setmetatable({ + scriptMoves = {}, transitioning = false, npcs = {}, entities = {}, + map = { id = "MOD_PARA_MAP" }, + parallelRunners = {}, parallelQueue = {}, marchers = {}, + pendingScripts = {}, npcMoveLocks = {}, + }, { __index = OW }) + ow.runner = ScriptRunner.new(fakeGame, ow) + + for _ = 1, 5 do + ow:startParallel({ { "wait_flag", "MOD_PARA_GO" } }) + end + ow:updateParallel() + check(#ow.parallelRunners == 4 and #ow.parallelQueue == 1, + "four bounded slots; overflow waits FIFO-style") + + Flags.set(fakeGame.save, "MOD_PARA_GO") + ow:updateParallel() + ow:updateParallel() + ow:updateParallel() + check(#ow.parallelRunners == 0 and #ow.parallelQueue == 0, + "parallel runners drain once the flag lands") + + -- a parallel NPC walk takes the move lock; a foreground move preempts + Flags.clear(fakeGame.save, "MOD_PARA_GO") + local npc = { def = { index = 2 }, cellX = 0, cellY = 0, moving = false } + ow.npcs = { npc } + ow:startParallel({ { "walk_npc", 2, { "down", "down" } } }) + ow:updateParallel() + local holder = ow.npcMoveLocks[npc] + check(holder ~= nil and holder.parallel, "a parallel walk takes the NPC move lock") + + ow.runner:run({ { "move_npc", 2, "up", 1 } }, {}) + check(ow.npcMoveLocks[npc] == nil, "a foreground move releases the lock") + check(not holder:isRunning(), "the parallel runner was preempted") + -- finish the foreground move so the runner ends clean + ow:updateScriptMoves() + npc.moving = false + ow:updateScriptMoves() + check(not ow.runner:isRunning(), "the foreground move completes") + + -- the player is never movable from a parallel runner + ow.player = { cellX = 0, cellY = 0, moving = false } + ow:startParallel({ { "walk_npc", "player", { "down" } } }) + ow:updateParallel() + ow:updateParallel() + check(#ow.parallelRunners == 0, "a parallel player move dies on the spot") + + -- march_in_place toggles ride ow.marchers, not scriptMoves + local marchRunner = ScriptRunner.new(fakeGame, ow) + marchRunner:run({ { "march_in_place", 2, true } }, {}) + check(ow.marchers[npc] == true and #ow.scriptMoves == 0, + "march_in_place arms the marcher table without a scriptMove") + ow:updateScriptMoves() + check(npc.marching == true, "the marcher cycle re-arms") + npc.moving, npc.marching = false, false + marchRunner:run({ { "march_in_place", 2, false } }, {}) + ow:updateScriptMoves() + check(ow.marchers[npc] == nil and npc.marching == false, + "march_in_place off stops the cycle") + + -- restore the module-local Game for later chained suites + debug.setupvalue(OW.updateParallel, i, require("src.core.Game")) +end + +-- ------- emote and choice + +do + local fakeGame = newGame() + local ow = { player = { px = 0, py = 0 }, npcs = {} } + local runner = ScriptRunner.new(fakeGame, ow) + runner:run({ { "emote", "player", "question", 5 }, + { "set_field", "after", 1 } }, {}) + check(ow.emote and ow.emote.npc == ow.player and ow.emote.bubble == 2 + and ow.emote.frames == 5, "emote arms the bubble hold") + check(fakeGame.save.after == nil, "emote blocks until the hold ends") + ow.emote.onDone() + check(fakeGame.save.after == 1, "the hold resumes the script") + + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + fakeGame.stack = stack + runner = ScriptRunner.new(fakeGame, ow) + runner:run({ { "choice", { "YES", "NO", "MAYBE" }, { cancel = 3 } }, + { "jump_if_true", "first" }, + { "set_field", "picked", "other" }, + { "jump", "end" }, + { "label", "first" }, + { "set_field", "picked", "first" } }, {}) + local menu = stack.states[#stack.states] + check(menu and #menu.items == 3, "choice pushes a three-way menu") + menu.items[2].onSelect() + check(fakeGame.save.picked == "other", "a non-first choice clears lastCheck") + + runner = ScriptRunner.new(fakeGame, ow) + runner:run({ { "choice", { "YES", "NO" } }, + { "jump_if_true", "first" }, + { "jump", "end" }, + { "label", "first" }, + { "set_field", "picked", "first" } }, {}) + stack.states[#stack.states].items[1].onSelect() + check(fakeGame.save.picked == "first", "the first choice sets lastCheck") +end + +-- ------- mod-field routing and wait_flag + +do + local game = newGame() + local runner = ScriptRunner.new(game, nil) + runner:run({ { "set_field", "mod:stage", 3 }, + { "check_flag", "mod:stage" }, + { "jump_if_true", "yes" }, + { "set_field", "sawStage", false }, + { "jump", "end" }, + { "label", "yes" }, + { "set_field", "sawStage", true } }, + { source = { modId = "tmod" } }) + check(game.save.modData and game.save.modData.tmod + and game.save.modData.tmod.stage == 3, + "mod: fields land in save.modData under the owner") + check(game.save.sawStage == true, "check_flag reads mod: fields symmetrically") + + game = newGame() + runner = ScriptRunner.new(game, nil) + runner:run({ { "set_field", "mod:x", 1 }, { "set_field", "after", 1 } }, {}) + check(game.save.after == nil and game.save.modData == nil, + "mod: fields are a script error in engine-owned scripts") + + -- wait_flag: timeout path then flag path + game = newGame() + runner = ScriptRunner.new(game, nil) + local script = { { "wait_flag", "MOD_WF", 3 }, + { "jump_if_true", "hit" }, + { "set_field", "wf", "timeout" }, + { "jump", "end" }, + { "label", "hit" }, + { "set_field", "wf", "flag" } } + runner:run(script, {}) + check(runner:isRunning(), "wait_flag blocks") + drive(runner, 10) + check(game.save.wf == "timeout", "wait_flag times out with lastCheck false") + + game = newGame() + runner = ScriptRunner.new(game, nil) + runner:run(script, {}) + Flags.set(game.save, "MOD_WF") + drive(runner, 10) + check(game.save.wf == "flag", "wait_flag resumes true when the flag lands") +end + +-- ------- script events and the script.command hook + +do + local savedEvents, savedHooks, savedErrors = + Runtime.events, Runtime.hooks, Runtime.errors + local events, hooks = Events.new(), Hooks.new() + Runtime.install(events, hooks, {}) + + local flagSeen = {} + events:on("flag.changed", function(ev) + flagSeen[#flagSeen + 1] = ev.name .. "=" .. tostring(ev.value) + end, 0, "t") + local save = { flags = {} } + Flags.set(save, "MOD_EV") + Flags.set(save, "MOD_EV") + Flags.clear(save, "MOD_EV") + Flags.clear(save, "MOD_EV") + check(table.concat(flagSeen, ",") == "MOD_EV=true,MOD_EV=false", + "flag.changed fires only on actual transitions") + + local lifecycle = {} + events:on("script.started", function() lifecycle[#lifecycle + 1] = "start" end, + 0, "t") + events:on("script.ended", function(ev) + lifecycle[#lifecycle + 1] = "end:" .. tostring(ev.completed) + end, 0, "t") + local game = newGame() + local runner = ScriptRunner.new(game, nil) + runner:run({ { "set_flag", "X" } }, {}) + check(table.concat(lifecycle, ",") == "start,end:true", + "script.started/ended bracket a clean run") + lifecycle = {} + runner = ScriptRunner.new(game, nil) + runner:run({ { "jump", "nowhere" } }, {}) + check(lifecycle[2] == "end:false", "an error-kill emits completed = false") + + local commandsSeen = {} + hooks:wrap("script.command", function(nextFn, ctx, name, args) + commandsSeen[#commandsSeen + 1] = name + if name == "set_field" and args[1] == "skipme" then + return 4 -- force the jump past the marker row + end + return nextFn() + end, 0, "t") + game = newGame() + runner = ScriptRunner.new(game, nil) + runner:run({ { "set_flag", "A" }, + { "set_field", "skipme", 1 }, + { "set_field", "skipped", 1 }, + { "set_flag", "B" } }, {}) + check(table.concat(commandsSeen, ",") == "set_flag,set_field,set_flag", + "the script.command hook wraps every dispatch") + check(game.save.skipme == nil and game.save.skipped == nil + and Flags.get(game.save, "B"), + "a hook-returned pc rewrites the jump") + + Runtime.install(savedEvents, savedHooks, savedErrors) +end + +S.finish() diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua new file mode 100644 index 00000000..de8ab760 --- /dev/null +++ b/tests/mod_ui_tests.lua @@ -0,0 +1,776 @@ +-- UI extensibility (M8): the Screens factory and its cache invalidation, +-- StateStack screen events, the three menu-injection hooks with non-table +-- degrade, the mod.ui helper surface, theme defaults, branding reads from +-- field.*, and ManagerState v2 -- error surfacing, toggle resolution with +-- dependency dialogs, staged apply/discard, options auto-UI, profiles and +-- the safe-mode banner. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("mod ui") +local check = S.check + +local love = _G.love or require("tests.love_stub") +_G.love = love + +local Events = require("src.mods.Events") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local Logger = require("src.core.Logger") +local Assets = require("src.render.Assets") +local Screens = require("src.ui.Screens") +local StateStack = require("src.core.StateStack") +local ManagerState = require("src.mods.ManagerState") +local ModUI = require("src.ui.ModUI") +local Theme = require("src.ui.Theme") + +local savedEvents, savedHooks, savedErrors = + Runtime.events, Runtime.hooks, Runtime.errors +local savedSafeMode = Runtime.safeMode + +local function logged(fragment) + for _, line in ipairs(Logger.history) do + if line:find(fragment, 1, true) then return true end + end + return false +end + +-- minimal stack/input doubles matching the StateStack and Input surfaces +local function newStack() + local stack = { states = {} } + function stack:push(state, ...) + table.insert(self.states, state) + if state.enter then state:enter(...) end + end + function stack:pop() + local state = table.remove(self.states) + if state and state.exit then state:exit() end + return state + end + function stack:top() return self.states[#self.states] end + return stack +end + +local function newInput() + local input = { queue = {} } + function input:wasPressed(btn) return self.queue[btn] or false end + return input +end + +local function press(state, btn) + state.game.input.queue = { [btn] = true } + state:update(1 / 60) + state.game.input.queue = {} +end + +-- ------- Screens: resolution identity (parity) +Screens.invalidate() +local sgame = { data = {}, stack = newStack() } +for _, id in ipairs({ "TitleState", "IntroMovie", "OakSpeech", "NamingScreen", + "StartMenu", "PokedexMenu", "DexEntryMenu", "TownMap", "PartyMenu", + "BagMenu", "SummaryMenu", "TrainerCard", "OptionsMenu", "ShopMenu", + "BoxMenu", "PlayerPC", "MoveLearnMenu", "EvolutionState", "HallOfFame", + "Credits", "SlotMachine", "TradeAnim", "FlyMenu", "BindingsMenu" }) do + check(Screens.get(sgame, id) == require("src.ui." .. id), + "empty registry resolves the require module: " .. id) +end +check(Screens.get(sgame, "ManagerState") == ManagerState, + "ManagerState resolves from src.mods") + +-- ------- Screens: override, screenId stamp, broken-factory fallback +Screens.invalidate() +sgame.data.screens = { + TitleState = { new = function(game) return { marker = true } end }, +} +local inst = Screens.push(sgame, "TitleState") +check(inst.marker == true, "registry record wins over the builtin") +check(inst.screenId == "TitleState", "push stamps screenId") +check(sgame.stack:top() == inst, "push lands the instance on the stack") + +Screens.invalidate() +sgame.data.screens = { TitleState = { new = function() error("boom") end } } +local fallback = Screens.push(sgame, "TitleState") +check(getmetatable(fallback) == require("src.ui.TitleState"), + "a throwing mod factory degrades to the builtin") +check(logged("mod screen 'TitleState' failed"), + "the failed factory is logged") + +-- ------- Screens: cache flush rides the Assets invalidation fan-out +Screens.invalidate() +sgame.data.screens = nil +local cached = Screens.get(sgame, "TitleState") +sgame.data.screens = { + TitleState = { new = function() return { modded = true } end }, +} +check(Screens.get(sgame, "TitleState") == cached, + "the factory cache holds between resolutions") +Assets.invalidate() +check(Screens.get(sgame, "TitleState") ~= cached, + "Assets.invalidate flushes the screens cache") +sgame.data.screens = nil +Screens.invalidate() + +-- ------- StateStack events +local events, hooks = Events.new(), Hooks.new() +local errors = {} +Runtime.install(events, hooks, errors) +StateStack:init() +local order = {} +events:on("screen.pushed", function(e) + order[#order + 1] = "pushed:" .. tostring(e.state.entered) +end, 0, "t") +events:on("screen.popped", function(e) + order[#order + 1] = "popped:" .. tostring(e.state.exited) +end, 0, "t") +local probe = {} +function probe:enter() self.entered = true end +function probe:exit() self.exited = true end +StateStack:push(probe) +StateStack:pop() +check(order[1] == "pushed:true", "screen.pushed fires after enter") +check(order[2] == "popped:true", "screen.popped fires after exit") + +local seenId +events:on("screen.pushed", function(e) seenId = e.state.screenId end, 0, "t") +Screens.push({ data = {}, stack = StateStack }, "TrainerCard") +check(seenId == "TrainerCard", "listeners match by screenId via Screens.push") +StateStack:pop() +events:removeOwner("t") +check(not Runtime.wants("screen.pushed"), + "no listeners left: the emit guard skips payload construction") +StateStack:push({}) -- no listener, no payload, no error +StateStack:pop() + +-- ------- ui.start_menu.items +local StartMenu = require("src.ui.StartMenu") +local function startGame() + return { + data = {}, + stack = newStack(), + input = newInput(), + save = { flags = { EVENT_GOT_POKEDEX = true }, party = { {} }, + player = { name = "RED" }, options = {}, + pokedex = { owned = {} } }, + } +end +local VANILLA_START = { "POKéDEX", "POKéMON", "ITEM", "RED", "SAVE", + "OPTION", "LINK", "QUIT" } +local menu = StartMenu.new(startGame()) +check(#menu.items == #VANILLA_START, "vanilla start menu row count") +for i, label in ipairs(VANILLA_START) do + check(menu.items[i].label == label, "vanilla start menu row " .. i) +end +check(menu.th == #menu.items * 2 + 2, "menu height derives from the item count") + +local gated = startGame() +gated.modStatus = { available = { { id = "m" } } } +menu = StartMenu.new(gated) +check(menu.items[#menu.items - 1].label == "MODS", + "pause-menu MODS entry appears once a mod is discovered") + +hooks:wrap("ui.start_menu.items", function(nextFn, game, items) + ModUI.insertBefore(items, "ITEM", { label = "QUESTS", + onSelect = function() end }) + return nextFn(game, items) +end, 0, "fixture") +menu = StartMenu.new(startGame()) +check(menu.items[3].label == "QUESTS" and menu.items[4].label == "ITEM", + "hook inserts a start-menu entry before its anchor") +hooks:removeOwner("fixture") + +hooks:wrap("ui.start_menu.items", function() return 42 end, 0, "bad") +menu = StartMenu.new(startGame()) +check(#menu.items == #VANILLA_START and menu.items[3].label == "ITEM", + "a non-table hook result degrades to the vanilla items") +check(logged("ui.start_menu.items returned"), "the degrade is logged") +hooks:removeOwner("bad") + +-- ------- ui.options.rows and the descriptor refactor +local OptionsMenu = require("src.ui.OptionsMenu") +local function optGame() + return { + data = { + rulesets = { + gen1_faithful = { name = "GEN 1" }, + modern_clean = { name = "MODERN" }, + secret = { name = "SECRET", hidden = true }, + }, + constants = {}, + }, + save = { options = {} }, + stack = newStack(), + input = newInput(), + modStatus = { available = {} }, + } +end +local om = OptionsMenu.new(optGame()) +local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset", + "musicVol", "sfxVol", "musicFilter", "colors", "tilt", + "gbcfx", "mods", "controls" } +check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") +for i, id in ipairs(WANT_IDS) do + check(om.rows[i].id == id, "options row order: " .. id) +end + +-- ruleset row cycles the sorted non-hidden registry ids showing name +om.game.save.options.ruleset = "gen1_faithful" +check(om.rows[4].value(om.game) == "GEN 1", "ruleset row shows record.name") +om.rows[4].step(om.game, 1) +check(om.game.save.options.ruleset == "modern_clean", + "ruleset row cycles sorted registry ids") +om.rows[4].step(om.game, 1) +check(om.game.save.options.ruleset == "gen1_faithful", + "hidden rulesets are excluded from the cycle") + +-- stepping parity with the old per-index ladder +om.rows[1].step(om.game, 1) +check(om.game.save.options.textSpeed == 5, "text speed MEDIUM steps to SLOW") +om.rows[1].step(om.game, 1) +check(om.game.save.options.textSpeed == 1, "then wraps to FAST") +om.rows[2].step(om.game, 1) +check(om.game.save.options.animations == false, "animations toggles off") +om.rows[3].step(om.game, 1) +check(om.game.save.options.battleStyle == "set", "battle style flips to SET") +om.rows[5].step(om.game, -1) +check(om.game.save.options.musicVol == 6, "music volume steps down") +for _ = 1, 10 do om.rows[5].step(om.game, -1) end +check(om.game.save.options.musicVol == 0, "music volume clamps at 0") + +-- the MODS row is the manager's discoverable home +local mgGame = optGame() +om = OptionsMenu.new(mgGame) +om.rows[11].activate(mgGame) +check(getmetatable(mgGame.stack:top()) == ManagerState, + "the MODS row opens the manager") +check(mgGame.stack:top().screenId == "ManagerState", + "the pushed manager carries its screen id") + +-- ------- the CONTROLS row and BindingsMenu (gap C2's file-12 half) +local BindingsMenu = require("src.ui.BindingsMenu") +local cbGame = optGame() +om = OptionsMenu.new(cbGame) +om.rows[12].activate(cbGame) +local bm = cbGame.stack:top() +check(getmetatable(bm) == BindingsMenu, + "the CONTROLS row opens the rebind list") +check(bm.screenId == "BindingsMenu", + "the pushed rebind screen carries its screen id") +check(#bm.items == 8, "one row per logical button") +check(bm.items[1].label == "UP" and bm.items[1].right == "UP" + and bm.items[5].label == "A" and bm.items[5].right == "Z" + and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE", + "with no rebind the rows mirror the fixed map") +check(cbGame.save.options.bindings == nil, + "opening the screen alone writes nothing") +check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil, + "no raw-input claim until a capture is armed") +press(bm, "a") +check(bm.capture == bm.items[1] and bm.onKeyPressed ~= nil, + "A on a row arms the capture") +local wroteOptions = false +function cbGame:writeOptions() wroteOptions = true end +bm:onKeyPressed("j") +check(cbGame.save.options.bindings.up.key == "j", + "a captured key lands in options.bindings") +check(bm.items[1].right == "J", "the row shows the new key") +check(wroteOptions, "a rebind persists through writeOptions") +check(bm.capture == nil and bm.onKeyPressed == nil + and bm.onGamepadPressed == nil, "the capture disarms after one input") +bm.index = 5 +press(bm, "a") +bm:onGamepadPressed("y") +check(cbGame.save.options.bindings.a.pad == "y", + "a captured pad button lands beside the key slot") +check(bm.items[5].right == "Z", "a pad rebind keeps the key column") +press(bm, "b") +check(#cbGame.stack.states == 0, "B closes the rebind screen") + +-- Game routes pad buttons to a capturing top state and nowhere else +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local gpGame = { stack = newStack() } +local sawPad +gpGame.stack:push({ onGamepadPressed = function(_, b) sawPad = b end }) +Game.gamepadpressed(gpGame, nil, "y") +check(sawPad == "y", "pad buttons reach a capturing top state") +Input:init() +gpGame.stack:pop() +Game.gamepadpressed(gpGame, nil, "a") +Input:step() +check(Input:wasPressed("a"), + "without a capturing state pad input still feeds the mapped path") + +local hookSawCancel = false +hooks:wrap("ui.options.rows", function(nextFn, game, rows) + for _, row in ipairs(rows) do + if row.label == "CANCEL" then hookSawCancel = true end + end + rows[#rows + 1] = { id = "quest_pace", label = "QUEST PACE", + value = function() return "OFF" end, + step = function() return true end } + return nextFn(game, rows) +end, 0, "fixture") +om = OptionsMenu.new(optGame()) +check(om.rows[#om.rows].id == "quest_pace", "hook appends an options row") +check(not hookSawCancel, "CANCEL is appended after the hook, unreachable") +hooks:removeOwner("fixture") + +hooks:wrap("ui.options.rows", function() return "nope" end, 0, "bad") +om = OptionsMenu.new(optGame()) +check(#om.rows == #WANT_IDS, "a non-table rows result keeps the vanilla rows") +hooks:removeOwner("bad") + +-- ------- ui.party.submenu +local PartyMenu = require("src.ui.PartyMenu") +local function partyGame() + return { + data = { pokemon = { PIKACHU = { name = "PIKACHU" } } }, + save = { + party = { { species = "PIKACHU", hp = 10, stats = { hp = 10 }, + level = 5, moves = { { id = "TACKLE" } } } }, + inventory = {}, options = {}, + }, + stack = newStack(), + input = newInput(), + } +end +local pgame = partyGame() +local pm = PartyMenu.new(pgame) +pm.game = pgame +pgame.stack:push(pm) +press(pm, "a") +check(pm.submenu and #pm.subItems == 2 + and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH", + "vanilla party submenu unchanged with no hooks") +pm.submenu = nil + +local ranWith +hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx) + table.insert(items, { label = "QUESTS", + onSelect = function(m) ranWith = m end }) + return nextFn(game, items, mon, ctx) +end, 0, "fixture") +press(pm, "a") +check(#pm.subItems == 3 and pm.subItems[3].label == "QUESTS", + "hook appends a party submenu entry") +pm.subIndex = 3 +press(pm, "a") +check(ranWith == pgame.save.party[1], + "an injected entry's onSelect runs with the focused mon") +check(not pm.submenu, "the submenu closes after an injected entry runs") +hooks:removeOwner("fixture") + +hooks:wrap("ui.party.submenu", function() return nil end, 0, "bad") +press(pm, "a") +check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list") +hooks:removeOwner("bad") +pm.submenu = nil + +-- ------- mod.ui helpers and theme defaults +local items = { { label = "A" }, { label = "B" } } +ModUI.insertAfter(items, "A", { label = "X" }) +check(items[2].label == "X", "insertAfter lands behind its anchor") +ModUI.insertBefore(items, "A", { label = "Y" }) +check(items[1].label == "Y", "insertBefore lands ahead of its anchor") +ModUI.removeLabel(items, "X") +check(#items == 3 and items[3].label == "B", "removeLabel drops the entry") +ModUI.insertBefore(items, "MISSING", { label = "Z" }) +check(items[#items].label == "Z", "a missing anchor appends") +check(ModUI.Menu == require("src.ui.Menu"), "mod.ui exposes the widgets") +check(ModUI.TextBox == require("src.render.TextBox"), + "mod.ui exposes TextBox") +check(type(ModUI.push) == "function", "mod.ui.push opens screens") + +check(Theme.cursor == 0xED and Theme.cursorHollow == 0xEC + and Theme.moreArrow == 0xEE, "theme defaults are the old literals") +check(Theme.choiceBox.tx == 0 and Theme.choiceBox.ty == 7 + and Theme.choiceBox.tw == 6 and Theme.choiceBox.th == 5, + "choice box geometry keeps its vanilla tiles") +Theme.load({ field = { theme = { cursor = 0xAA } } }) +check(Theme.cursor == 0xAA, "field.theme restyles the cursor glyph") +Theme.cursor = 0xED + +-- ------- branding reads from field.* +local TitleState = require("src.ui.TitleState") +local tgame = { data = { field = { title = { + cycleSpecies = { "MEW" }, music = "My_Song", copyrightText = "HELLO", +} }, pokemon = { MEW = {} } } } +local title = TitleState.new(tgame, {}) +check(#title.cycleSpecies == 1 and title.cycleSpecies[1] == "MEW", + "field.title.cycleSpecies replaces the literal list") +check(title.title.music == "My_Song", "field.title.music is read") +title = TitleState.new({ data = {} }, {}) +check(#title.cycleSpecies == 16 and title.cycleSpecies[1] == "CHARMANDER", + "no data keeps the vanilla cycle list") +check(title.logo and title.logo.path == "assets/logo/pokemon_logo.png", + "no data keeps the shipped logo") + +-- the importer seeds field.title with {path,width,height} descriptors +-- (data/generated/field.lua); they must load via their path, and the +-- file-12 plain-string shape must keep working +title = TitleState.new({ data = { field = { title = { + logo = { path = "assets/generated/title/pokemon_logo.png", + width = 128, height = 56 }, + version = { path = "assets/generated/title/red_version.png", + width = 80, height = 8 }, +} } } }, {}) +check(title.logo and title.logo.path + == "assets/generated/title/pokemon_logo.png", + "a {path} logo descriptor loads its image") +check(title.version and title.version.path + == "assets/generated/title/red_version.png", + "the importer's version descriptor feeds the ribbon") +title = TitleState.new({ data = { field = { title = { + logo = "mods/x/logo.png", versionRibbon = "mods/x/ribbon.png", +} } } }, {}) +check(title.logo and title.logo.path == "mods/x/logo.png", + "a plain-string logo path loads directly") +check(title.version and title.version.path == "mods/x/ribbon.png", + "versionRibbon wins as the file-12 patch key") +-- pin against the shipped data itself: a real boot must load the logo +-- art, never fall back to the ASCII placeholder +title = TitleState.new({ data = { field = dofile("data/generated/field.lua") } }, + {}) +check(title.logo and title.logo.path + == "assets/generated/title/pokemon_logo.png", + "the shipped field.title.logo loads its art") +check(title.version and title.version.path + == "assets/generated/title/red_version.png", + "the shipped version ribbon loads") + +local OakSpeech = require("src.ui.OakSpeech") +local ogame = { data = { + field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } }, + pokemon = { PIKACHU = {} }, trainers = {}, + constants = { playerNameLength = 10 }, +} } +local oak = OakSpeech.new(ogame, nil) +check(oak.demoSpecies == "PIKACHU", "field.oakSpeech.demoSpecies is read") +check(oak.nameLen == 10, "constants.playerNameLength caps the naming screen") +check(oak.cfg.music == "X_Song", "field.oakSpeech.music is read") +oak = OakSpeech.new({ data = {} }, nil) +check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7, + "no data keeps the vanilla speech values") + +local IntroMovie = require("src.ui.IntroMovie") +local introDone = false +local igame = { data = { field = { intro = { + studio = { card = "MY STUDIO", credit = "ME" }, skip = true, + music = "Alt_Battle", +} } }, stack = newStack(), input = newInput() } +local movie = IntroMovie.new(igame, function() introDone = true end) +check(movie.studio.card == "MY STUDIO" and movie.studio.credit == "ME", + "field.intro.studio strings are read") +check(movie.introCfg.music == "Alt_Battle", "field.intro.music is read") +igame.stack:push(movie) +movie:update(1 / 60) +check(introDone and #igame.stack.states == 0, + "field.intro.skip jumps straight past the movie") + +local Credits = require("src.ui.Credits") +local credits = Credits.new({ data = { field = { + credits = { music = "My_Credits" } } } }, nil, nil) +check(credits.music == "My_Credits", "field.credits.music is read") +credits = Credits.new({ data = {} }, nil, nil) +check(credits.music == "Music_Credits", "no data keeps the vanilla song") + +-- ------- ManagerState v2 +check(ManagerState.onKeyPressed == nil, + "the manager reads mapped input, not raw keys") +check(ManagerState.screenId == "ManagerState", + "the manager carries its screen id for the F10 toggle") + +local function manifest(id, over) + local m = { id = id, name = id:upper(), version = "1.0.0", + category = "OTHER", state = "loaded", enabled = true, + dependencySpecs = {}, conflictSpecs = {}, permissions = {}, + description = "a mod" } + for k, v in pairs(over or {}) do m[k] = v end + return m +end + +local function fakeLoader(available, loadErrors) + local loader = { optionSchemas = {}, modOptions = {}, + events = Events.new(), errors = loadErrors or {} } + function loader:status() + return { available = available, loaded = {}, errors = self.errors, + order = {} } + end + function loader:setEnabled(id, enabled) + for _, m in ipairs(available) do + if m.id == id then m.enabled = enabled end + end + return true + end + return loader +end + +local function managerGame(loader) + return { data = {}, stack = newStack(), input = newInput(), + save = { options = { mods = {} } }, mods = loader, + modStatus = loader:status() } +end + +-- resolveToggle: the table-driven dependency cases +local RT = ManagerState.resolveToggle +local rtMods = { + base = manifest("base"), + addon = manifest("addon", { dependencySpecs = { { id = "base" } } }), + rival = manifest("rival", { conflictSpecs = { { id = "base" } } }), + old = manifest("old", { game_version = ">=99.0.0" }), + strict = manifest("strict", + { dependencySpecs = { { id = "base", range = ">=2.0.0" } } }), + ghostly = manifest("ghostly", { dependencySpecs = { { id = "ghost" } } }), +} +local r = RT(rtMods, "base", false, { base = true }) +check(r.apply.base == false and #r.alsoDisable == 0 and #r.missing == 0, + "clean flip: no cascade") +r = RT(rtMods, "base", false, { base = true, addon = true }) +check(r.apply.base == false and r.apply.addon == false + and r.alsoDisable[1] == "addon", "disabling a dep cascades to dependents") +r = RT(rtMods, "addon", true, {}) +check(r.apply.addon == true and r.apply.base == true + and r.alsoEnable[1] == "base", "enabling pulls hard deps in") +r = RT(rtMods, "ghostly", true, {}) +check(r.missing[1] == "ghost", "a missing dep blocks") +r = RT(rtMods, "rival", true, { base = true }) +check(r.conflicts[1] == "base", "a co-enabled conflict blocks") +r = RT(rtMods, "old", true, {}) +check(#r.badVersion == 1 and r.badVersion[1].engine, + "an engine version mismatch blocks") +r = RT(rtMods, "strict", true, { base = true }) +check(#r.badVersion == 1 and r.badVersion[1].id == "base", + "a dep range mismatch blocks") + +-- errors are visible: glyph on the roster, message on the errors screen +local avail = { + manifest("badmod", { state = "failed", error = "boom" }), + manifest("okmod"), +} +local loader = fakeLoader(avail, { "badmod: boom" }) +local mgame = managerGame(loader) +-- production wiring: the runtime error feed is the loader's error list +Runtime.errors = loader.errors +local ms = ManagerState.new(mgame) +mgame.stack:push(ms) +local rows = ms:modRows() +check(rows[1].header and rows[1].label == "OTHER", + "categories are section headers") +check(rows[2].mod.id == "badmod" and rows[2].glyph == "!", + "an errored mod carries the ! glyph") +check(rows[3].mod.id == "okmod" and rows[3].glyph == " ", + "a healthy mod has a clear gutter") +local lines = ms:errorLines(nil) +check(lines[1]:find("badmod: boom", 1, true) ~= nil, + "loader errors finally render in the manager") +check(ms:errorLines(avail[1])[1]:find("FAILED: boom", 1, true) ~= nil, + "the per-mod error leads its own view") + +-- select stages a clean toggle; discard reverts it +check(ms.cursor == 2, "the cursor skips the category header") +press(ms, "select") +check(avail[1].enabled == false, "SELECT quick-toggles the focused mod") +check(ms:isStaged(avail[1]), "a flip against boot state is staged") +check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph") +check(mgame.save.options.mods.badmod == false, + "the live options table mirrors the flip") +check(ms.restartPending, "staged changes arm the apply screen") +ms:discardChanges() +check(avail[1].enabled == true and not ms.restartPending, + "discard restores the boot enable set") + +-- cascade dialog: disabling a dep asks before flipping both +local avail2 = { + manifest("base"), + manifest("addon", { dependencySpecs = { { id = "base" } } }), +} +local mgame2 = managerGame(fakeLoader(avail2)) +local ms2 = ManagerState.new(mgame2) +mgame2.stack:push(ms2) +ms2:beginToggle(ms2.byId.base) +check(ms2.overlay and ms2.overlay.kind == "confirm", + "a cascading toggle opens the consent dialog") +check(avail2[1].enabled and avail2[2].enabled, + "nothing flips before consent") +press(ms2, "a") -- YES +check(avail2[1].enabled == false and avail2[2].enabled == false, + "consent flips the whole closure") + +-- blocked dialog: a missing dep explains and refuses +local avail3 = { manifest("lonely", { enabled = false, state = "disabled", + dependencySpecs = { { id = "ghost" } } }) } +local mgame3 = managerGame(fakeLoader(avail3)) +local ms3 = ManagerState.new(mgame3) +mgame3.stack:push(ms3) +ms3:beginToggle(ms3.byId.lonely) +check(ms3.overlay and ms3.overlay.kind == "ok", "a blocked toggle explains") +check(ms3.overlay.lines[1] == "NEEDS ghost", "the dialog names the dep") +check(avail3[1].enabled == false, "a blocked toggle never flips") +press(ms3, "a") +check(ms3.overlay == nil, "the blocked dialog dismisses") + +-- options auto-UI: schema rows edit, persist, emit, reset +local schema = { + { key = "hardcore", label = "NUZLOCKE", type = "toggle", default = false }, + { key = "odds", label = "ODDS", type = "choice", + choices = { { "STD", "std" }, { "BOOST", "boosted" } }, default = "std" }, + { key = "startMoney", label = "START", type = "number", + min = 0, max = 9000, step = 1000, default = 3000 }, + { key = "tag", label = "RIVAL", type = "text", maxLen = 7, + default = "BLUE" }, + { bad = "row" }, +} +loader.optionSchemas.okmod = schema +local heardOpt +loader.events:on("mod.options_changed", function(e) heardOpt = e end, 0, "t") +ms.currentMod = ms.byId.okmod +ms:openOptions(ms.byId.okmod) +check(ms.screen == "options", "OPTIONS.. routes to the options screen") +check(#ms.optionRows == 5, "four typed rows plus RESET; malformed skipped") +check(loader.errors[#loader.errors]:find("options row skipped", 1, true), + "the malformed row lands in the error feed") +ms.optionRows[1].step(mgame, 1) +check(mgame.save.options.modOptions.okmod.hardcore == true, + "a toggle edit persists to options.modOptions") +check(loader.modOptions.okmod.hardcore == true, + "the live value is visible to mod.options:get") +check(heardOpt and heardOpt.mod == "okmod" and heardOpt.key == "hardcore" + and heardOpt.value == true, "mod.options_changed fires on edit") +check(ms.optionRows[1].value(mgame) == "ON", "the toggle renders its state") +ms.optionRows[2].step(mgame, 1) +check(loader.modOptions.okmod.odds == "boosted" + and ms.optionRows[2].value(mgame) == "BOOST", + "a choice edit cycles and renders its label") +ms.optionRows[3].step(mgame, -1) +check(loader.modOptions.okmod.startMoney == 2000, "a number edit steps") +for _ = 1, 5 do ms.optionRows[3].step(mgame, -1) end +check(loader.modOptions.okmod.startMoney == 0, "number edits clamp at min") +ms.optionRows[4].activate() +check(getmetatable(mgame.stack:top()) == require("src.ui.NamingScreen"), + "a text row opens the naming screen") +mgame.stack:top().onDone("REDD") +mgame.stack:pop() +check(loader.modOptions.okmod.tag == "REDD", "the typed text persists") +ms.optionRows[5].activate() +check(loader.modOptions.okmod.hardcore == false + and loader.modOptions.okmod.odds == "std" + and loader.modOptions.okmod.startMoney == 3000 + and loader.modOptions.okmod.tag == "BLUE", + "RESET DEFAULTS restores every schema default") +press(ms, "b") +check(ms.screen == "list", "B leaves the options screen") + +-- profiles: save, drift to ad-hoc, apply, rename, delete +ms.tab = 2 +ms:saveCurrentAs() +mgame.stack:top().onDone("EASY") +mgame.stack:pop() +local easy = ms:findProfile("EASY") +check(easy ~= nil and easy.enabled.badmod == true, + "SAVE CURRENT AS snapshots the enable set") +check(ms:optionsTable().activeProfile == "EASY", "the new profile is active") +ms:commitToggle({ okmod = false }) +check(ms:optionsTable().activeProfile == nil, + "an off-profile toggle reverts to the ad-hoc set") +ms:applyProfile(easy) +check(ms.byId.okmod.enabled == true + and ms:optionsTable().activeProfile == "EASY", + "applying a profile stages the flips back") +ms:renameProfile(easy) +mgame.stack:top().onDone("HARD") +mgame.stack:pop() +check(easy.name == "HARD" and ms:optionsTable().activeProfile == "HARD", + "rename keeps the active pointer") +ms:deleteProfile(easy) +check(ms:findProfile("HARD") == nil + and ms:optionsTable().activeProfile == nil, "delete clears the profile") + +-- permissions rows +local permy = manifest("permy", { permissions = { "network" } }) +local msP = ManagerState.new(managerGame(fakeLoader({ permy }))) +msP.game.stack:push(msP) +local prows = msP:permissionRows(permy) +check(prows[1].glyph == "!" and prows[1].label == "USES THE NETWORK", + "declared permissions render with risk glyphs") +check(msP:permissionRows(manifest("pure"))[1].label == "DATA & API ONLY", + "no permissions shows the synthetic clean row") + +-- safe mode: the banner rides Runtime.safeMode, never an option +Runtime.safeMode = true +local msS = ManagerState.new(mgame) +msS:enter() +check(msS.banner == "SAFE MODE - ALL MODS OFF", + "safe mode shows the recovery banner") +Runtime.safeMode = nil +local msN = ManagerState.new(mgame) +msN:enter() +check(msN.banner == nil, "no safe mode, no banner") + +-- empty roster shows the empty state and B closes the manager +local msE = ManagerState.new(managerGame(fakeLoader({}))) +msE.game.stack:push(msE) +check(msE:modRows()[1].label == "NO MODS INSTALLED", "empty-state row") +press(msE, "b") +check(#msE.game.stack.states == 0, "B on the roster closes the manager") + +-- ------- mod.ui through a loader-built api +-- the worked example in 12 6 does mod.ui.insertBefore / mod.ui.push / +-- mod.ui.Theme on the api the loader hands the entry chunk, so the facade +-- has to arrive wired there, not just exist as a module +local Loader = require("src.mods.Loader") +local uiFiles = { + ["mods/uikit/manifest.json"] = + '{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}', + ["mods/uikit/main.lua"] = "return function(mod) _G.MOD_UI_API = mod end", +} +local uiFs = { + read = function(path) return uiFiles[path] end, + getInfo = function(path) + if uiFiles[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(uiFiles) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not uiFiles[path] then return nil, "no file: " .. path end + return load(uiFiles[path], path) + end, + getDirectoryItems = function(path) + local seen, names = {}, {} + local prefix = path .. "/" + for key in pairs(uiFiles) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + names[#names + 1] = child + end + end + end + table.sort(names) + return names + end, +} +local uiLoader = Loader.new({ fs = uiFs }) +check(uiLoader:load({}) == true, "the uikit fixture loads clean") +local uiApi = _G.MOD_UI_API +_G.MOD_UI_API = nil +check(uiApi ~= nil, "the entry chunk received its api") +check(uiApi.ui == ModUI, "mod.ui is the toolkit facade") +check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module") +check(uiApi.ui.Menu == require("src.ui.Menu"), + "mod.ui widgets resolve through the loader-built api") +local uiItems = { { label = "ITEM" } } +uiApi.ui.insertBefore(uiItems, "ITEM", { label = "QUESTS" }) +check(uiItems[1].label == "QUESTS" and uiItems[2].label == "ITEM", + "mod.ui.insertBefore works as documented") +local uiGame = { data = {}, stack = newStack() } +local uiPushed = uiApi.ui.push(uiGame, "TrainerCard") +check(uiGame.stack:top() == uiPushed and uiPushed.screenId == "TrainerCard", + "mod.ui.push opens a screen from a loader-built api") + +Runtime.safeMode = savedSafeMode +Runtime.install(savedEvents, savedHooks, savedErrors) + +S.finish() diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua new file mode 100644 index 00000000..1dc32844 --- /dev/null +++ b/tests/mod_world_tests.lua @@ -0,0 +1,960 @@ +-- World & maps v2: the de-Kanto'd literals replayed against their old +-- values, authoring a new map/tileset/encounter table through the +-- registries, the encounter/palette/collision/warp hooks, the map events, +-- MapLoader invalidation, and the mod.world runtime services. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Collision = require("src.world.Collision") +local Data = require("src.core.Data") +local Encounter = require("src.world.Encounter") +local Events = require("src.mods.Events") +local FieldDefaults = require("src.world.FieldDefaults") +local Hooks = require("src.mods.Hooks") +local Loader = require("src.mods.Loader") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Merge = require("src.mods.Merge") +local OW = require("src.world.OverworldController") +local Registry = require("src.mods.Registry") +local Runtime = require("src.mods.Runtime") +local Schemas = require("src.mods.Schemas") +local Warp = require("src.world.Warp") +local WorldAPI = require("src.world.WorldAPI") + +local S = require("tests.harness").suite("world & maps v2") +local check = S.check + +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +-- the same upvalue rewire the parity suites use: OverworldState's methods +-- close over a module-local Game that only a real boot assigns +local function bindGame(fn, game) + local i = 1 + while true do + local name = debug.getupvalue(fn, i) + if not name then return false end + if name == "Game" then debug.setupvalue(fn, i, game) return true end + i = i + 1 + end +end + +-- ------- the literal-lift oracles + +-- paletteNameFor before this milestone, verbatim, as the oracle +local TOWN_PALS = { + PALLET_TOWN = "PALLET", VIRIDIAN_CITY = "VIRIDIAN", + PEWTER_CITY = "PEWTER", CERULEAN_CITY = "CERULEAN", + LAVENDER_TOWN = "LAVENDER", VERMILION_CITY = "VERMILION", + CELADON_CITY = "CELADON", FUCHSIA_CITY = "FUCHSIA", + CINNABAR_ISLAND = "CINNABAR", INDIGO_PLATEAU = "INDIGO", + SAFFRON_CITY = "SAFFRON", +} +local function oldPaletteNameFor(def, lastOutdoorId) + local ts, id = def.tileset, def.id + if ts == "CEMETERY" then return "GRAYMON" + elseif ts == "CAVERN" then return "CAVE" + elseif id == "LORELEIS_ROOM" then return "PALLET" + elseif id == "BRUNOS_ROOM" then return "CAVE" + elseif TOWN_PALS[id] or id:match("^ROUTE_") then + return TOWN_PALS[id] or "ROUTE" + end + local last = lastOutdoorId or "PALLET_TOWN" + return TOWN_PALS[last] or "ROUTE" +end + +do + local fakeGame = { data = Data } + check(bindGame(OW.paletteNameFor, fakeGame), "paletteNameFor binds Game") + -- every map, with no outdoor memory and with each of the outdoor maps + -- remembered, must land on the palette the literals used to pick + -- "" stands for no outdoor memory at all + local lasts = { "", "PALLET_TOWN", "ROUTE_1", "INDIGO_PLATEAU", "CELADON_CITY" } + local mapIds = {} + for id in pairs(Data.maps) do mapIds[#mapIds + 1] = id end + table.sort(mapIds) + local compared = 0 + for _, lastId in ipairs(lasts) do + local last = lastId ~= "" and { id = lastId } or nil + local state = setmetatable({ lastOutdoor = last }, { __index = OW }) + for _, id in ipairs(mapIds) do + local def = Data.maps[id] + local want = oldPaletteNameFor(def, last and last.id) + local got = state:paletteNameFor({ id = id, def = def }) + check(got == want, ("palette parity %s (last %s): got %s, want %s") + :format(id, tostring(last and last.id), tostring(got), tostring(want))) + compared = compared + 1 + end + end + check(compared >= #mapIds * 2, "palette oracle covered every map") +end + +do + -- Map:isWaterCell against the pre-change literals, over every cell of + -- the largest map of each tileset (SHIP_PORT's $32 exception included) + local byTileset, area = {}, {} + for id, def in pairs(Data.maps) do + local size = def.width * def.height + if size > (area[def.tileset] or -1) then + area[def.tileset], byTileset[def.tileset] = size, id + end + end + local cells = 0 + for tileset, mapId in pairs(byTileset) do + local map = MapLoader.load(Data, mapId) + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + local t = map:cellTile(cx, cy) + local want = t == 0x14 + or (tileset ~= "SHIP_PORT" and (t == 0x32 or t == 0x48)) + check(map:isWaterCell(cx, cy) == want, + ("water parity %s (%d,%d) tile %02x"):format(mapId, cx, cy, t)) + cells = cells + 1 + end + end + end + check(cells > 10000, "water oracle walked a real sample of cells") +end + +do + -- outdoor / outside / region / ghost / pushable, replayed per map + for id, def in pairs(Data.maps) do + check(Map.isOutdoor(def) == (def.tileset == "OVERWORLD"), + "outdoor parity " .. id) + check(Map.isOutside(def) == + (def.tileset == "OVERWORLD" or def.tileset == "PLATEAU"), + "outside parity " .. id) + check(Map.inRegion(def, "SAFARI", "SAFARI_ZONE") + == (id:find("SAFARI_ZONE", 1, true) == 1), "safari region parity " .. id) + local ghost = Map.ghostBattles(def) + check((ghost ~= nil) == (id:find("POKEMON_TOWER", 1, true) == 1), + "ghost battle parity " .. id) + for _, obj in ipairs(def.objects or {}) do + check(Map.isPushable(obj) == (obj.sprite == "SPRITE_BOULDER"), + "pushable parity " .. id) + end + end + -- the properties win over the fallbacks, which is how a new map opts in + check(Map.isOutdoor({ id = "X", tileset = "CAVERN", outdoor = true }), + "map.outdoor overrides the tileset fallback") + check(not Map.isOutdoor({ id = "X", tileset = "OVERWORLD", outdoor = false }), + "map.outdoor = false is honored, not treated as absent") + check(Map.inRegion({ id = "MY_ZONE", region = "SAFARI" }, "SAFARI", "SAFARI_ZONE"), + "map.region reaches the safari rules without a Kanto name") + check(not Map.inRegion({ id = "SAFARI_ZONE_X", region = "OTHER" }, "SAFARI", + "SAFARI_ZONE"), "an explicit region beats the id prefix") + check(Map.isPushable({ sprite = "SPRITE_OAK", pushable = true }), + "obj.pushable makes any sprite a boulder") +end + +do + -- the Route 22 Gate LAST_MAP rewrite, now a table + local rewrite = FieldDefaults.field(Data, "lastMapRewrites").ROUTE_22_GATE + for y = 0, 8 do + local want = y < 4 and "ROUTE_23" or "ROUTE_22" + check(OW.rewrittenLastMap(rewrite, 0, y) == want, + "last-map rewrite parity at y=" .. y) + end + -- ordered rules, first match wins, and the x axis works the same + local custom = { axis = "x", rules = { { below = 2, map = "A" }, + { atLeast = 6, map = "C" }, + { map = "B" } } } + check(OW.rewrittenLastMap(custom, 1, 0) == "A", "x-axis rewrite low") + check(OW.rewrittenLastMap(custom, 4, 0) == "B", "x-axis rewrite default row") + check(OW.rewrittenLastMap(custom, 7, 0) == "C", "x-axis rewrite atLeast") +end + +do + -- badge gates dispatch on the record's shape, not the map id, and the + -- Route 22 gate keeps its pre-v2 save flag spelling + check(FieldDefaults.fieldValue(Data, "badgeGates", "ROUTE_22_GATE", "passedFlag") + == "PASSED_ROUTE22_GATE", "the vanilla gate keeps its save flag name") + check(FieldDefaults.fieldValue(Data, "badgeGates", "MY_GATE", "passedFlag") + == nil, "a gate a mod adds falls through to PASSED_") + check(Data.field.badgeGates.ROUTE_22_GATE.coords ~= nil, + "the Route 22 gate record is the coords shape") + check(Data.field.badgeGates.ROUTE_23.guards ~= nil, + "the Route 23 record is the guards shape") +end + +do + -- the ledge rows keep their OVERWORLD-only reach until a row says otherwise + local rows = 0 + for _, ledge in ipairs(Data.field.ledges) do + check(ledge.tileset == nil, "vanilla ledge rows carry no tileset") + rows = rows + 1 + end + check(rows > 0, "ledge rows extracted") +end + +-- ------- encounter buckets + +do + check(FieldDefaults.constant(Data, "encounterBuckets")[10] == 256, + "encounterBuckets seeded, last bucket 256") + local seq, i = { 0, 20 }, 0 + local function rng() i = i + 1 return seq[i] end + -- rate 30 > 0 so the first draw bites; pick 20 lands in slot 1 (< 51) + local def = { grass = { rate = 30, slots = { + { species = "RATTATA", level = 3 }, { species = "PIDGEY", level = 5 } } } } + local enc = Encounter.roll(def, rng) + check(enc and enc.species == "RATTATA", "vanilla buckets pick slot 1") + -- a per-def bucket array of any length reshapes the odds + i = 0 + def.grass.buckets = { 10, 256 } + enc = Encounter.roll(def, rng) + check(enc and enc.species == "PIDGEY", "per-def buckets reshape the slot pick") +end + +-- ------- fixture + inline mods + +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local function manifestJson(id) + return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua","api":2}]]) + :format(id, id) +end + +-- a private slice of the real dataset: these tests merge into their data +local function fixture() + local maps = {} + for _, id in ipairs({ "PALLET_TOWN", "ROUTE_21", "ROUTE_22_GATE", + "SEAFOAM_ISLANDS_B4F", "VERMILION_GYM" }) do + maps[id] = Merge.deepCopy(Data.maps[id]) + end + local pokemon = {} + for _, id in ipairs({ "TANGELA", "MAGIKARP", "GOLDEEN", "POLIWAG" }) do + pokemon[id] = Merge.deepCopy(Data.pokemon[id]) + end + return { + maps = maps, + pokemon = pokemon, + tilesets = Merge.deepCopy(Data.tilesets), + items = Merge.deepCopy(Data.items), + moves = Merge.deepCopy(Data.moves), + sprites = Merge.deepCopy(Data.sprites), + encounters = {}, + constants = Merge.deepCopy(Data.constants), + field = FieldDefaults.seed({ field = Merge.deepCopy(Data.field) }).field, + } +end + +local function withMod(id, source) + local loader = Loader.new({ fs = memfs({ + ["mods/" .. id .. "/manifest.json"] = manifestJson(id), + ["mods/" .. id .. "/main.lua"] = source, + }) }) + local data = fixture() + local ok = loader:load(data) + return data, loader, ok +end + +-- ------- seeding is fill-only and idempotent + +do + local data = { field = { palettes = { byMap = { MY_TOWN = "MINE" } } }, + constants = { world = { stepFrames = 4 } } } + FieldDefaults.seed(data) + check(data.field.palettes.byMap.MY_TOWN == "MINE", + "seed never overwrites a stamped value") + check(data.field.palettes.byMap.PALLET_TOWN == "PALLET", + "seed fills the missing siblings of a stamped record") + check(data.constants.world.stepFrames == 4, "seed leaves a stamped constant") + check(data.constants.world.turnFrames == 2, "seed fills the missing siblings") + check(data.field.fishing.OLD_ROD.always.species == "MAGIKARP", + "seed installs the whole missing key") + local before = data.field.fishing + FieldDefaults.seed(data) + check(data.field.fishing == before, "seed is idempotent") + -- the vanilla dataset is untouched by any of this + check(Data.field.source ~= nil, "the real field table still loads") +end + +-- ------- authoring a new map, tileset, connection and encounter table + +local SABLE = [[ +return function(mod) + -- a mod-local atlas is just a path; this fixture borrows the vanilla + -- sheet so the headless renderer has real dimensions to quad up + mod.content.tilesets:register("SABLE_TILES", { + id = "SABLE_TILES", + image = "assets/generated/tilesets/overworld.png", + imageWidth = 128, imageHeight = 48, tilesPerRow = 16, + blocks = (function() + local blocks = {} + for b = 1, 4 do + local row = {} + -- block 0 is all grass, block 3 all water; 1 and 2 are spare + for i = 1, 16 do row[i] = (b == 1) and 0x01 or 0x14 end + blocks[b] = row + end + return blocks + end)(), + walkable = { 0x01 }, + waterTiles = { 0x14 }, + shoreTiles = {}, + grassTile = 0x01, + }) + mod.content.maps:register("SABLE_COVE", { + id = "SABLE_COVE", label = "SableCove", + tileset = "SABLE_TILES", width = 4, height = 4, + blocks = (function() + local b = {} + for i = 1, 16 do b[i] = (i <= 8) and 0 or 3 end + return b + end)(), + borderBlock = 3, + warps = {}, signs = {}, objects = {}, + connections = { north = { map = "ROUTE_21", offset = 0 } }, + outdoor = true, palette = "WATER", + }) + mod.content.maps:patch("ROUTE_21", { + connections = { south = { map = "SABLE_COVE", offset = 0 } }, + }) + mod.content.encounters:register("SABLE_COVE", { + grass = { rate = 200, slots = (function() + local s = {} + for i = 1, 10 do s[i] = { level = 22, species = "TANGELA" } end + return s + end)() }, + }) + mod.content.field:patch("hiddenItems", { + SABLE_COVE = { { x = 3, y = 3, item = "NUGGET" } }, + }) + mod.content.field:patch("flyWarps", { SABLE_COVE = { x = 2, y = 2 } }) + mod.content.field:patch("flyOrder", { __append = { "SABLE_COVE" } }) + mod.content.field:patch("townMap", { + locations = { SABLE_COVE = { x = 4, y = 17, name = "SABLE COVE" } }, + cursorOrder = { __append = { "SABLE_COVE" } }, + }) + mod.content.field:patch("palettes", { byMap = { SABLE_COVE = "WATER" } }) +end +]] + +do + local data, loader, ok = withMod("sable_cove", SABLE) + check(ok, "the new-map mod loads: " .. tostring((loader.errors or {})[1])) + check(data.maps.SABLE_COVE ~= nil, "the authored map reaches data.maps") + check(data.tilesets.SABLE_TILES ~= nil, "the authored tileset reaches data") + check(data.maps.ROUTE_21.connections.south.map == "SABLE_COVE", + "a connections patch lands on a base map") + check(data.maps.ROUTE_21.connections.north ~= nil, + "the base map keeps its other connections") + + -- the map builds and walks: MapLoader resolves both records lazily + MapLoader.invalidate("SABLE_COVE") + local cove = MapLoader.load(data, "SABLE_COVE") + check(cove.widthCells == 8 and cove.heightCells == 8, "authored map geometry") + check(cove:isWalkableCell(0, 0), "authored walkable tile") + check(cove:isGrassCell(0, 0), "authored grass tile") + check(cove:isWaterCell(0, 7), "authored water tile via tileset.waterTiles") + check(not cove:isWaterCell(0, 0), "authored land tile is not water") + check(Map.isOutdoor(cove.def), "the authored map declares itself outdoor") + + -- neighbors compose off the patched connection, both ways + local fromCove = OW.computeNeighbors(data.maps, "SABLE_COVE", 1) + check(#fromCove == 1 and fromCove[1].id == "ROUTE_21", + "the authored map connects north to Route 21") + local fromRoute = OW.computeNeighbors(data.maps, "ROUTE_21", 1) + local sawCove = false + for _, n in ipairs(fromRoute) do + if n.id == "SABLE_COVE" then sawCove = true end + end + check(sawCove, "Route 21 connects back south to the authored map") + + -- the encounter table rolls the authored species + local seq, i = { 0, 0 }, 0 + local enc = Encounter.roll(data.encounters.SABLE_COVE, + function() i = i + 1 return seq[i] or 0 end) + check(enc and enc.species == "TANGELA", "the authored encounter table rolls") + + -- the field patches added to Kanto instead of replacing it + check(data.field.hiddenItems.SABLE_COVE[1].item == "NUGGET", + "a map-dict field patch adds the mod's map") + check(data.field.hiddenItems.VIRIDIAN_FOREST ~= nil, + "the map-dict keeps every vanilla entry") + check(data.field.flyWarps.SABLE_COVE.x == 2, "a fly warp is one patch") + check(data.field.flyWarps.PALLET_TOWN ~= nil, "the vanilla fly warps survive") + check(data.field.flyOrder[#data.field.flyOrder] == "SABLE_COVE", + "__append puts the new map at the end of the fly order") + check(#data.field.flyOrder == #Data.field.flyOrder + 1, + "__append extends the list instead of replacing it") + check(data.field.townMap.locations.SABLE_COVE.name == "SABLE COVE", + "the town-map square is one patch") + check(data.field.townMap.locations.PALLET_TOWN ~= nil, + "the vanilla town-map squares survive") + check(data.field.palettes.byMap.SABLE_COVE == "WATER", + "a palettes patch adds a map") + check(data.field.palettes.byMap.PALLET_TOWN == "PALLET", + "the vanilla palette table survives the patch") + MapLoader.invalidate("SABLE_COVE") +end + +-- ------- town map and fly order as data + +do + local data = fixture() + data.field.flyOrder = { "PALLET_TOWN", "ROUTE_21", "PALLET_TOWN" } + data.maps.ROUTE_21.outdoor = false + local game = { data = data, save = { visited = { PALLET_TOWN = true, + ROUTE_21 = true } } } + local menu = require("src.ui.FlyMenu").new(game) + local labels = {} + for _, item in ipairs(menu.items or {}) do labels[#labels + 1] = item.value end + check(#labels == 1 and labels[1] == "PALLET_TOWN", + "the fly menu reads flyOrder, dedupes, and honors map.outdoor") +end + +-- ------- MapLoader invalidation + +do + local data = fixture() + local before = MapLoader.load(data, "PALLET_TOWN") + local rendererBefore = before.renderer + check(MapLoader.load(data, "PALLET_TOWN") == before, "the cache returns one instance") + check(MapLoader.cached("PALLET_TOWN") == before, "cached() sees it") + + -- an un-invalidated map keeps its instance even after its record changes + data.maps.PALLET_TOWN.blocks[1] = 0x0B + check(MapLoader.load(data, "PALLET_TOWN") == before, + "a record change alone does not reach a cached map") + + check(MapLoader.invalidate("PALLET_TOWN"), "invalidate reports the drop") + check(MapLoader.cached("PALLET_TOWN") == nil, "the entry is gone") + local after = MapLoader.load(data, "PALLET_TOWN") + check(after ~= before, "the next load builds a fresh Map") + check(after.renderer ~= rendererBefore, "and a fresh TileRenderer") + check(after:blockAt(0, 0) == 0x0B, "blockAt reflects the patched record") + check(not MapLoader.invalidate("NO_SUCH_MAP"), "invalidating a cold map is false") + MapLoader.invalidateAll() + check(MapLoader.cached("PALLET_TOWN") == nil, "invalidateAll clears everything") +end + +-- ------- hooks: empty chains are the vanilla path + +local function withBuses(fn) + local events, hooks = Events.new(), Hooks.new() + local prevE, prevH = Runtime.events, Runtime.hooks + Runtime.install(events, hooks) + local ok, err = pcall(fn, events, hooks) + Runtime.events, Runtime.hooks = prevE, prevH + if not ok then error(err, 0) end +end + +do + check(not Runtime.wantsHook("encounter.roll"), + "no chain means no encounter ctx is ever built") + check(not Runtime.wants("world.stepped"), + "no listener means no world.stepped payload is ever built") +end + +do + -- encounter.roll suppresses; encounter.species transforms; unhooked is vanilla + local data = fixture() + data.encounters.PALLET_TOWN = { grass = { rate = 255, slots = (function() + local s = {} + for i = 1, 10 do s[i] = { level = 5, species = "TANGELA" } end + return s + end)() } } + -- rollEncounter needs no Game: it reads the def it is handed and the map + -- id off self, which is what keeps the hot path free of a Data lookup + local state = setmetatable({ map = MapLoader.load(data, "PALLET_TOWN") }, + { __index = OW }) + + local vanilla = state:rollEncounter(data.encounters.PALLET_TOWN, "grass") + check(vanilla and vanilla.species == "TANGELA", + "with no wrapper the roll is the vanilla pick") + + withBuses(function(_, hooks) + hooks:wrap("encounter.roll", function() return nil end, 0, "suppressor") + local suppressed = 0 + for _ = 1, 200 do + if state:rollEncounter(data.encounters.PALLET_TOWN, "grass") == nil then + suppressed = suppressed + 1 + end + end + check(suppressed == 200, "an encounter.roll wrapper suppresses every encounter") + end) + + withBuses(function(_, hooks) + local sawCtx + hooks:wrap("encounter.species", function(next_, enc, ctx) + sawCtx = ctx + enc = next_(enc, ctx) + enc.species = "MAGIKARP" + return enc + end, 0, "transformer") + local enc = state:rollEncounter(data.encounters.PALLET_TOWN, "grass") + check(enc and enc.species == "MAGIKARP", + "an encounter.species wrapper transforms the roll") + check(sawCtx.mapId == "PALLET_TOWN" and sawCtx.terrain == "grass", + "the encounter ctx carries the documented fields") + end) + + -- the chain sees the def and may force a pick without calling next + withBuses(function(_, hooks) + hooks:wrap("encounter.roll", function() + return { species = "GOLDEEN", level = 40 } + end, 0, "forcer") + local enc = state:rollEncounter(nil, "water") + check(enc and enc.species == "GOLDEEN" and enc.level == 40, + "a wrapper that skips next forces its own encounter") + end) + MapLoader.invalidateAll() +end + +do + -- the rod tables are field.fishing now, and encounter.fishing wraps the + -- catch with the resolved pool in hand + local fishing = FieldDefaults.field(Data, "fishing") + check(fishing.OLD_ROD.always.species == "MAGIKARP", + "the Old Rod's fixed catch is data") + check(#fishing.GOOD_ROD.pool == 2 and fishing.GOOD_ROD.pool[1].species == "GOLDEEN", + "the Good Rod's pool is data") + check(fishing.SUPER_ROD.perMap == "superRod", + "the Super Rod points at the per-map field key") + check(Data.field.superRod ~= nil, "and that key is the extracted table") +end + +do + -- movement.collision: the verdict flips, the reason rides ctx + local map = MapLoader.load(Data, "PALLET_TOWN") + local mover = { cellX = 5, cellY = 6, facing = "up" } + local blocked, why = Collision.canMove(map, { mover }, mover, "left") + check(blocked == true, "the plaza step is legal with no wrapper") + withBuses(function(_, hooks) + local seen + hooks:wrap("movement.collision", function(next_, allowed, ctx) + seen = ctx + allowed = next_(allowed, ctx) + ctx.reason = "warded" + return false + end, 0, "warder") + local ok2, reason = Collision.canMove(map, { mover }, mover, "left") + check(ok2 == false and reason == "warded", + "a movement.collision wrapper blocks a legal step") + check(seen.map == map and seen.mover == mover and seen.dir == "left" + and seen.fromX == 5 and seen.fromY == 6 and seen.toX == 4 and seen.toY == 6, + "the collision ctx carries the documented fields") + end) + -- (4,4) is a house wall: the vanilla reason survives the unwrapped path + local walled = { cellX = 4, cellY = 5, facing = "up" } + blocked, why = Collision.canMove(map, { walled }, walled, "up") + check(blocked == false and why == "tile", "a walled step still reports 'tile'") +end + +do + -- warp.destination reroutes one door without owning the warp table + local warpDef = Data.maps.PALLET_TOWN.warps[1] + local m1 = Warp.destination(Data, warpDef) + withBuses(function(_, hooks) + hooks:wrap("warp.destination", function(next_, mapId, x, y, ctx) + check(ctx.warp == warpDef, "warp ctx carries the warp record") + local _, nx, ny = next_(mapId, x, y, ctx) + return "ROUTE_21", nx, ny + end, 0, "rerouter") + local m2, x2 = Warp.destination(Data, warpDef) + check(m2 == "ROUTE_21", "a warp.destination wrapper reroutes the door") + check(x2 ~= nil, "and the landing cell still resolves") + end) + check(Warp.destination(Data, warpDef) == m1, + "unwrapping restores the vanilla destination") +end + +do + -- map.palette recolors without touching the table + local state = setmetatable({}, { __index = OW }) + check(bindGame(OW.paletteNameFor, { data = Data }), "paletteNameFor rebinds") + local map = { id = "PALLET_TOWN", def = Data.maps.PALLET_TOWN } + check(state:paletteNameFor(map) == "PALLET", "vanilla palette unhooked") + withBuses(function(_, hooks) + hooks:wrap("map.palette", function(next_, name, m) + check(m == map, "the palette hook sees the map") + return next_(name, m) .. "_NIGHT" + end, 0, "night") + check(state:paletteNameFor(map) == "PALLET_NIGHT", + "a map.palette wrapper transforms the name") + end) +end + +-- ------- events + +do + local data = fixture() + withBuses(function(events) + local seen = {} + events:on("world.block_replaced", function(ev) seen.block = ev end, 0, "spy") + events:on("map.reloaded", function(ev) seen.reload = ev end, 0, "spy") + local map = MapLoader.load(data, "PALLET_TOWN") + local state = setmetatable({ map = map, neighbors = {} }, { __index = OW }) + state:replaceBlock(1, 1, 7) + check(seen.block and seen.block.mapId == "PALLET_TOWN" + and seen.block.bx == 1 and seen.block.by == 1 and seen.block.block == 7, + "world.block_replaced fires with the documented payload") + + check(bindGame(OW.reloadMap, { data = data }), "reloadMap binds Game") + state.map = nil -- not the active map: pure cache drop + state:reloadMap("ROUTE_21", "hot_reload") + check(seen.reload and seen.reload.mapId == "ROUTE_21" + and seen.reload.reason == "hot_reload", + "map.reloaded carries the reason") + end) + MapLoader.invalidateAll() +end + +-- ------- a live world, driven headlessly + +-- setMap and onStepComplete close over the same two module-locals a real +-- boot fills in; with both rewired the world runs without a Game:load +local function liveWorld(data) + local StateStack = require("src.core.StateStack") + local SaveData = require("src.core.SaveData") + require("src.render.Font").load(data) + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local game = { data = data, save = SaveData.newGame(), stack = stack, + input = { isDown = function() return false end }, + renderer = { worldViewSize = function() return 160, 144 end } } + check(bindGame(OW.setMap, game), "setMap binds Game") + local i = 1 + while true do + local name = debug.getupvalue(OW.setMap, i) + if not name then break end + if name == "mapScripts" then + debug.setupvalue(OW.setMap, i, require("data.scripts.init")) + end + i = i + 1 + end + local state = setmetatable({ camera = require("src.render.Camera").new(), + scriptMoves = {}, npcPool = {} }, + { __index = OW }) + stack.states[1] = state + game.overworld = state + return state, game +end + +do + local data = fixture() + data.maps = Merge.deepCopy(Data.maps) -- setMap walks the connection graph + data.encounters = Merge.deepCopy(Data.encounters) + data.audio = Data.audio + data.text = Data.text + data.font = Data.font + local state, game = liveWorld(data) + + withBuses(function(events) + local seen = {} + events:on("map.entered", function(ev) seen.entered = ev end, 0, "spy") + events:on("map.exited", function(ev) seen.exited = ev end, 0, "spy") + events:on("world.stepped", function(ev) seen.stepped = ev end, 0, "spy") + + state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" }) + check(seen.entered and seen.entered.mapId == "PALLET_TOWN" + and seen.entered.via == "boot" and seen.entered.fromMapId == nil + and seen.entered.map == state.map, + "map.entered fires at boot with the documented payload") + check(seen.exited == nil, "map.exited does not fire when no map was loaded") + + state:setMap("ROUTE_21", 5, 6, "down") + check(seen.exited and seen.exited.mapId == "PALLET_TOWN" + and seen.exited.toMapId == "ROUTE_21", + "map.exited names both sides of the change") + check(seen.entered.via == "warp" and seen.entered.fromMapId == "PALLET_TOWN", + "map.entered reports the previous map and how it was reached") + + state:setMap("PALLET_TOWN", 5, 6, "down", { seamless = true }) + check(seen.entered.via == "connection", "a seamless crossing reports 'connection'") + + pcall(state.onStepComplete, state) + check(seen.stepped and seen.stepped.mapId == "PALLET_TOWN" + and seen.stepped.x == 5 and seen.stepped.y == 6 + and type(seen.stepped.tile) == "number", + "world.stepped fires with mapId, cell and tile") + end) + + -- WorldAPI against the live world: invalidateMap reloads in place + withBuses(function(events) + local reloaded + events:on("map.reloaded", function(ev) reloaded = ev end, 0, "spy") + local api = WorldAPI.new(game, "tester") + local snapshot = api:current() + check(snapshot.mapId == "PALLET_TOWN" and snapshot.x == 5, + "current() finds the world through the stack marker") + + local before = MapLoader.cached("PALLET_TOWN") + local pool = state.npcPool + data.maps.PALLET_TOWN.blocks[1] = 0x0B + check(api:invalidateMap("PALLET_TOWN") == true, "invalidateMap succeeds") + check(reloaded and reloaded.mapId == "PALLET_TOWN" + and reloaded.reason == "invalidate", "map.reloaded fires for the caller") + check(MapLoader.cached("PALLET_TOWN") ~= before, "the map was rebuilt") + check(state.map:blockAt(0, 0) == 0x0B, "the reloaded map sees the new record") + check(state.player.cellX == 5 and state.player.cellY == 6, + "the player keeps its cell across the reload") + check(state.npcPool == pool, "the NPC pool identity survives the reload") + + -- warping through the facade lands the player on the new map + check(api:warpTo("ROUTE_21", 4, 4, "up") == true, "warpTo starts the warp") + check(state.transitioning == true, "and the world is mid-transition") + end) + + -- walking the authored map triggers a wild battle from its own table + do + local moddedData, _, loaded = withMod("sable_cove", SABLE) + check(loaded, "the authored map mod loads for the walk") + moddedData.maps.ROUTE_21 = Merge.deepCopy(Data.maps.ROUTE_21) + moddedData.audio, moddedData.text = data.audio, data.text + moddedData.font, moddedData.trainer_headers = data.font, {} + local walker, walkerGame = liveWorld(moddedData) + walkerGame.save.party = { require("src.pokemon.Pokemon").new(moddedData, + "TANGELA", 30) } + walker:setMap("SABLE_COVE", 0, 0, "down", { via = "boot" }) + check(walker.map:isGrassCell(0, 0), "the player stands in authored grass") + local caught + walker.pushBattle = function(_, battle) caught = battle end + -- rate 200/256 per step: no reseed, so the shared RNG stream the rest + -- of the runner depends on is left exactly where it was + for _ = 1, 200 do + pcall(walker.onStepComplete, walker) + if caught then break end + end + check(caught ~= nil, "a wild battle fires on the authored map") + check(caught.enemy and caught.enemy.mon + and caught.enemy.mon.species == "TANGELA", + "and it is the species the authored encounter table names") + + -- the same walk with an encounter.roll wrapper never starts a battle + withBuses(function(_, hooks) + hooks:wrap("encounter.roll", function() return nil end, 0, "nuzlocke") + caught = nil + for _ = 1, 1000 do + pcall(walker.onStepComplete, walker) + check(caught == nil, "encounter.roll suppression holds for the whole walk") + end + end) + MapLoader.invalidateAll() + end + + MapLoader.invalidateAll() +end + +-- ------- a map record that omits the optional subtables + +do + -- warps/signs/objects are all f.opt in the maps schema, so a record + -- authored without them has to survive the entered-map spawn loop and + -- the neighbor ghost loop, not just Map.new + local data = fixture() + data.maps = Merge.deepCopy(Data.maps) + data.encounters = Merge.deepCopy(Data.encounters) + data.audio, data.text, data.font = Data.audio, Data.text, Data.font + data.trainer_headers = {} + + local bare = Merge.deepCopy(Data.maps.ROUTE_21) + bare.id, bare.label = "BARE_COVE", "BareCove" + bare.warps, bare.signs, bare.objects = nil, nil, nil + bare.connections = { north = { map = "ROUTE_21", offset = 0 } } + data.maps.BARE_COVE = bare + data.maps.ROUTE_21.connections.south = { map = "BARE_COVE", offset = 0 } + + local state = liveWorld(data) + local ok, err = pcall(state.setMap, state, "BARE_COVE", 1, 1, "down", + { via = "boot" }) + check(ok, "entering a map with no objects table does not throw: " + .. tostring(err)) + check(#state.npcs == 0, "and it simply spawns no NPCs") + + -- the same record reached as a rendered neighbor of the active map + ok, err = pcall(state.setMap, state, "ROUTE_21", 5, 5, "down") + check(ok, "a neighbor with no objects table does not throw: " + .. tostring(err)) + local sawBare = false + for _, nb in ipairs(state.neighbors) do + if nb.map.id == "BARE_COVE" then sawBare = true end + end + check(sawBare, "and that neighbor really was in the drawn set") + + MapLoader.invalidateAll() +end + +-- ------- mod.world + +do + local api = WorldAPI.new({ data = Data, stack = { states = {} } }, "tester") + local value, err = api:current() + check(value == nil and err == "no overworld", "current() off the world") + value, err = api:warpTo("PALLET_TOWN", 5, 6) + check(value == nil and err == "no overworld", "warpTo() off the world") + value, err = api:replaceBlock(0, 0, 1) + check(value == nil and err == "no overworld", "replaceBlock() off the world") + value, err = api:spawnNpc("PALLET_TOWN", { sprite = "SPRITE_OAK" }) + check(value == nil and err == "no overworld", "spawnNpc() off the world") + value, err = api:npc("PALLET_TOWN", 1) + check(value == nil and err == "no overworld", "npc() off the world") + value, err = api:queueScript({}) + check(value == nil and err == "no overworld", "queueScript() off the world") +end + +do + local data = fixture() + local save = { flags = {}, objectToggles = {}, party = {} } + local map = MapLoader.load(data, "PALLET_TOWN") + local state = setmetatable({ map = map, npcs = {}, entities = {}, npcPool = {}, + neighbors = {}, player = { cellX = 5, cellY = 6, + facing = "down" } }, + { __index = OW }) + local game = { data = data, save = save, + stack = { states = { state } } } + check(bindGame(OW.addRuntimeObject, game), "addRuntimeObject binds Game") + local api = WorldAPI.new(game, "tester") + local other = WorldAPI.new(game, "intruder") + + local snapshot = api:current() + check(snapshot.mapId == "PALLET_TOWN" and snapshot.x == 5 and snapshot.y == 6 + and snapshot.facing == "down", "current() snapshots the live world") + + -- flags + check(api:setFlag("mod:tester:hello", true), "setFlag writes") + check(api:getFlag("mod:tester:hello") == true, "getFlag reads back") + check(save.flags["mod:tester:hello"] == true, "the flag lands in the save") + + -- object toggles emit and persist + withBuses(function(events) + local seen + events:on("world.object_toggled", function(ev) seen = ev end, 0, "spy") + -- an inactive map takes the plain save-write path + check(api:toggleObject("ROUTE_21", "SOMEONE", false), "toggleObject writes") + check(save.objectToggles.ROUTE_21.SOMEONE == false, "the toggle persists") + check(seen and seen.mapId == "ROUTE_21" and seen.objName == "SOMEONE" + and seen.visible == false, "world.object_toggled fires") + end) + + -- spawnNpc / removeNpc, with ownership enforced + local before = #data.maps.PALLET_TOWN.objects + local npcId = api:spawnNpc("PALLET_TOWN", + { x = 6, y = 6, sprite = "SPRITE_OAK", movement = "STAY", range = "DOWN" }) + check(type(npcId) == "string", "spawnNpc returns an id: " .. tostring(npcId)) + check(#data.maps.PALLET_TOWN.objects == before + 1, + "the runtime object joins the map record") + local spawned = data.maps.PALLET_TOWN.objects[before + 1] + check(spawned.runtime == true and spawned.owner == "tester", + "the runtime object records its owner") + check(#state.npcs == 1 and state.npcs[1].id == npcId, + "the NPC is instantiated on the active map") + check(#state.entities == 1, "and joins the collision entities") + + check(bindGame(OW.removeRuntimeObject, game), "removeRuntimeObject binds Game") + value, err = other:removeNpc(npcId) + check(value == nil and err:find("not owned", 1, true), + "removeNpc refuses another mod's object") + check(#data.maps.PALLET_TOWN.objects == before + 1, "and changes nothing") + check(api:removeNpc(npcId) == true, "the owner may remove it") + check(#data.maps.PALLET_TOWN.objects == before, "the record is clean again") + check(#state.npcs == 0 and #state.entities == 0, "and so is the live world") + + -- imported objects are never removable through this door + local importedId = "PALLET_TOWN_obj_" .. data.maps.PALLET_TOWN.objects[1].index + value, err = api:removeNpc(importedId) + check(value == nil and err:find("no runtime object", 1, true), + "removeNpc refuses an imported object") + + -- a handle onto a live NPC + npcId = api:spawnNpc("PALLET_TOWN", + { x = 7, y = 6, sprite = "SPRITE_OAK", movement = "STAY", range = "DOWN" }) + local handle = api:npc("PALLET_TOWN", npcId) + check(handle ~= nil, "npc() resolves a handle by id") + state.scriptMoves = {} + check(handle:face("left"), "the handle turns the NPC") + local hx, hy = handle:position() + check(hx == 7 and hy == 6, "the handle reports the NPC cell") + check(handle:scriptMove("left", 1), "the handle queues a scripted move") + check(#state.scriptMoves == 1, "and the move reached the queue") + api:removeNpc(npcId) + + -- spawnNpc on a map the dataset does not have + value, err = api:spawnNpc("NO_SUCH_MAP", { sprite = "SPRITE_OAK" }) + check(value == nil and err:find("unknown map", 1, true), + "spawnNpc refuses an unknown map") + value, err = api:warpTo("NO_SUCH_MAP", 0, 0) + check(value == nil and err:find("unknown map", 1, true), + "warpTo validates against the merged maps") + + -- invalidateMap on the live map keeps the player and the pool identity + check(bindGame(OW.reloadMap, game), "reloadMap rebinds") + check(bindGame(OW.setMap, game), "setMap binds Game") + check(bindGame(OW.healPoint, game), "healPoint binds Game") + MapLoader.invalidateAll() +end + +-- ------- no-mod parity of the seeded tables + +do + -- FieldDefaults never reaches into Data at require time + local before = Merge.deepCopy(Data.field.ledges) + local _ = FieldDefaults.field(Data, "palettes") + local same = true + for i, row in ipairs(Data.field.ledges) do + for k, v in pairs(row) do if before[i][k] ~= v then same = false end end + end + check(same, "reading a default never mutates the dataset") + check(Data.field.hiddenExtras.trashCans.map == "VERMILION_GYM", + "a key the importer stamps is left as the importer wrote it") +end + +-- ------- the boot path seeds, so a mod's patch folds over Kanto + +do + -- Data:load runs seedDefaults, which pulls these in. Without them the + -- registry's base for the key is nil and the first patch replaces Kanto + -- wholesale instead of merging into it. + check(Data.field.palettes.byMap.PALLET_TOWN == "PALLET", + "the boot path seeded field.palettes") + check(Data.field.playerSprites.walk == "SPRITE_RED", + "the boot path seeded field.playerSprites") + check(Data.field.badgeGates.ROUTE_22_GATE.passedFlag == "PASSED_ROUTE22_GATE", + "the boot path filled the gaps in a stamped key") + check(Data.constants.world.stepFrames == 16, + "the boot path seeded constants.world") + check(Data.constants.encounterBuckets[10] == 256, + "the boot path seeded constants.encounterBuckets") + + local registry = Registry.new("field", Schemas.REGISTRIES.field) + registry.base = function() return Data.field end + registry:patch("palettes", { byMap = { SABLE_COVE = "WATER" } }, "somemod") + local merged = registry:get("palettes") + check(merged.byMap.SABLE_COVE == "WATER", "a field patch lands") + check(merged.byMap.PALLET_TOWN == "PALLET", "and Kanto survives it") + check(merged.default == "ROUTE", "including the fallthrough") +end + +S.finish() diff --git a/tests/modkit/cases/link_desync.lua b/tests/modkit/cases/link_desync.lua new file mode 100644 index 00000000..2b5df589 --- /dev/null +++ b/tests/modkit/cases/link_desync.lua @@ -0,0 +1,205 @@ +-- T4: the link desync suite extended for modded battles +-- (21-testing-and-ci "link desync suite"). +-- +-- Three classes, all ROM-free against the fixture dataset: +-- +-- symmetric-mod the same mod on both sides changes battle math and +-- desyncs nothing -- the two simulations stay mirrored. +-- one-sided mod the handshake sees the fingerprint move and refuses +-- the battle, instead of letting it desync into a draw +-- that explains nothing. +-- extra bag a mod field and ppUps survive the wire, and a mon a +-- total conversion cannot rebuild is rejected by name +-- rather than silently substituted. +-- +-- The primary desync assertion is the mirrored final state (my mon's HP on +-- A equals A's mon as seen by B), not the per-turn hash table: LinkBattle +-- only records a hash on turns that reach its end-of-turn act, so the hash +-- table is sparse enough that "no mismatch" alone would pass vacuously. +-- Hashes are still compared where both sides recorded one. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Protocol = require("src.link.Protocol") +local Pokemon = require("src.pokemon.Pokemon") + +math.randomseed(4242) + +-- a mod that moves battle math: moves are link surface, so installing it +-- must move the fingerprint. accuracy is 0..100 and the effect has to +-- resolve against the move_effects registry -- a mod registration is +-- schema-checked even though the base dataset's own records are not. +local BATTLE_MOD = { + ["mods/fix_battle_mod/manifest.json"] = [[{ + "id": "fix_battle_mod", + "name": "Fixture Battle Mod", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "affects_link": true + }]], + ["mods/fix_battle_mod/main.lua"] = [[ + local mod = ... + mod.content.moves:register("FIX_MODMOVE", { + id = "FIX_MODMOVE", index = 90, name = "FIX MODMOVE", + effect = "BURN_SIDE_EFFECT1", + power = 60, type = "FIRE", accuracy = 100, pp = 20, + anim = { sound = 1, pitch = 0, tempo = 0 }, + }) + ]], +} + +local function loadBattleMod(data) + return T.sdk.loadMods({ "mods/fix_battle_mod" }, + { data = data, fs = T.sdk.memfs(BATTLE_MOD) }) +end + +-- ------- symmetric mod: identical installs stay in lockstep + +do + local dataA = T.fixtures.fresh() + local dataB = T.fixtures.fresh() + local runA = loadBattleMod(dataA) + T.eq(#runA.errors, 0, "the battle mod loads clean on side A") + T.check(dataA.moves.FIX_MODMOVE ~= nil, "the mod's move merged into side A") + runA.release() + + local runB = loadBattleMod(dataB) + T.eq(#runB.errors, 0, "the battle mod loads clean on side B") + T.check(dataB.moves.FIX_MODMOVE ~= nil, "the mod's move merged into side B") + runB.release() + + local Fingerprint = require("src.link.Fingerprint") + local mods = { { id = "fix_battle_mod", version = "1.0.0", affectsLink = true } } + T.eq(Fingerprint.compute(dataA, mods), Fingerprint.compute(dataB, mods), + "two identical modded installs agree on the fingerprint") + + -- and the battle really runs to a mirrored finish. dataA already + -- carries the engine's built-in records from the merge above: a second + -- load into the same table would re-register them and raise, so both + -- sides share this one dataset (as two installs of the same mod do). + T.link.prepare(dataA) + local gameA = T.link.fakeGame(dataA, { "FIXMON_A", "FIXMON_C" }, { name = "RED", level = 20 }) + local gameB = T.link.fakeGame(dataA, { "FIXMON_B", "FIXMON_A" }, { name = "BLUE", level = 20 }) + local result = T.link.lockstep(gameA, gameB, { maxFrames = 60000 }) + + T.check(result.completed, "the symmetric-mod lockstep battle completes on both sides") + T.check((result.resultA == "win" and result.resultB == "lose") + or (result.resultA == "lose" and result.resultB == "win") + or (result.resultA == "draw" and result.resultB == "draw"), + ("both simulations agree on the outcome (%s / %s)") + :format(tostring(result.resultA), tostring(result.resultB))) + + -- the dense check: each side's view of the same mon must match + T.eq(result.battleA.player.mon.hp, result.battleB.enemy.mon.hp, + "host mon HP identical on both sides") + T.eq(result.battleA.enemy.mon.hp, result.battleB.player.mon.hp, + "guest mon HP identical on both sides") + T.eq(result.battleA.player.mon.species, result.battleB.enemy.mon.species, + "host active species identical on both sides") + T.check(result.agreed, "no per-turn hash mismatch across the battle") + + -- the real party is untouched: link battles fight clamped copies + T.eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp, + "the real party is untouched by the link battle") +end + +-- ------- one-sided mod: the handshake must fail closed + +do + local plain = T.fixtures.fresh() + local plainRun = T.sdk.loadNone({ data = plain }) + + local modded = T.fixtures.fresh() + local moddedRun = loadBattleMod(modded) + T.eq(#moddedRun.errors, 0, "the one-sided install loads clean") + moddedRun.release() + + T.link.prepare(plain) + local gameA = T.link.fakeGame(plain, "FIXMON_A", { name = "RED" }) + local gameB = T.link.fakeGame(modded, "FIXMON_B", { name = "BLUE" }) + + -- Handshake.mods reads game.mods, so stand in the loaded set explicitly: + -- side B is the one carrying the mod + gameB.mods = { status = function() + return { loaded = { { id = "fix_battle_mod", version = "1.0.0", affects_link = true } } } + end } + + local shake = T.link.handshake(gameA, gameB, "battle", nil) + T.check(not shake.match, "a one-sided mod moves the fingerprint") + T.eq(shake.verdict, "subset", "the handshake grades a fingerprint mismatch as subset") + T.eq(shake.reason, "fingerprint_mismatch", "the mismatch is named, not generic") + T.eq(shake.battleAllowed, false, + "a subset verdict refuses the lockstep battle rather than desyncing into it") + T.eq(shake.tradeAllowed, true, "a subset verdict still permits a negotiated trade") + + -- the positive control: two identical sides must be allowed to battle, + -- or the assertion above would pass simply because nothing ever links + local twin = T.fixtures.fresh() + local twinRun = T.sdk.loadNone({ data = twin }) + local gameC = T.link.fakeGame(twin, "FIXMON_A", { name = "GREEN" }) + local clean = T.link.handshake(gameA, gameC, "battle", nil) + T.check(clean.match, "two unmodded sides agree on the fingerprint") + T.eq(clean.verdict, "full", "two unmodded sides grade as full compatibility") + T.eq(clean.battleAllowed, true, "two unmodded sides may battle") + twinRun.release() + plainRun.release() +end + +-- ------- extra bag, ppUps, and strict rejection + +do + local data = T.fixtures.fresh() + local run = T.sdk.loadNone({ data = data }) + + local mon = Pokemon.new(data, "FIXMON_A", 20) + mon.extra = { fix_mod_charge = 7, fix_mod_flag = true, fix_mod_name = "SODA" } + mon.moves[1].ppUps = 3 + + local packed = Protocol.packMon(mon) + T.check(packed.extra ~= nil, "the extra bag rides the wire") + T.eq(packed.extra.fix_mod_charge, 7, "a numeric mod field is packed") + T.eq(packed.extra.fix_mod_flag, true, "a boolean mod field is packed") + T.eq(packed.extra.fix_mod_name, "SODA", "a string mod field is packed") + T.eq(packed.moves[1].ppUps, 3, "ppUps are packed") + + local received = Protocol.unpackMon(data, packed) + T.check(received ~= nil, "the mon rebuilds on the far side") + T.eq(received.extra.fix_mod_charge, 7, "the mod field survives the round trip") + T.eq(received.extra.fix_mod_flag, true, "the boolean survives the round trip") + T.eq(received.extra.fix_mod_name, "SODA", "the string survives the round trip") + T.eq(received.moves[1].ppUps, 3, "ppUps survive the round trip") + T.eq(received.species, "FIXMON_A", "the species survives") + T.eq(received.level, 20, "the level survives") + + -- the extra bag is a copy, not a shared reference: a mod mutating its + -- own field must not reach back through the wire + received.extra.fix_mod_charge = 99 + T.eq(mon.extra.fix_mod_charge, 7, "the extra bag is copied, not aliased") + + -- strict rejection: a total conversion that never heard of this species + -- must say so rather than substitute a hard-coded fallback + local foreign = Protocol.packMon(mon) + foreign.species = "NOT_IN_THIS_DATASET" + local rebuilt, reason = Protocol.unpackMon(data, foreign, { strict = true }) + T.eq(rebuilt, nil, "an unknown species is rejected under strict") + T.check(reason ~= nil and tostring(reason):find("POK") ~= nil, + "the rejection names the species problem (got " .. tostring(reason) .. ")") + + -- and a mon whose moves the dataset does not have + local noMoves = Protocol.packMon(mon) + noMoves.moves = { { id = "NOT_A_MOVE_HERE", pp = 10 } } + local rebuilt2, reason2 = Protocol.unpackMon(data, noMoves, { strict = true }) + T.eq(rebuilt2, nil, "a mon with no shared moves is rejected under strict") + T.eq(reason2, "no shared moves", "the rejection names the move problem") + + -- without strict, the v1 path still substitutes rather than crashing + local lenient = Protocol.unpackMon(data, noMoves) + T.check(lenient ~= nil, "the non-strict path still rebuilds a mon") + T.check(#lenient.moves > 0, "the non-strict path gives the mon a usable move") + + run.release() +end + +T.finish("link_desync") diff --git a/tests/modkit/cases/mod_lifecycle.lua b/tests/modkit/cases/mod_lifecycle.lua new file mode 100644 index 00000000..57fefba3 --- /dev/null +++ b/tests/modkit/cases/mod_lifecycle.lua @@ -0,0 +1,140 @@ +-- T4: the mod lifecycle through the public API only +-- (21-testing-and-ci "the modkit test harness"). +-- +-- This is the case a mod author copies: synthesize (or point at) a mod, +-- load it headlessly against the fixture dataset, and assert on what +-- reached Data and the buses. Nothing here reaches into loader internals +-- that a mod could not reach itself. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +-- ------- a mod that exercises content, events, hooks, save and exports + +local GOOD = { + ["mods/fix_kitchen_sink/manifest.json"] = [[{ + "id": "fix_kitchen_sink", + "name": "Fixture Kitchen Sink", + "version": "2.1.0", + "entry": "main.lua", + "api": 2, + "description": "Exercises the public mod surface." + }]], + ["mods/fix_kitchen_sink/main.lua"] = [[ + local mod = ... + mod.content.items:register("FIX_SODA", { + id = "FIX_SODA", index = 90, name = "FIX SODA", price = 400, + tossable = true, + }) + -- patch an existing record instead of replacing it + mod.content.items:patch("FIX_POTION", { price = 250 }) + mod.events:on("game.ready", function(ev) mod.exports.sawReady = ev ~= nil end) + mod.hooks:wrap("catch.rate", function(nextFn, ctx) + local base = nextFn(ctx) + return base + end) + mod.save:set("charge", 3) + mod.exports.marker = "kitchen-sink" + ]], +} + +do + local data = T.fixtures.fresh() + local run = T.sdk.loadMods({ "mods/fix_kitchen_sink" }, + { data = data, fs = T.sdk.memfs(GOOD) }) + + T.eq(#run.errors, 0, "the mod loads with no errors (" .. tostring(run.errors[1]) .. ")") + local mod = run.mods.fix_kitchen_sink + T.check(mod ~= nil, "the loader discovered the mod by its manifest id") + T.eq(mod and mod.state, "loaded", "the mod reached the loaded state") + T.eq(mod and mod.manifest.version, "2.1.0", "the manifest version is read") + + -- content reached Data through the merge + T.check(data.items.FIX_SODA ~= nil, "a registered item merged into Data") + T.eq(data.items.FIX_SODA.price, 400, "the registered item kept its fields") + T.eq(data.items.FIX_SODA.tossable, true, "record fields survive the merge") + + -- patch is a deep merge over the base record, not a replacement + T.eq(data.items.FIX_POTION.price, 250, "patch overwrote the field it named") + T.eq(data.items.FIX_POTION.tossable, true, + "patch left the fields it did not name alone") + T.eq(data.items.FIX_POTION.name, "FIX POTION", "patch left the record's name alone") + + -- the hook is wrapped, and with exactly one link + local hooks = T.record.hooks(run.loader) + T.eq(hooks:depth("catch.rate"), 1, "the mod's hook is wrapped once") + T.eq(hooks:owners("catch.rate")[1], "fix_kitchen_sink", "the hook link is attributed to the mod") + + -- the event recorder sees what the engine emits + local rec = T.record.events(run.loader) + run.loader.events:emit("game.ready", { game = { marker = true } }) + T.eq(rec:count("game.ready"), 1, "the recorder captured the emit") + T.check(rec:first("game.ready").game ~= nil, "game.ready carries { game = Game }") + rec:stop() + + run.release() +end + +-- ------- a mod that throws in its entry chunk rolls back completely + +local BAD = { + ["mods/fix_broken/manifest.json"] = [[{ + "id": "fix_broken", + "name": "Fixture Broken", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_broken/main.lua"] = [[ + local mod = ... + mod.content.items:register("FIX_GHOST", { + id = "FIX_GHOST", index = 91, name = "FIX GHOST", price = 1, + }) + mod.events:on("game.ready", function() end) + error("entry chunk exploded") + ]], +} + +do + local data = T.fixtures.fresh() + local run = T.sdk.loadMods({ "mods/fix_broken" }, + { data = data, fs = T.sdk.memfs(BAD) }) + + T.check(#run.errors > 0, "a throwing entry chunk is reported as an error") + T.check(tostring(run.errors[1]):find("exploded", 1, true) ~= nil, + "the error names the failure (" .. tostring(run.errors[1]) .. ")") + T.eq(run.mods.fix_broken and run.mods.fix_broken.state, "failed", + "the mod is marked failed") + + -- rollback: neither its content nor its subscription survived + T.eq(data.items.FIX_GHOST, nil, "a failed mod's content is rolled back out of Data") + local hooks = T.record.hooks(run.loader) + T.eq(hooks:depth("catch.rate"), 0, "a failed mod leaves no hook links") + local listeners = run.loader.events.listeners["game.ready"] + T.eq(#(listeners or {}), 0, "a failed mod leaves no event listeners") + + -- and the engine still works: a failed mod is "not installed", not fatal + T.check(data.items.FIX_POTION ~= nil, "base content survives a failed mod") + + run.release() +end + +-- ------- the no-mod parity baseline for this same path + +do + local data = T.fixtures.fresh() + local run = T.sdk.loadNone({ data = data }) + T.eq(#run.errors, 0, "loading no mods produces no errors") + T.eq(next(run.mods), nil, "loading no mods discovers no mods") + T.eq(data.items.FIX_SODA, nil, "no mod means no mod content in Data") + T.eq(data.items.FIX_POTION.price, 300, "the base item keeps its unpatched price") + + local hooks = T.record.hooks(run.loader) + for _, name in ipairs(T.catalog.hooks()) do + T.eq(hooks:depth(name), 0, "no mod means an empty chain: " .. name) + end + run.release() +end + +T.finish("mod_lifecycle") diff --git a/tests/modkit/catalog.lua b/tests/modkit/catalog.lua new file mode 100644 index 00000000..5963ae4d --- /dev/null +++ b/tests/modkit/catalog.lua @@ -0,0 +1,106 @@ +-- The live extension-point catalog: every registry, every event name the +-- engine emits, every hook name it calls. +-- +-- Registries come from Schemas.REGISTRIES. Events and hooks are read back +-- out of the source rather than from a hand-kept list, because a hand-kept +-- list is exactly the thing that drifts -- adding a Runtime.emit and +-- forgetting the catalog entry is how a seam ships untested. The +-- parity-gate meta-test (tests/engine/gate_meta_coverage.lua) walks these +-- three sets, so a new seam is in the coverage requirement the moment its +-- call site exists. + +local Schemas = require("src.mods.Schemas") + +local Catalog = {} + +local function luaFilesUnder(dir) + local files = {} + -- -L follows symlinks: a checkout that symlinks src/ (worktrees, the + -- ROM-free CI probe) would otherwise scan nothing and hand every gate an + -- empty catalog to pass vacuously against + local pipe = io.popen("find -L " .. dir .. " -name '*.lua' -type f 2>/dev/null") + if not pipe then return files end + for line in pipe:lines() do + if line ~= "" then files[#files + 1] = line end + end + pipe:close() + table.sort(files) + return files +end + +local function scan(dirs, patterns) + local found = {} + for _, dir in ipairs(dirs) do + for _, path in ipairs(luaFilesUnder(dir)) do + local handle = io.open(path, "r") + if handle then + local body = handle:read("*a") + handle:close() + for _, pattern in ipairs(patterns) do + for name in body:gmatch(pattern) do + local list = found[name] or {} + found[name] = list + list[#list + 1] = path + end + end + end + end + end + return found +end + +local function sortedKeys(map) + local keys = {} + for key in pairs(map) do keys[#keys + 1] = key end + table.sort(keys) + return keys +end + +local registries, events, hooks, eventSites, hookSites + +function Catalog.registries() + if not registries then registries = sortedKeys(Schemas.REGISTRIES) end + return registries +end + +-- Runtime.emit is the engine's channel; a bare bus:emit inside src counts +-- too (the loader emits mods.loaded straight off its own bus) +function Catalog.events() + if not events then + eventSites = scan({ "src" }, { + 'Runtime%.emit%("([%w%._]+)"', + 'events:emit%("([%w%._]+)"', + }) + events = sortedKeys(eventSites) + end + return events +end + +function Catalog.hooks() + if not hooks then + hookSites = scan({ "src" }, { + 'Runtime%.call%("([%w%._]+)"', + 'hooks:call%("([%w%._]+)"', + }) + hooks = sortedKeys(hookSites) + end + return hooks +end + +function Catalog.eventSites(name) + Catalog.events() + return eventSites[name] or {} +end + +function Catalog.hookSites(name) + Catalog.hooks() + return hookSites[name] or {} +end + +-- mods may only emit under "mod.."; those are not engine seams and +-- carry no coverage requirement +function Catalog.isModEvent(name) + return name:sub(1, 4) == "mod." +end + +return Catalog diff --git a/tests/modkit/drivers.lua b/tests/modkit/drivers.lua new file mode 100644 index 00000000..0964bb02 --- /dev/null +++ b/tests/modkit/drivers.lua @@ -0,0 +1,17 @@ +-- The frame-driver helper kit, published as require-able SDK API +-- (21-testing-and-ci "golden screenshots"). +-- +-- tests/drivers/util.lua stays where it is: 22 committed driver scripts +-- reach it with dofile("tests/drivers/util.lua") and a POKEPORT_DRIVER +-- chunk runs before package.path is anyone's problem. This module is the +-- require-able face of the same table, so a mod's driver can say +-- local U = require("tests.modkit.drivers") +-- and get wait/tap/hold/shot/newGame/teleport with no path juggling. + +local ok, util = pcall(dofile, "tests/drivers/util.lua") +if not ok then + error("tests/modkit/drivers requires tests/drivers/util.lua (run from the repo root): " + .. tostring(util), 0) +end + +return util diff --git a/tests/modkit/fixtures.lua b/tests/modkit/fixtures.lua new file mode 100644 index 00000000..27cf2fc5 --- /dev/null +++ b/tests/modkit/fixtures.lua @@ -0,0 +1,49 @@ +-- Data-shaped view of tests/fixture_data (21-testing-and-ci "fixture +-- dataset"). The T2/T4/T5 tiers run against this instead of +-- data/generated/*, which is what makes them ROM-free and therefore +-- CI-runnable. +-- +-- The returned table carries its own module tables but inherits the Data +-- methods (resolveText/textEntry/trainerHeader/ensure) through __index, so +-- engine code that takes `data` as a parameter cannot tell it apart from a +-- real load. The Data singleton is never touched: a fixture test and a +-- content_red test can run in the same process without either seeing the +-- other's tables. + +local Data = require("src.core.Data") +local fixture = require("tests.fixture_data") + +local Fixtures = {} + +Fixtures.DIR = "tests/fixture_data" + +local cached + +-- a fresh dataset every call: the mod merge writes into the table it is +-- given, so a cached one would carry the previous case's registrations +function Fixtures.fresh() + local data = fixture.load() + setmetatable(data, { __index = Data }) + -- the same fill-if-absent pass a real Data:load runs before the mod + -- loader, so constants/field.boot defaults exist to be patched over + Data.seedDefaults(data) + return data +end + +-- idempotent handle for suites that only read; use fresh() when the case +-- loads mods +function Fixtures.load() + if not cached then cached = Fixtures.fresh() end + return cached +end + +-- the ids a fixture case can rely on, so a case reads a name instead of +-- re-deriving it from the tables +Fixtures.ids = { + species = { "FIXMON_A", "FIXMON_B", "FIXMON_C" }, + moves = { "FIX_TACKLE", "FIX_SCRATCH", "FIX_EMBERISH", "FIX_CUT" }, + maps = { "FIX_TOWN", "FIX_ROUTE" }, + tileset = "FIX_OUT", +} + +return Fixtures diff --git a/tests/modkit/init.lua b/tests/modkit/init.lua new file mode 100644 index 00000000..263fffcd --- /dev/null +++ b/tests/modkit/init.lua @@ -0,0 +1,57 @@ +-- The mod-SDK test harness (21-testing-and-ci "modkit test harness"). +-- +-- This is the whole public surface a mod's tests/ directory compiles +-- against, and the same code the engine's own T4 cases use. A mod author +-- with a checkout of the engine and no ROM writes: +-- +-- local T = require("tests.modkit") +-- local Data = T.fixtures.load() +-- local r = T.sdk.loadMod("mods/rare_soda", { data = Data }) +-- T.check(#r.errors == 0, "mod loads clean") +-- T.finish() +-- +-- Requiring this installs the love stub as the global `love`, because +-- everything below the fixture line touches it. The love-free T1 tier +-- requires tests.harness directly instead. + +if not package.path:find("./?/init.lua", 1, true) then + package.path = "./?.lua;./?/init.lua;" .. package.path +end + +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") + +local M = {} + +-- assertions and the exit contract come straight off the shared harness, +-- so a mod's suite and an engine suite report identically +M.harness = T +M.check, M.eq, M.neq = T.check, T.eq, T.neq +M.same, M.raises = T.same, T.raises +M.rng = T.rng +M.finish = function(label) return T.finish(label or "modkit") end + +function M.failures() return T.failures end + +M.love = _G.love +M.fs = require("tests.fs_io") +M.fixtures = require("tests.modkit.fixtures") +M.sdk = require("tests.modkit.sdk") +M.record = require("tests.modkit.record") +M.link = require("tests.modkit.link") +M.shots = require("tests.modkit.shots") +M.catalog = require("tests.modkit.catalog") + +-- drivers pull in tests/drivers/util.lua, which only makes sense inside a +-- real LOVE run; keep it lazy so a headless case never pays for it +setmetatable(M, { __index = function(_, key) + if key == "drivers" then + local drivers = require("tests.modkit.drivers") + rawset(M, "drivers", drivers) + return drivers + end + return nil +end }) + +return M diff --git a/tests/modkit/link.lua b/tests/modkit/link.lua new file mode 100644 index 00000000..01c2ffc3 --- /dev/null +++ b/tests/modkit/link.lua @@ -0,0 +1,131 @@ +-- Loopback + lockstep helpers (21-testing-and-ci "link desync suite"), +-- generalized out of the hand-rolled harness in tests/run_link_tests.lua +-- so a modded battle can be driven the same way an unmodded one is. +-- +-- Both sides run the whole engine locally off a shared seed; the only +-- thing crossing the wire is the turn's choice and a per-turn state hash. +-- If a mod changes battle math on one side only, the hashes diverge -- so +-- "the hashes agreed every turn" is the desync assertion, and the +-- handshake fingerprint is what is supposed to stop that battle starting. + +local Net = require("src.link.Net") +local Protocol = require("src.link.Protocol") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local Link = {} + +-- the 15-line stack pattern from tests/run_link_tests.lua:171-186 +function Link.fakeGame(data, leadSpecies, opts) + opts = opts or {} + local save = SaveData.newGame() + local level = opts.level or 50 + for _, species in ipairs(type(leadSpecies) == "table" and leadSpecies or { leadSpecies }) do + table.insert(save.party, Pokemon.new(data, species, level)) + end + if opts.name then save.player.name = opts.name end + + local stack = { list = {} } + function stack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function stack:pop() table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end + end + + local Input = require("src.core.Input") + return { data = data, input = Input, stack = stack, save = save } +end + +-- the engine bits a battle needs before any of this runs +function Link.prepare(data) + local Input = require("src.core.Input") + Input:init() + require("src.render.Font").load(data) + return Input +end + +-- run a full lockstep battle over a loopback pair, mashing A on both +-- sides, and report whether any turn's hashes disagreed +function Link.lockstep(gameA, gameB, opts) + opts = opts or {} + local LinkBattle = require("src.link.LinkBattle") + local Input = require("src.core.Input") + + local netA, netB = Net.loopbackPair() + local packedA = Protocol.packParty(gameA.save.party) + local packedB = Protocol.packParty(gameB.save.party) + local seed = opts.seed or 987654321 + + local battleA = LinkBattle.newHost(gameA, netA, { + myParty = packedA, theirParty = packedB, + theirName = gameB.save.player.name, seed = seed, + }) + local battleB = LinkBattle.newGuest(gameB, netB, { + myParty = packedB, theirParty = packedA, + theirName = gameA.save.player.name, seed = seed, + }) + + local resA, resB + battleA.onFinish = function(r) resA = r end + battleB.onFinish = function(r) resB = r end + gameA.stack:push(battleA) + gameB.stack:push(battleB) + + local guard, limit = 0, opts.maxFrames or 60000 + while (resA == nil or resB == nil) and guard < limit do + guard = guard + 1 + Input.pressed = { a = true } + gameA.stack:update(1 / 60) + gameB.stack:update(1 / 60) + end + + -- a turn present on both sides with different hashes is the desync the + -- suite exists to catch + local mismatch + for turn, hash in pairs(battleA.localHashes) do + local other = battleB.localHashes[turn] + if other and other ~= hash then mismatch = mismatch or turn end + end + + return { + battleA = battleA, battleB = battleB, + resultA = resA, resultB = resB, + frames = guard, completed = resA ~= nil and resB ~= nil, + desyncTurn = mismatch, + agreed = mismatch == nil, + } +end + +-- convenience: build both sides and run, for the common symmetric case +function Link.pair(data, leadA, leadB, opts) + opts = opts or {} + Link.prepare(data) + local gameA = Link.fakeGame(data, leadA, { name = opts.nameA or "RED", level = opts.level }) + local gameB = Link.fakeGame(data, leadB, { name = opts.nameB or "BLUE", level = opts.level }) + return gameA, gameB +end + +-- the two hellos the handshake compares. A one-sided mod moves the +-- fingerprint, which must land as "subset" -- and subset is not +-- battleAllowed, so the lockstep battle never starts instead of desyncing +-- into an unexplained draw. +function Link.handshake(gameA, gameB, modeA, modeB) + local Handshake = require("src.link.Handshake") + local helloA = Handshake.hello(gameA, modeA or "battle") + local helloB = Handshake.hello(gameB, modeB) + local verdict, reason = Handshake.checkCompat(helloA, helloB) + return { + helloA = helloA, helloB = helloB, + verdict = verdict, reason = reason, + match = helloA.fingerprint == helloB.fingerprint, + battleAllowed = Handshake.battleAllowed(verdict), + tradeAllowed = Handshake.tradeAllowed(verdict), + } +end + +return Link diff --git a/tests/modkit/record.lua b/tests/modkit/record.lua new file mode 100644 index 00000000..b573116b --- /dev/null +++ b/tests/modkit/record.lua @@ -0,0 +1,113 @@ +-- Recorders for the SDK harness (21-testing-and-ci "modkit test +-- harness"). A case asserts that a seam fired without standing up a live +-- battle or a real frame. +-- +-- The event recorder shadows emit on the bus *instance*; Events methods +-- come off the shared metatable, so assigning the field masks it and +-- clearing it restores the original with nothing left behind. Production +-- emit is untouched -- there is no recorder branch in the engine at all, +-- which is why this costs a mod-free boot exactly nothing. + +local Record = {} + +-- accepts a loader or a bare Events bus +local function busOf(target) + if target and target.events and target.events.emit then return target.events end + return target +end + +function Record.events(target, opts) + local bus = busOf(target) + assert(bus and bus.emit, "record.events needs a loader or an Events bus") + local only = opts and opts.only + local capture = { events = {} } + local original = bus.emit + + bus.emit = function(self, name, payload) + if not only or only == name or (type(only) == "table" and only[name]) then + capture.events[#capture.events + 1] = { name = name, payload = payload } + end + return original(self, name, payload) + end + + -- nil restores the metatable lookup rather than pinning a copy of emit + function capture:stop() bus.emit = nil end + function capture:clear() capture.events = {} end + + function capture:names() + local out = {} + for i, entry in ipairs(capture.events) do out[i] = entry.name end + return out + end + + function capture:count(name) + local n = 0 + for _, entry in ipairs(capture.events) do + if entry.name == name then n = n + 1 end + end + return n + end + + -- first payload emitted under `name`, the usual assertion target + function capture:first(name) + for _, entry in ipairs(capture.events) do + if entry.name == name then return entry.payload end + end + return nil + end + + function capture:saw(name) return capture:first(name) ~= nil or capture:count(name) > 0 end + + return capture +end + +-- the sanctioned draw-capture pattern from tests/parity_gbcfx.lua, lifted +-- so sprite/tileset mods can assert on what reached the screen +function Record.draw() + local original = love.graphics.draw + local capture = { draws = {} } + + love.graphics.draw = function(image, ...) + capture.draws[#capture.draws + 1] = { image = image, args = { ... } } + if original then return original(image, ...) end + end + + function capture:stop() love.graphics.draw = original end + function capture:clear() capture.draws = {} end + + -- draws whose image came from `path`; the love stub keeps the path on + -- the image it fabricates, so an asset override is observable + function capture:fromPath(path) + local out = {} + for _, entry in ipairs(capture.draws) do + local image = entry.image + if type(image) == "table" and image.path == path then out[#out + 1] = entry end + end + return out + end + + return capture +end + +-- hook-chain recorder: proves an empty chain stayed empty, or that exactly +-- the expected mod links are wrapped around a name +function Record.hooks(target) + local bus = (target and target.hooks) or target + assert(bus and bus.chains, "record.hooks needs a loader or a Hooks bus") + local capture = {} + + function capture:depth(name) + local chain = bus.chains[name] + return chain and #chain or 0 + end + + function capture:owners(name) + local out = {} + for _, entry in ipairs(bus.chains[name] or {}) do out[#out + 1] = entry.owner end + return out + end + + return capture +end + +return Record diff --git a/tests/modkit/sdk.lua b/tests/modkit/sdk.lua new file mode 100644 index 00000000..f492a48c --- /dev/null +++ b/tests/modkit/sdk.lua @@ -0,0 +1,165 @@ +-- Headless mod load/merge for the SDK harness (21-testing-and-ci "modkit +-- test harness"). A mod author with a checkout of the engine and no ROM +-- can load their mod, merge it into the fixture dataset, and assert on the +-- result -- the seam that makes `modkit test` possible. +-- +-- Loader:_discover hard-codes the root "mods", so loading exactly one mod +-- (or a mod that lives outside mods/) goes through an aliasing filesystem: +-- "mods/" is rewritten to the real directory and the listing of +-- "mods" is narrowed to the selected set. Everything else is the +-- production path -- same Loader, same validate, same topo-sort, same +-- merge -- so a green SDK test means the mod really loads in the game. + +local FsIo = require("tests.fs_io") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") + +local Sdk = {} + +local function basename(path) + return (tostring(path):gsub("/+$", ""):match("[^/]+$")) +end + +-- rewrite "mods/" and anything under it to the mod's real location, +-- and answer getDirectoryItems("mods") with just the selected aliases +local function aliasFs(inner, alias) + local fs = { root = inner.root } + + local function map(path) + if path == nil then return path end + for name, real in pairs(alias) do + local prefix = "mods/" .. name + if path == prefix then return real end + if path:sub(1, #prefix + 1) == prefix .. "/" then + return real .. path:sub(#prefix + 1) + end + end + return path + end + + function fs.read(path) return inner.read(map(path)) end + function fs.write(path, body) return inner.write(map(path), body) end + function fs.load(path) return inner.load(map(path)) end + + function fs.getInfo(path) + if path == "mods" then return { type = "directory" } end + return inner.getInfo(map(path)) + end + + function fs.getDirectoryItems(path) + if path == "mods" then + local names = {} + for name in pairs(alias) do names[#names + 1] = name end + table.sort(names) + return names + end + return inner.getDirectoryItems(map(path)) + end + + return fs +end + +-- flat path -> content filesystem, for cases that synthesize a mod rather +-- than committing one to disk +function Sdk.memfs(files) + local loadstr = loadstring or load + return { + read = function(path) return files[path] end, + write = function(path, body) files[path] = body return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return loadstr(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +-- Runtime is process-wide and Loader:load installs into it; a case that +-- forgets to put it back would leak its buses into the next suite +local saved + +function Sdk.captureRuntime() + saved = { events = Runtime.events, hooks = Runtime.hooks, errors = Runtime.errors } +end + +function Sdk.restoreRuntime() + if not saved then return end + Runtime.events, Runtime.hooks, Runtime.errors = saved.events, saved.hooks, saved.errors + Runtime.currentMod = nil + saved = nil +end + +-- opts.data the merge target (defaults to a fresh fixture dataset) +-- opts.fs override the filesystem entirely (e.g. Sdk.memfs) +-- opts.root repo root the real paths are relative to +-- opts.dev force the dev tripwire on +function Sdk.loadMods(paths, opts) + opts = opts or {} + local data = opts.data or require("tests.modkit.fixtures").fresh() + + local fs = opts.fs + if not fs then + local alias = {} + for _, path in ipairs(paths) do alias[basename(path)] = path end + fs = aliasFs(FsIo.new(opts.root or "."), alias) + end + + Sdk.captureRuntime() + local loader = Loader.new({ fs = fs, dev = opts.dev }) + local ok, err = pcall(loader.load, loader, data) + if not ok then + Sdk.restoreRuntime() + error(err, 0) + end + + local mods = {} + for _, path in ipairs(paths) do + for id, mod in pairs(loader.mods) do + if mod.path == path or basename(mod.path) == basename(path) then mods[id] = mod end + end + end + + return { + loader = loader, + data = data, + mods = mods, + errors = loader.errors, + -- release the buses; a case that wants them live calls keep() + release = function() Sdk.restoreRuntime() end, + } +end + +function Sdk.loadMod(path, opts) + local result = Sdk.loadMods({ path }, opts) + result.mod = select(2, next(result.mods)) + return result +end + +-- load nothing: the no-mod baseline every parity gate compares against +function Sdk.loadNone(opts) + return Sdk.loadMods({}, opts) +end + +return Sdk diff --git a/tests/modkit/shots.lua b/tests/modkit/shots.lua new file mode 100644 index 00000000..20a2969f --- /dev/null +++ b/tests/modkit/shots.lua @@ -0,0 +1,47 @@ +-- Golden-screenshot capture helper (21-testing-and-ci "golden +-- screenshots"). Only meaningful inside a real LOVE run under a driver: +-- main.lua:98-109 flushes game.capturePath to a PNG after the frame is +-- drawn, so capture is "set the path, yield two frames". +-- +-- The diffing lives in tools/compare_shots.py; this side only decides +-- where a shot goes, so a driver and CI agree on the filename without +-- either hard-coding a directory. + +local Shots = {} + +-- CI hands the run a scratch directory; a developer capturing locally gets +-- the same layout under the repo so --bless-shots can copy them across +Shots.DEFAULT_DIR = "tests/goldens/shots" + +function Shots.dir() + return os.getenv("SHOT_DIR") or Shots.DEFAULT_DIR +end + +function Shots.path(name) + local file = tostring(name):gsub("%.png$", "") + return Shots.dir() .. "/" .. file .. ".png" +end + +-- capture from inside a driver coroutine; `wait` is the driver kit's +-- frame-yield so the capture flushes before the driver moves on +function Shots.capture(game, name, wait) + game.capturePath = Shots.path(name) + if wait then wait(2) end + return game.capturePath +end + +-- The fixture-dataset shot list, named here rather than in the workflow so +-- adding a golden is a one-line change next to the driver that produces +-- it. Nothing captures these yet: a POKEPORT_DRIVER chunk is loaded after +-- main.lua has already booted the game, and src/core/Data.lua has no +-- POKEPORT_DATA_DIR branch, so no LOVE process can be pointed at the +-- fixture dataset. The list is the contract the driver will satisfy once +-- that override lands. +Shots.FIXTURE_SHOTS = { + "fixture_title", + "fixture_start_menu", + "fixture_battle_intro", + "fixture_mod_screen", +} + +return Shots diff --git a/tests/modkit_tests.lua b/tests/modkit_tests.lua new file mode 100644 index 00000000..3e1c137f --- /dev/null +++ b/tests/modkit_tests.lua @@ -0,0 +1,667 @@ +-- M13 developer tooling: the fixture dataset under the headless loader, +-- hot reload (teardown + re-merge + cache invalidation), the dev console +-- (repl, verbs, tracer), the quarantine report screen, and the modkit CLI +-- (scaffold / validate / lint). Self-contained like the sibling mod +-- suites: own bootstrap, assert-based checks, error() on failure. +package.path = "./?.lua;./?/init.lua;" .. package.path + +-- parity first: nothing before this suite may have dragged dev code in +assert(package.loaded["src.dev.HotReload"] == nil, + "no src/dev module loads without the dev hotkeys") +assert(package.loaded["src.dev.Console"] == nil, + "no console load without the dev hotkeys") + +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Assets = require("src.render.Assets") +local fixture = require("tests.fixture_data") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local savedErrors = Runtime.errors +local savedWants, savedWantsHook = Runtime.wants, Runtime.wantsHook + +local S = require("tests.harness").suite("modkit") +local check = S.check + +local function deepEqual(a, b) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + for k, v in pairs(a) do + if not deepEqual(v, b[k]) then return false end + end + for k in pairs(b) do + if a[k] == nil then return false end + end + return true +end + +local function memfs(files) + return { + read = function(path) return files[path] end, + write = function(path, body) files[path] = body return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return loadstring(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +-- ------- carried handoff: the data-driven move repair floor + +check(require("src.core.Data").constants.fallbackMove == "TACKLE", + "CONSTANT_DEFAULTS seeds fallbackMove = TACKLE") + +-- ------- fixture dataset: complete, ROM-free, loader-ready + +local data = fixture.load() +for _, name in ipairs(fixture.MODULES) do + check(type(data[name]) == "table", "fixture module present: " .. name) +end +check(data.pokemon.FIXMON_A.evolutions[1].species == "FIXMON_B", + "fixture evolution chain") +check(data.maps.FIX_TOWN.width * data.maps.FIX_TOWN.height + == #data.maps.FIX_TOWN.blocks, "fixture map blocks match dimensions") +for id, def in pairs(data.pokemon) do + check(def.spriteFront:find("tests/fixture_data/", 1, true) == 1, + "fixture sprite stays ROM-free: " .. id) + local handle = io.open(def.spriteFront, "rb") + check(handle ~= nil, "fixture sprite file exists: " .. def.spriteFront) + handle:close() +end + +-- the loader runs over the fixture with no love global and no ROM +local savedLove = love +love = nil +local fixtureOk, fixtureErr = pcall(function() + local files = { + ["mods/fix_mod/manifest.json"] = + [[{"id":"fix_mod","name":"Fix","version":"1.0.0","entry":"main.lua","api":2}]], + ["mods/fix_mod/main.lua"] = [[ +return function(mod) + mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } }) +end +]], + } + local headless = Loader.new({ fs = memfs(files) }) + local freshData = fixture.load() + check(headless:load(freshData) == true, "fixture loader run is clean") + check(freshData.pokemon.FIXMON_A.baseStats.speed == 99, + "fixture merge applies the patch") + check(freshData.pokemon.FIXMON_A.baseStats.hp == 45, + "fixture merge keeps unpatched fields") +end) +love = savedLove +if not fixtureOk then error(fixtureErr) end +love = love or require("tests.love_stub") + +-- ------- hot reload: edit -> F5 -> live change, pristine base, caches flushed + +local hotFiles = { + ["mods/hot_mod/manifest.json"] = + [[{"id":"hot_mod","name":"Hot","version":"1.0.0","entry":"main.lua","api":2}]], + ["mods/hot_mod/main.lua"] = [[ +return function(mod) + mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } }) + mod.events:on("game.ready", function() + _G.MODKIT_TEST_READY = (_G.MODKIT_TEST_READY or 0) + 1 + end) +end +]], +} +local hotFs = memfs(hotFiles) + +-- a Data-shaped table whose reloadGenerated rebuilds from the fixture, the +-- same restore-to-pristine contract src/core/Data.lua implements for the +-- generated cache +local function freshHotData() + local d = fixture.load() + function d:reloadGenerated() + local pristine = fixture.load() + for key in pairs(self) do + if key ~= "reloadGenerated" then self[key] = nil end + end + for key, value in pairs(pristine) do self[key] = value end + end + return d +end + +_G.MODKIT_TEST_READY = 0 +local hotData = freshHotData() +local game = { data = hotData, save = { modData = {} } } +local bootLoader = Loader.new({ fs = hotFs }) +bootLoader.game = game +game.mods = bootLoader +check(bootLoader:load(hotData) == true, "hot-reload boot load is clean") +game.modStatus = bootLoader:status() +check(hotData.pokemon.FIXMON_A.baseStats.speed == 99, "boot merge applied") + +-- content froze at boot; the reload path must still work because it swaps +-- in a fresh loader instead of writing into the frozen registries +local frozen = pcall(function() + bootLoader.content.pokemon:patch("FIXMON_A", { baseStats = { hp = 1 } }, "x") +end) +check(frozen == false, "boot registries are frozen") + +local flushed = 0 +Assets.register(function() flushed = flushed + 1 end) + +-- the edit: same mod file, new value +hotFiles["mods/hot_mod/main.lua"] = hotFiles["mods/hot_mod/main.lua"] + :gsub("speed = 99", "speed = 123") + +-- the audio caches ride the same bus (20 §2, audio rows): a cached sfx +-- source and the chip music state must not survive the flush +local Sound = require("src.core.Sound") +local ChipAudio = require("src.core.ChipAudio") +local savedAudio = love.audio +local sourcesMade = 0 +love.audio = { newSource = function() + sourcesMade = sourcesMade + 1 + local src = {} + function src:play() self.playing = true end + function src:stop() self.playing = false end + function src:setVolume() end + function src:isPlaying() return self.playing end + return src +end } +local beepData = { audio = { sfx = { Fix_Beep = "assets/fix_beep.wav" } } } +Sound.play(beepData, "Fix_Beep") +Sound.play(beepData, "Fix_Beep") +check(sourcesMade == 1, "a played sfx source is cached") +-- invalidate reaches stopMusic through the module table, so a swap here +-- observes the bus call without touching ChipAudio internals +local savedStopMusic = ChipAudio.stopMusic +local musicStops = 0 +ChipAudio.stopMusic = function() musicStops = musicStops + 1 end + +local HotReload = require("src.dev.HotReload") +local reloaded, summary = HotReload.run(game, { fs = hotFs }) +check(reloaded ~= bootLoader, "reload builds a fresh loader") +check(game.mods == reloaded, "game adopts the fresh loader") +check(hotData.pokemon.FIXMON_A.baseStats.speed == 123, + "edited value is live after reload") +check(hotData.pokemon.FIXMON_A.baseStats.hp == 45, + "unedited field survives reload") +check(deepEqual(hotData.pokemon.FIXMON_B, fixture.load().pokemon.FIXMON_B), + "untouched base record is byte-identical after reload") +check(flushed >= 1, "reload flushed the registered caches") +check(summary:find("reloaded 1 mods", 1, true) ~= nil, "reload summary counts") +check(_G.MODKIT_TEST_READY >= 1, "game.ready re-reaches re-subscribed mods") +ChipAudio.stopMusic = savedStopMusic +check(musicStops >= 1, "reload stops chip music through the cache bus") +Sound.play(beepData, "Fix_Beep") +check(sourcesMade == 2, "reload evicted the cached sfx source") +Sound.invalidate() +love.audio = savedAudio + +-- reload twice: invalidate is idempotent, the merge converges +local again = HotReload.run(game, { fs = hotFs }) +check(hotData.pokemon.FIXMON_A.baseStats.speed == 123, "second reload converges") +check(#again.errors == 0, "second reload is clean") + +-- a broken edit surfaces as an attributed error, not a crash +hotFiles["mods/hot_mod/main.lua"] = "return function(mod) error('boom') end" +local broken = HotReload.run(game, { fs = hotFs }) +check(#broken.errors > 0, "broken edit lands in the error feed") +check(hotData.pokemon.FIXMON_A.baseStats.speed == 45, + "broken mod rolls back to pristine base") +_G.MODKIT_TEST_READY = nil + +-- ------- dev console: repl, verbs, tracer, input isolation + +local Console = require("src.dev.Console") +local popped = 0 +local stubStack = { states = {} } +function stubStack:top() return self.states[#self.states] end +function stubStack:pop() + popped = popped + 1 + return table.remove(self.states) +end +function stubStack:push(state) table.insert(self.states, state) end + +local inputMarker = {} +local consoleGame = { + data = hotData, + mods = game.mods, + modStatus = game.mods:status(), + save = { flags = {}, party = {}, inventory = {}, modData = {} }, + stack = stubStack, + input = { marker = inputMarker, + wasPressed = function() return false end }, +} +local console = Console.new(consoleGame) + +local function lastLine() + return console.lines[#console.lines] +end + +-- console output wraps to the canvas width, so recent-line checks scan a +-- window instead of the tail chunk alone +local function sawRecent(text) + for i = math.max(1, #console.lines - 7), #console.lines do + if console.lines[i]:find(text, 1, true) then return true end + end + return false +end + +console:exec("1+1") +check(lastLine() == "2", "repl evaluates expressions") +console:exec("data.pokemon.FIXMON_A.baseStats.hp") +check(lastLine() == "45", "repl reads live data") +console:exec("nosuchfunction()") +check(sawRecent("error"), "repl reports errors") + +console:exec("flag TEST_FLAG on") +check(consoleGame.save.flags.TEST_FLAG == true, "flag verb sets") +console:exec("flag TEST_FLAG off") +check(consoleGame.save.flags.TEST_FLAG == nil, "flag verb clears") + +console:exec("give FIX_POTION 3") +check(consoleGame.save.inventory.FIX_POTION == 3, "give verb adds items") +console:exec("give FIXMON_C 7") +check(consoleGame.save.party[1] and consoleGame.save.party[1].species == "FIXMON_C" + and consoleGame.save.party[1].level == 7, "give verb builds a party mon") + +console:exec("mods") +local sawMod = false +for _, line in ipairs(console.lines) do + if line:find("hot_mod", 1, true) then sawMod = true end +end +check(sawMod, "mods verb lists the loaded set") + +-- tracer: events log with payloads, hooks log in -> out, wants widens +console:exec("trace fix.*") +Runtime.emit("fix.ping", { n = 7 }) +check(sawRecent("fix.ping"), "tracer logs a matching event") +check(Runtime.wants("fix.anything") == true, "tracer widens wants()") +check(Runtime.wants("battle.unrelated") == false, + "tracer leaves other names alone") +local hooked = Runtime.call("fix.hook", function(v) return v + 1 end, 2) +check(hooked == 3, "traced hook still returns the vanilla value") +local sawOut = false +for _, line in ipairs(console.lines) do + if line:find("fix.hook", 1, true) and line:find("out", 1, true) then + sawOut = true + end +end +check(sawOut, "tracer logs the hook transformation") +Runtime.emit("mod.other.event", { x = 1 }) +console:exec("trace off") +check(Runtime.wants("fix.anything") == false, "trace off restores wants()") + +-- typed input: keys become buffer text, return executes, backtick closes +console:onKeyPressed("p") +console:onKeyPressed("p") +check(console.buffer == "pp", "letter keys append to the buffer") +console:onKeyPressed("backspace") +check(console.buffer == "p", "backspace edits the buffer") +console.buffer = "1+2" +console:onKeyPressed("return") +check(lastLine() == "3", "return executes the buffer") +console:onKeyPressed("up") +check(console.buffer == "1+2", "history recall") +console:onKeyPressed("`") +check(popped == 1, "backtick closes the console") +check(consoleGame.input.marker == inputMarker + and consoleGame.input.state == nil, + "console leaves game input untouched") + +-- ------- quarantine report screen + +local QuarantineReport = require("src.ui.QuarantineReport") +local report = { + lostMons = { { species = "ZORUA", from = "party" } }, + lostItems = { { id = "MOD_ITEM", count = 3, from = "inventory" } }, + remappedMaps = { { id = "MOD_TOWN", to = "PALLET_TOWN", field = "player" } }, + restoredMons = { { species = "MEWTHREE", box = 2 } }, + restoredItems = {}, + recovered = "bak", + modsDiff = { added = {}, removed = { "illusion_pack" }, changed = {} }, +} +local rgame = { + save = { meta = { mods = { { id = "illusion_pack", version = "1.1.0" } } } }, + stack = stubStack, + input = { wasPressed = function(_, btn) return btn == "a" end }, +} +local screen = QuarantineReport.new(rgame, report) +local blob = table.concat(screen.lines, "\n") +check(blob:find("ZORUA", 1, true) ~= nil, "report names the lost mon") +check(blob:find("MOD_ITEM x3", 1, true) ~= nil, "report names the lost item") +check(blob:find("MOD_TOWN", 1, true) ~= nil, "report names the remapped map") +check(blob:find("MEWTHREE", 1, true) ~= nil, "report names the restored mon") +check(blob:find("bak", 1, true) ~= nil, "report notes the backup recovery") +check(blob:find("no longer active", 1, true) ~= nil, "report carries the mods diff") +local before = popped +screen:update() +check(popped == before + 1, "CONTINUE pops the report screen") +-- draw must not throw under the stub (Font pages already loaded upstream) +local drawOk = pcall(function() screen:draw() end) +check(drawOk, "report draws headless") + +-- an empty report never builds content rows +local empty = QuarantineReport.new(rgame, { lostMons = {}, lostItems = {}, + remappedMaps = {}, restoredMons = {}, restoredItems = {} }) +check(#empty.lines == 0, "empty report renders no rows") + +-- ------- modkit CLI: scaffold -> validate green, lint gate red + +-- luajit's pclose drops the exit status, so the shell reports it in-band +local function run(command) + local pipe = io.popen(command .. ' 2>&1; echo "EXIT:$?"') + local output = pipe:read("*a") + pipe:close() + local code = tonumber(output:match("EXIT:(%d+)%s*$")) or -1 + return output, code +end + +local python = "python3" +local haveTools = run(python .. " --version") +check(haveTools:find("Python 3", 1, true) ~= nil, "python3 available for modkit") + +local tmp = os.tmpname() +os.remove(tmp) +local root = tmp .. "_modkit" +check(os.execute(("mkdir -p %q"):format(root)) == 0 + or os.execute(("mkdir -p %q"):format(root)) == true, "scratch dir") + +local out, code = run(("%s tools/modkit.py scaffold scaffy --dest %q") + :format(python, root)) +check(code == 0, "modkit scaffold succeeds: " .. out) +local manifest = io.open(root .. "/scaffy/manifest.json", "rb") +check(manifest ~= nil, "scaffold writes a manifest") +manifest:close() + +out, code = run(("%s tools/modkit.py validate %q") + :format(python, root .. "/scaffy")) +check(code == 0, "scaffolded mod validates clean: " .. out) + +-- the template patches MEW, which only the player's imported dataset carries. +-- Against that dataset MK103 resolves it; against the three-species fixture +-- the rule has no evidence either way, so it reports itself skipped rather +-- than guessing -- a warning there would be fatal under pack and --strict +local haveImported = io.open("data/generated/pokemon.lua", "rb") +if haveImported then + haveImported:close() + out, code = run(("%s tools/modkit.py validate %q --base imported") + :format(python, root .. "/scaffy")) + check(code == 0, "template validates against the imported dataset: " .. out) + check(out:find("MK103", 1, true) == nil, + "template patch target resolves in the imported dataset") +end +out, code = run(("%s tools/modkit.py validate %q --base fixture") + :format(python, root .. "/scaffy")) +check(code == 0, "template stays a pass against the fixture stand-in") +check(out:find("MK103 not checked", 1, true) ~= nil, + "the fixture base names MK103 as skipped instead of reporting it") + +-- the ROM-free path CI actually runs (M13 criterion 4): the tool's own +-- onboarding example must strict-validate AND pack with no ROM imported +out, code = run(("%s tools/modkit.py validate %q --base fixture --strict") + :format(python, root .. "/scaffy")) +check(code == 0, "template strict-validates ROM-free: " .. out) + +local pkg = root .. "/scaffy.modpkg" +out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, root .. "/scaffy", pkg)) +check(code == 0, "template packs ROM-free: " .. out) +local packed = io.open(pkg, "rb") +check(packed ~= nil, "packing the template writes a .modpkg") +if packed then packed:close() end + +-- a bad mod trips the schema rule and the no-ROM-content gate +local bad = root .. "/badmod" +os.execute(("mkdir -p %q"):format(bad)) +local function write(path, body) + local handle = assert(io.open(path, "wb")) + handle:write(body) + handle:close() +end +write(bad .. "/manifest.json", + [[{"id":"badmod","name":"Bad","version":"1.0.0","api":2,"entry":"main.lua"}]]) +write(bad .. "/main.lua", [[ +return function(mod) + mod.content.pokemon:patch("FIXMON_A", { base_stats = { speed = 130 } }) +end +]]) +write(bad .. "/hack.gb", "GBDATA") +write(bad .. "/cachepath.lua", + 'return { pic = "assets/generated/battle/front/mew.png" }') + +-- pinned to the fixture so the expectations hold with or without an import +out, code = run(("%s tools/modkit.py validate %q --base fixture") + :format(python, bad)) +check(code ~= 0, "bad mod fails validate") +check(out:find("MK101", 1, true) ~= nil, "schema typo reported as MK101") +check(out:find("base_stats", 1, true) ~= nil, "MK101 names the bad field") +check(out:find("MK301", 1, true) ~= nil, "cache reference reported as MK301") +check(out:find("MK303", 1, true) ~= nil, "ROM patch file reported as MK303") + +out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, bad, root .. "/bad.modpkg")) +check(code ~= 0, "pack refuses a failing mod") +check(io.open(root .. "/bad.modpkg", "rb") == nil, "no package written on refusal") + +-- ------- the completed rule table: MK005, MK006, MK103, MK104 + +-- every case below runs against the fixture so the verdicts do not depend on +-- whether the machine has an imported dataset +local function ruleMod(id, manifestExtra, body) + local dir = root .. "/" .. id + os.execute(("mkdir -p %q"):format(dir)) + write(dir .. "/manifest.json", + ('{"id":"%s","name":"%s","version":"1.0.0","api":2,"entry":"main.lua"%s}') + :format(id, id, manifestExtra or "")) + write(dir .. "/main.lua", body) + return dir +end + +local function validate(dir, extra) + return run(("%s tools/modkit.py validate %q --base fixture %s") + :format(python, dir, extra or "")) +end + +-- MK103: a patch whose target nothing defines is a no-op, almost always a typo +local orphan = ruleMod("orphanpatch", nil, [[ +return function(mod) + mod.content.pokemon:patch("NOSUCHMON", { baseStats = { speed = 99 } }) +end +]]) +-- only the imported dataset owns the vanilla id space, so only there does a +-- miss prove a typo -- and there it is an error, not an advisory warning +if haveImported then + out, code = run(("%s tools/modkit.py validate %q --base imported") + :format(python, orphan)) + check(out:find("MK103", 1, true) ~= nil, + "orphan patch target reported as MK103: " .. out) + check(out:find("NOSUCHMON", 1, true) ~= nil, "MK103 names the missing target") + check(code ~= 0, "MK103 fails validate against the authoritative base") +end +-- the fixture cannot tell NOSUCHMON from MEW, so it says so and stays out of +-- the exit code under both plain and --strict runs +out, code = validate(orphan) +check(code == 0, "MK103 is skipped, not guessed, against the fixture: " .. out) +check(out:find("MK103 not checked", 1, true) ~= nil, + "the skip names the rule that did not run") +out, code = validate(orphan, "--strict") +check(code == 0, "--strict cannot promote a rule that never ran: " .. out) + +-- ...and stays quiet for a target the base defines, or one the load set +-- registers itself +local anchored = ruleMod("anchoredpatch", nil, [[ +return function(mod) + mod.content.pokemon:patch("FIXMON_A", { baseStats = { speed = 99 } }) + mod.content.tokens:register("MK103_TOK", function() return "a" end) + mod.content.tokens:patch("MK103_TOK", function() return "b" end) +end +]]) +out, code = validate(anchored) +check(code == 0, "a grounded patch validates clean: " .. out) +check(out:find("MK103", 1, true) == nil, + "MK103 spares a base id and a self-registered id") + +-- MK104: a tombstone that strands a live reference is its own rule, not an +-- unclassified cross-ref failure +local orphanRemove = ruleMod("orphanremove", nil, [[ +return function(mod) + mod.content.pokemon:remove("FIXMON_B") +end +]]) +out, code = validate(orphanRemove) +check(code ~= 0, "a remove that strands a reference fails validate") +check(out:find("MK104", 1, true) ~= nil, "orphaning remove reported as MK104: " .. out) +check(out:find("FIXMON_B", 1, true) ~= nil, "MK104 names the removed id") + +-- a plain dangling reference is still MK102; MK104 must not swallow it +local dangling = ruleMod("danglingref", nil, [[ +return function(mod) + mod.content.pokemon:patch("FIXMON_A", { + evolutions = { { method = "LEVEL", level = 9, species = "NOPEMON" } }, + }) +end +]]) +out, code = validate(dangling) +check(code ~= 0, "a dangling reference fails validate") +check(out:find("MK102", 1, true) ~= nil, "plain dangling reference stays MK102") +check(out:find("MK104", 1, true) == nil, "MK104 does not swallow plain dangling refs") + +-- MK005: the permission vocabulary is the engine's own +local badPerm = ruleMod("badperm", ',"permissions":["network","warp_drive"]', + "return function(mod) end\n") +out, code = validate(badPerm) +check(code ~= 0, "an unknown permission fails validate") +check(out:find("MK005", 1, true) ~= nil, "unknown permission reported as MK005") +check(out:find("warp_drive", 1, true) ~= nil, "MK005 names the bad permission") +local mk005 = 0 +for _ in out:gmatch("MK005") do mk005 = mk005 + 1 end +check(mk005 == 1, "MK005 is reported once, not echoed again by the loader") + +local goodPerm = ruleMod("goodperm", + ',"permissions":["network","filesystem","engine_internals"]', + "return function(mod) end\n") +out, code = validate(goodPerm) +check(code == 0, "the known permission set validates clean: " .. out) +check(out:find("MK005", 1, true) == nil, "MK005 spares declared, known permissions") + +-- MK006: the require never runs, so only a static scan can see it -- which is +-- why this is not left to the loader's dev-mode tripwire +local reachy = ruleMod("reachy", nil, [[ +-- a commented require("src.core.Data") is not a reach past the API +local Semver = require("src.mods.Semver") +local function lazy() + return require("src.core.Logger") +end +return function(mod) + local doc = "call require('src.core.Data') at your peril" + if Semver == nil or lazy == nil or doc == nil then error("unreachable") end +end +]]) +out, code = validate(reachy) +check(out:find("MK006", 1, true) ~= nil, "undeclared engine require reported as MK006") +check(out:find("src.core.Logger", 1, true) ~= nil, "MK006 names the module") +check(out:find("main.lua:4", 1, true) ~= nil, "MK006 reports file:line") +check(out:find("src.core.Data", 1, true) == nil, + "a require in a comment or a string literal does not fire MK006") +check(out:find("src.mods.Semver", 1, true) == nil, + "a supported require does not fire MK006") +check(code == 0, "MK006 is a warning by default") +out, code = validate(reachy, "--strict") +check(code ~= 0, "--strict makes MK006 fatal") + +local declared = ruleMod("declaredreach", ',"permissions":["engine_internals"]', + [[ +local function lazy() + return require("src.core.Logger") +end +return function(mod) + if lazy == nil then error("unreachable") end +end +]]) +out, code = validate(declared) +check(code == 0, "a declared engine_internals require validates clean: " .. out) +check(out:find("MK006", 1, true) == nil, + "MK006 spares a require the manifest declares") + +-- pack runs validate --strict, so a warn-only mod passes validate and is still +-- refused by the distribution path -- otherwise MK006 and MK3xx have no teeth +-- where they matter most +local warnPkg = root .. "/reachy.modpkg" +out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, reachy, warnPkg)) +check(code ~= 0, "pack refuses a mod whose only finding is a warning: " .. out) +check(out:find("MK006", 1, true) ~= nil, "pack names the warn-severity rule") +check(io.open(warnPkg, "rb") == nil, "no package written for a warn-only mod") + +local cleanPkg = root .. "/declared.modpkg" +out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, cleanPkg)) +check(code == 0, "a finding-free mod still packs: " .. out) +local packed = io.open(cleanPkg, "rb") +check(packed ~= nil, "pack writes the package") +if packed then packed:close() end + +-- MK305 diffs shipped tables against the imported dataset; fake one under +-- a scratch repo root so the check exercises the same on ROM-less machines +local fake = root .. "/fakerepo" +os.execute(("mkdir -p %q %q"):format( + fake .. "/data/generated", fake .. "/mods/dumper")) +local rows = {} +for index = 1, 12 do + rows[#rows + 1] = (" FAKE_%02d = { index = %d, power = %d },") + :format(index, index, index * 5) +end +local dump = "return {\n" .. table.concat(rows, "\n") .. "\n}\n" +write(fake .. "/data/generated/moves.lua", dump) +write(fake .. "/mods/dumper/manifest.json", + [[{"id":"dumper","name":"Dumper","version":"1.0.0","api":2,"entry":"main.lua"}]]) +write(fake .. "/mods/dumper/main.lua", "return function(mod) end\n") +write(fake .. "/mods/dumper/moves.lua", dump) + +out, code = run(("%s tools/modkit.py --repo %q lint %q") + :format(python, fake, fake .. "/mods/dumper")) +check(code ~= 0, "bulk data-table dump fails lint: " .. out) +check(out:find("MK305", 1, true) ~= nil, "dump reported as MK305") + +-- no interpreter means no verdict; the gate fails closed, never open +out, code = run(("MODKIT_LUAJIT=%q %s tools/modkit.py --repo %q lint %q") + :format(fake .. "/no-such-luajit", python, fake, fake .. "/mods/dumper")) +check(code ~= 0, "lint fails when luajit is missing") +check(out:find("MK100", 1, true) ~= nil, "missing luajit reported as MK100") + +-- without an imported dataset the skip is visible, not silent +out, code = run(("%s tools/modkit.py --repo %q lint %q") + :format(python, root, fake .. "/mods/dumper")) +check(code == 0, "dump check without a dataset stays a warning") +check(out:find("MK305 WARN", 1, true) ~= nil, "skipped dump check is reported") + +os.execute(("rm -rf %q"):format(root)) + +-- ------- restore shared runtime state for the suites that follow + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.errors = savedErrors +Runtime.wants, Runtime.wantsHook = savedWants, savedWantsHook + +S.finish() diff --git a/tests/parity_A.lua b/tests/parity_A.lua index 1b45a959..3e82b6ea 100644 --- a/tests/parity_A.lua +++ b/tests/parity_A.lua @@ -4,9 +4,8 @@ if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end local unpack = table.unpack or unpack -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity A") +local check, eq = S.check, S.eq -- === assertions === @@ -264,5 +263,4 @@ do package.loaded["src.render.TextBox"] = realTB end -print(("parity A: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-A assertion(s) failed") end +S.finish() diff --git a/tests/parity_B.lua b/tests/parity_B.lua index a85ac5d9..070793df 100644 --- a/tests/parity_B.lua +++ b/tests/parity_B.lua @@ -7,9 +7,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity B") +local check, eq = S.check, S.eq -- === assertions === @@ -103,5 +102,4 @@ fakeGame.save.pendingHallOfFame = false hof.onEnter(fakeGame, fakeOw) check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed") -print(("parity B: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-B assertion(s) failed") end +S.finish() diff --git a/tests/parity_C.lua b/tests/parity_C.lua index d6ecfd12..16e5732d 100644 --- a/tests/parity_C.lua +++ b/tests/parity_C.lua @@ -11,9 +11,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity C") +local check, eq = S.check, S.eq -- empty a table in place (rebinding the local wouldn't reach closures -- that already captured the original table, e.g. ow.startWarpTo below) @@ -282,5 +281,4 @@ end Sound.play = origSoundPlay -print(("parity C: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-C assertion(s) failed") end +S.finish() diff --git a/tests/parity_D.lua b/tests/parity_D.lua index 624e2206..1ebf24c7 100644 --- a/tests/parity_D.lua +++ b/tests/parity_D.lua @@ -5,9 +5,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity D") +local check, eq = S.check, S.eq -- === assertions per your spec test plan === -- Gap: SAFARI_STEP_MAPS (OverworldController.lua ~2106-2109) only decremented @@ -83,5 +82,4 @@ check(fired, "safariStep reports the game-over trigger at 0 steps") check(fakeGame.save.safari == nil, "safari session clears (safariGameOver fired) when steps hit 0 in the secret house") -print(("parity D: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-D assertion(s) failed") end +S.finish() diff --git a/tests/parity_E.lua b/tests/parity_E.lua index b4e9b9d2..bac8a3d0 100644 --- a/tests/parity_E.lua +++ b/tests/parity_E.lua @@ -17,9 +17,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity E") +local check, eq = S.check, S.eq -- The real TextBox needs a loaded font plus frame-stepped input to type -- and dismiss pages -- unrelated to what this workstream verifies (flag / @@ -201,5 +200,4 @@ end package.loaded["src.render.TextBox"] = realTextBox package.loaded["src.ui.Menu"] = realMenu -print(("parity E: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-E assertion(s) failed") end +S.finish() diff --git a/tests/parity_F.lua b/tests/parity_F.lua index cb70a315..cf83c7a8 100644 --- a/tests/parity_F.lua +++ b/tests/parity_F.lua @@ -12,9 +12,8 @@ if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity F") +local check, eq = S.check, S.eq local Game = require("src.core.Game") local Input = require("src.core.Input") @@ -90,5 +89,4 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), "Oak talk (post-grant) script completes") eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk") -print(("parity F: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-F assertion(s) failed") end +S.finish() diff --git a/tests/parity_G.lua b/tests/parity_G.lua index 24945138..1cea8b88 100644 --- a/tests/parity_G.lua +++ b/tests/parity_G.lua @@ -11,9 +11,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity G") +local check, eq = S.check, S.eq local TileRenderer = require("src.render.TileRenderer") @@ -54,5 +53,4 @@ check(a ~= b, "arrow blur frame toggles every ~8 ticks while spinning") TileRenderer.setSpinning(false) check(not TileRenderer.spinBlurActive(), "blur frame turns off once the spin ends") -print(("parity G: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-G assertion(s) failed") end +S.finish() diff --git a/tests/parity_H.lua b/tests/parity_H.lua index f3e45eaa..1dadc4c6 100644 --- a/tests/parity_H.lua +++ b/tests/parity_H.lua @@ -11,9 +11,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity H") +local check, eq = S.check, S.eq -- === (1) static extraction assertions === local sf = Data.field.seafoam @@ -188,5 +187,4 @@ check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F", objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")), "SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state") -print(("parity H: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-H assertion(s) failed") end +S.finish() diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 9c11fdc8..762db222 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -10,9 +10,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity I_M") +local check, eq = S.check, S.eq -- === harness === require("src.render.Font").load(Data) @@ -400,5 +399,4 @@ eq(Game.stack:top(), ow, "back on the map after the blink") TextBox.new = realTextBoxNew popAll() -print(("parity I_M: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-I_M assertion(s) failed") end +S.finish() diff --git a/tests/parity_J.lua b/tests/parity_J.lua index acb5d240..1882b527 100644 --- a/tests/parity_J.lua +++ b/tests/parity_J.lua @@ -3,9 +3,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity J") +local check, eq = S.check, S.eq -- === assertions per your spec test plan === @@ -369,5 +368,4 @@ do "the caught Weedle is NOT added to the dex") end -print(("parity J: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-J assertion(s) failed") end +S.finish() diff --git a/tests/parity_K.lua b/tests/parity_K.lua index ee6bd2ab..42d888a0 100644 --- a/tests/parity_K.lua +++ b/tests/parity_K.lua @@ -8,9 +8,8 @@ if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end local TypeChart = require("src.battle.TypeChart") TypeChart.load(Data) local TrainerAI = require("src.battle.TrainerAI") -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity K") +local check, eq = S.check, S.eq local rngLo = function(a, b) return a end -- picks first minimum local rngHi = function(a, b) return b end -- picks last minimum @@ -136,5 +135,4 @@ do "switchAction: no switch when no backup is unfainted") end -print(("parity K: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-K assertion(s) failed") end +S.finish() diff --git a/tests/parity_L.lua b/tests/parity_L.lua index d69e4dc2..817886d8 100644 --- a/tests/parity_L.lua +++ b/tests/parity_L.lua @@ -5,9 +5,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity L") +local check, eq = S.check, S.eq local MoveEffects = require("src.battle.MoveEffects") local Damage = require("src.battle.Damage") @@ -167,5 +166,4 @@ MoveEffects.primary.ATTACK_UP1_EFFECT(nil, para, nil) check(para.hazeStatReset == nil, "stage change re-arms the paralysis penalty") eq(TurnOrder.effectiveSpeed(para), 25, "Speed-quartering resumes after the stage change") -print(("parity L: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-L assertion(s) failed") end +S.finish() diff --git a/tests/parity_flavor.lua b/tests/parity_flavor.lua index 43598d6f..39b5a440 100644 --- a/tests/parity_flavor.lua +++ b/tests/parity_flavor.lua @@ -9,8 +9,8 @@ if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end local init = require("data.scripts.init") -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end +local S = require("tests.harness").suite("parity flavor") +local check = S.check -- (1) every ported (map, TEXT const) resolves via the registry local ported = 0 @@ -49,5 +49,4 @@ for _, modname in ipairs({ "data.scripts.flavor_all", "data.scripts.story7" }) d end check(missing == 0, ("all %d row-list text labels exist in generated text"):format(labels)) -print(("parity flavor: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-flavor assertion(s) failed") end +S.finish() diff --git a/tests/parity_gbcfx.lua b/tests/parity_gbcfx.lua index c51e3a87..585ae082 100644 --- a/tests/parity_gbcfx.lua +++ b/tests/parity_gbcfx.lua @@ -5,9 +5,8 @@ -- stub offers no love.graphics.newShader (shader() returns nil). package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity gbcfx") +local check, eq = S.check, S.eq -- === assertions === @@ -82,5 +81,4 @@ check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0, GBCFX.setLevel(0) -- === summary === -print(("%d/%d checks passed"):format(total - fails, total)) -if fails > 0 then error(("parity_gbcfx: %d checks failed"):format(fails)) end +S.finish() diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index 094224a4..4a840bb0 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -7,9 +7,8 @@ local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end local Font = require("src.render.Font") if not pcall(Font.encode, "A") then Font.load(Data) end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity HOF") +local check, eq = S.check, S.eq -- === (1) extracted credits data matches CreditsOrder/CreditsMons === @@ -143,5 +142,4 @@ local Game = require("src.core.Game") check(type(Game.makeTitleState) == "function", "Game:makeTitleState exists for the intro's title handoff") -print(("parity HOF: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-HOF assertion(s) failed") end +S.finish() diff --git a/tests/parity_intro.lua b/tests/parity_intro.lua index dd251000..1902d9e5 100644 --- a/tests/parity_intro.lua +++ b/tests/parity_intro.lua @@ -11,9 +11,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity intro") +local check, eq = S.check, S.eq local mapScripts = require("data.scripts.init") local pallet = mapScripts.get("PALLET_TOWN") @@ -187,5 +186,4 @@ do for k, v in pairs(prev) do Game[k] = v end end -if fails > 0 then error(("parity_intro: %d/%d checks failed"):format(fails, total)) end -print(("parity_intro: %d checks passed"):format(total)) +S.finish() diff --git a/tests/parity_static.lua b/tests/parity_static.lua index 5cdc0028..79bd4532 100644 --- a/tests/parity_static.lua +++ b/tests/parity_static.lua @@ -14,9 +14,8 @@ if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity static") +local check, eq = S.check, S.eq local Game = require("src.core.Game") local Input = require("src.core.Input") @@ -271,5 +270,4 @@ Commands.show_text = origShow Commands.start_battle = origStart Game.save = SaveData.newGame() -print(("parity static: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-static assertion(s) failed") end +S.finish() diff --git a/tests/parity_tilt.lua b/tests/parity_tilt.lua index c3049b6a..e8f28630 100644 --- a/tests/parity_tilt.lua +++ b/tests/parity_tilt.lua @@ -6,9 +6,8 @@ -- tilted plane covering the window. package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity tilt") +local check, eq = S.check, S.eq -- === assertions === @@ -224,5 +223,4 @@ check(pallet.renderer.drawTilt == nil and pallet.renderer.drawTiltMapOnly == nil "TileRenderer no longer has a separate tilt ground draw path") MapLoader.clearCache() -print(("parity tilt: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-tilt assertion(s) failed") end +S.finish() diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua index 37e90b8b..34cc91db 100644 --- a/tests/parity_trade_gift.lua +++ b/tests/parity_trade_gift.lua @@ -22,9 +22,8 @@ if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity trade/gift") +local check, eq = S.check, S.eq local Game = require("src.core.Game") local Input = require("src.core.Input") @@ -277,5 +276,4 @@ eq(ss.onStep(Game, nil, 30, 8), false, "unbeaten rival: no ambush off the trigge Commands.show_text = origShow -print(("parity trade/gift: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity trade/gift assertion(s) failed") end +S.finish() diff --git a/tests/parity_trainer_sight.lua b/tests/parity_trainer_sight.lua index ba071325..0d18e24d 100644 --- a/tests/parity_trainer_sight.lua +++ b/tests/parity_trainer_sight.lua @@ -26,9 +26,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity trainer_sight") +local check, eq = S.check, S.eq require("src.render.Font").load(Data) local Game = require("src.core.Game") @@ -170,5 +169,4 @@ do "tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)") end -print(("parity trainer_sight: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " trainer-sight assertion(s) failed") end +S.finish() diff --git a/tests/parity_trashcans.lua b/tests/parity_trashcans.lua index 91eea4dc..3abda268 100644 --- a/tests/parity_trashcans.lua +++ b/tests/parity_trashcans.lua @@ -21,9 +21,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end -local fails, total = 0, 0 -local function check(c, m) total = total + 1; if c then print("ok " .. m) else fails = fails + 1; print("FAIL " .. m) end end -local function eq(g, w, m) check(g == w, ("%s (got %s, want %s)"):format(m, tostring(g), tostring(w))) end +local S = require("tests.harness").suite("parity trashcans") +local check, eq = S.check, S.eq local OW = require("src.world.OverworldController") local SaveData = require("src.core.SaveData") @@ -209,5 +208,4 @@ end love.math.random = realRandom setUpvalue(OW.trashCanSwitch, "TextBox", realTextBox) -print(("parity trashcans: %d/%d passed"):format(total - fails, total)) -if fails > 0 then error(fails .. " parity-trashcans assertion(s) failed") end +S.finish() diff --git a/tests/run_content_red.lua b/tests/run_content_red.lua new file mode 100644 index 00000000..08add683 --- /dev/null +++ b/tests/run_content_red.lua @@ -0,0 +1,9 @@ +-- T3 content-parity tier: the facts that are true of Pokemon Red and of +-- nothing else. Needs an imported ROM (data/generated/), so CI skips it +-- and scripts/test.sh only runs it when the generated data is present. +-- A total conversion swaps this directory for its own content_/. +-- luajit tests/run_content_red.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +require("tests.tier_runner").main({ "tests/content_red" }, "content_red") diff --git a/tests/run_engine.lua b/tests/run_engine.lua new file mode 100644 index 00000000..43d87fa8 --- /dev/null +++ b/tests/run_engine.lua @@ -0,0 +1,8 @@ +-- T2 engine-invariant tier: formulas and machinery parameterized by the +-- loaded dataset, plus the no-mod parity gates. Runs against +-- tests/fixture_data, so it needs no ROM and is the tier CI leans on. +-- luajit tests/run_engine.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +require("tests.tier_runner").main({ "tests/engine" }, "engine") diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index 8309ea3c..e6c8cd9a 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -240,5 +240,11 @@ eq(gameA.save.money, 3000, "no prize money in link battles") eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp, "the real party is untouched (battle used clamped copies)") +-- ---------------------------------------------------------------- mod link compat +-- Self-contained like the tests/mod_*.lua suites: own bootstrap and +-- assert-based checks, so it lands here as a single pass/fail line. +local modOk, modErr = pcall(dofile, "tests/mod_link_tests.lua") +check(modOk, "mod link compat suite" .. (modOk and "" or (": " .. tostring(modErr)))) + print(("\n%s"):format(failures == 0 and "ALL LINK TESTS PASSED" or failures .. " FAILURES")) os.exit(failures == 0 and 0 or 1) diff --git a/tests/run_modkit.lua b/tests/run_modkit.lua new file mode 100644 index 00000000..b1814ac2 --- /dev/null +++ b/tests/run_modkit.lua @@ -0,0 +1,22 @@ +-- T4 mod-SDK tier: the public mod API exercised headlessly against the +-- fixture dataset, plus any tests a shipped mod carries in its own +-- tests/ directory. No ROM, no display. +-- luajit tests/run_modkit.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +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 +local pipe = io.popen("ls -d mods/*/tests 2>/dev/null") +if pipe then + for line in pipe:lines() do + if line ~= "" then dirs[#dirs + 1] = line end + end + pipe:close() +end + +Runner.main(dirs, "modkit") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 3a6826bb..a2cf6180 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -8,21 +8,14 @@ package.path = "./?.lua;./?/init.lua;" .. package.path love = require("tests.love_stub") -math.randomseed(12345) -local failures = 0 -local function check(cond, msg) - if cond then - print("ok " .. msg) - else - failures = failures + 1 - print("FAIL " .. msg) - end -end - -local function eq(got, want, msg) - check(got == want, ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want))) -end +-- assertions and the RNG seed come off the shared harness; this file keeps +-- its own tail because the verdict line ("N FAILURES") is what CI greps +local T = require("tests.harness") +-- this suite has always streamed a line per check, and it is the one a +-- developer watches for progress through ~1600 assertions +T.verbose = true +local check, eq = T.check, T.eq -- ---------------------------------------------------------------- data local Data = require("src.core.Data") @@ -545,9 +538,6 @@ local cries = Data.audio and Data.audio.cries or {} local cryCount = 0 for _ in pairs(cries) do cryCount = cryCount + 1 end check(cryCount >= 150, "cries rendered for the full dex (" .. cryCount .. ")") -local cf = io.open("assets/generated/audio/cries/pikachu.wav", "rb") -check(cf ~= nil, "Pikachu cry WAV exists") -if cf then cf:close() end -- ---------------------------------------------------------------- slot machine paylines local SlotMachine = require("src.ui.SlotMachine") @@ -707,19 +697,24 @@ eq(Data.field.badgeGates.ROUTE_23.guards[1].badge, "EARTHBADGE", -- ROUTE_22, so the north LAST_MAP warps leave onto Route 23 do local OW = require("src.world.OverworldController") - eq(OW.route22GateOutdoor(0), "ROUTE_23", "Route22Gate Y=0 -> Route 23") - eq(OW.route22GateOutdoor(3), "ROUTE_23", "Route22Gate Y=3 -> Route 23") - eq(OW.route22GateOutdoor(4), "ROUTE_22", "Route22Gate Y=4 -> Route 22") - eq(OW.route22GateOutdoor(7), "ROUTE_22", "Route22Gate Y=7 -> Route 22") + local FieldDefaults = require("src.world.FieldDefaults") + local rewrite = FieldDefaults.field(Data, "lastMapRewrites").ROUTE_22_GATE + local function outdoorAt(cellY) + return OW.rewrittenLastMap(rewrite, 0, cellY) + end + eq(outdoorAt(0), "ROUTE_23", "Route22Gate Y=0 -> Route 23") + eq(outdoorAt(3), "ROUTE_23", "Route22Gate Y=3 -> Route 23") + eq(outdoorAt(4), "ROUTE_22", "Route22Gate Y=4 -> Route 22") + eq(outdoorAt(7), "ROUTE_22", "Route22Gate Y=7 -> Route 22") local north = Data.maps.ROUTE_22_GATE.warps[3] local m, x, y = Warp.destination(Data, north, - { id = OW.route22GateOutdoor(0), x = 0, y = 0 }) + { id = outdoorAt(0), x = 0, y = 0 }) eq(m, "ROUTE_23", "north gate LAST_MAP with Y rewrite lands on Route 23") eq(x, 7, "north gate lands on Route 23 south warp x") eq(y, 139, "north gate lands on Route 23 south warp y") local south = Data.maps.ROUTE_22_GATE.warps[1] m, x, y = Warp.destination(Data, south, - { id = OW.route22GateOutdoor(7), x = 0, y = 0 }) + { id = outdoorAt(7), x = 0, y = 0 }) eq(m, "ROUTE_22", "south gate LAST_MAP with Y rewrite lands on Route 22") eq(x, 8, "south gate lands on Route 22 gate warp x") eq(y, 5, "south gate lands on Route 22 gate warp y") @@ -1907,7 +1902,6 @@ end do local savedParty = Game.save.party local Pokemon = require("src.pokemon.Pokemon") - check(Data.audio.sfx.Low_Health_Alarm ~= nil, "low-health alarm sfx extracted") Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } Game.save.party[1].hp = Game.save.party[1].stats.hp local lhb = BattleState.newWild(Game, "RATTATA", 3) @@ -1942,11 +1936,12 @@ end do -- == Task 8: options screen scrolls option boxes + audio/display rows == -- The screen keeps pokered's one-box-per-option adaptation of --- DisplayOptionMenu (engine/menus/main_menu.asm) but now scrolls 11 --- option boxes through a 4-box viewport with a $EE ▼ marker; MUSIC VOL / --- SFX VOL clamp at 0..7 like the text-speed cursor clamps at its ends --- (.pressedLeftInTextSpeed), MUSIC FILTER cycles OFF/1X/2X/3X, and --- COLORS / TILT / GBC FX cycle their display modes. +-- DisplayOptionMenu (engine/menus/main_menu.asm) but now scrolls the +-- option boxes (the port rows plus the MODS/CONTROLS entries) through a 4-box +-- viewport with a $EE ▼ marker; MUSIC VOL / SFX VOL clamp at 0..7 like +-- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed), +-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX cycle +-- their display modes. do local OptionsMenu = require("src.ui.OptionsMenu") local OInput = require("src.core.Input") @@ -2010,15 +2005,19 @@ do for _ = 1, 4 do press("a") end eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF") press("down") - eq(om.index, 11, "CANCEL is row 11") - eq(om.scroll, 6, "CANCEL keeps the last option boxes on screen") + eq(om.index, 11, "cursor reaches MODS") + press("down") + eq(om.index, 12, "cursor reaches CONTROLS") + press("down") + eq(om.index, 13, "CANCEL stays the fixed final row") + eq(om.scroll, 8, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub press("a") check(popped, "A on CANCEL closes the options menu") local om2 = OptionsMenu.new(og) OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} - eq(om2.index, 11, "up from the top wraps to CANCEL") - eq(om2.scroll, 6, "wrapping to CANCEL scrolls to the tail") + eq(om2.index, 13, "up from the top wraps to CANCEL") + eq(om2.scroll, 8, "wrapping to CANCEL scrolls to the tail") -- headless-safe: no love.audio, setters only update internal state require("src.core.Music").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options) @@ -2349,20 +2348,114 @@ eq(frameFor("BALL", true, 96), 3, "fallback: 16x96 sheet animates to 3") eq(frameFor("HELIX", true, 32), 1, "fallback: 16x32 sheet animates to 1") end --- ---------------------------------------------- parity workstream tests --- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, --- error()s if any assertion fails). We dofile the ones that exist here so --- `luajit tests/run_tests.lua` stays the single green bar; absent files --- are skipped. -for _, name in ipairs({ "D", "F", "E", "C", "K", "L", "H", "G", "I_M", "B", "J", "A", "flavor", "trainer_sight", "static", "trashcans", "hof", "trade_gift", "intro", "tilt", "gbcfx" }) do - local path = "tests/parity_" .. name .. ".lua" - local fh = io.open(path, "r") - if fh then - fh:close() +-- ---------------------------------------------- suite discovery +-- The chains below used to be hard-coded arrays, so adding a suite meant +-- editing a list and forgetting to meant the suite silently never ran. +-- They are globbed now (21-testing-and-ci §CI). +-- +-- Order still matters: these suites share one process and one Data, and +-- the sequence they were chained in is the sequence they are known to +-- pass in. So the known order runs first and anything the glob newly +-- turned up runs after it, alphabetically -- a new suite runs without a +-- code change, and no existing suite moves. +local function orderedGlob(pattern, preferred, skip) + local seen, ordered = {}, {} + for _, path in ipairs(preferred) do + local handle = io.open(path, "r") + if handle then + handle:close() + seen[path] = true + ordered[#ordered + 1] = path + end + end + local discovered = {} + local pipe = io.popen("ls -1 " .. pattern .. " 2>/dev/null") + if pipe then + for line in pipe:lines() do + if line ~= "" and not seen[line] and not (skip and skip[line]) then + seen[line] = true + discovered[#discovered + 1] = line + end + end + pipe:close() + end + table.sort(discovered) + for _, path in ipairs(discovered) do ordered[#ordered + 1] = path end + return ordered +end + +local function runSuites(paths) + for _, path in ipairs(paths) do + local label = path:match("([^/]+)%.lua$") or path local ok, err = pcall(dofile, path) - check(ok, "parity_" .. name .. (ok and " suite" or (": " .. tostring(err)))) + check(ok, label .. (ok and " suite" or (": " .. tostring(err)))) end end +-- ---------------------------------------------- mod runtime & loader +-- Self-contained like the parity files below: own bootstrap, assert-based +-- checks, error() on any failure. +runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", { + "tests/mod_runtime_tests.lua", "tests/mod_loader_tests.lua", + "tests/mod_registry_tests.lua", "tests/mod_manifest_tests.lua", + "tests/mod_constants_tests.lua", "tests/mod_catalog_tests.lua", + "tests/mod_audio_tests.lua", "tests/mod_world_tests.lua", + "tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua", + "tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua", + "tests/mod_save_tests.lua", "tests/modkit_tests.lua", +}, { + -- run_link_tests.lua owns this one; dofiling it here as well would + -- stand a second loader up over the same Data in this process + ["tests/mod_link_tests.lua"] = true, +})) + +-- the editor mod-awareness suite boots App.load's own loader over the +-- singleton Data, which collides with the loader Game:load already merged +-- in this process (one loader per process), so it gets a process to itself +do + local lua = (arg and arg[-1]) or "luajit" + local status = os.execute(("%q tests/save_editor_mod_tests.lua"):format(lua)) + check(status == 0 or status == true, "save_editor_mod_tests suite") +end + +-- ---------------------------------------------- parity workstream tests +-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, +-- error()s if any assertion fails). Globbed, so dropping a new parity +-- file into tests/ is enough to make it run. +runSuites(orderedGlob("tests/parity_*.lua", { + "tests/parity_D.lua", "tests/parity_F.lua", "tests/parity_E.lua", + "tests/parity_C.lua", "tests/parity_K.lua", "tests/parity_L.lua", + "tests/parity_H.lua", "tests/parity_G.lua", "tests/parity_I_M.lua", + "tests/parity_B.lua", "tests/parity_J.lua", "tests/parity_A.lua", + "tests/parity_flavor.lua", "tests/parity_trainer_sight.lua", + "tests/parity_static.lua", "tests/parity_trashcans.lua", + "tests/parity_hof.lua", "tests/parity_trade_gift.lua", + "tests/parity_intro.lua", "tests/parity_tilt.lua", + "tests/parity_gbcfx.lua", +})) + +-- ---------------------------------------------- the globbed tiers +-- content_red (T3, the Red-pinned facts split out of this file), +-- engine (T2, invariants over the fixture dataset) and modkit (T4, the +-- public mod API) each stand a loader up per suite -- and a second +-- Builtins.install over a Data this process already merged raises -- so +-- every one of their suites gets its own process, the way +-- save_editor_mod_tests already does above. Chaining the three runners +-- here keeps `luajit tests/run_tests.lua` the single green bar it has +-- always been; scripts/test.sh also runs them directly. +do + local lua = (arg and arg[-1]) or "luajit" + for _, tier in ipairs({ "tests/run_content_red.lua", + "tests/run_engine.lua", "tests/run_modkit.lua" }) do + local handle = io.open(tier, "r") + if handle then + handle:close() + local status = os.execute(("%q %s > /dev/null 2>&1"):format(lua, tier)) + check(status == 0 or status == true, tier:match("([^/]+)%.lua$") .. " tier") + end + end +end + +local failures = T.failures print(("\n%s"):format(failures == 0 and "ALL TESTS PASSED" or failures .. " FAILURES")) os.exit(failures == 0 and 0 or 1) diff --git a/tests/save_editor_mod_tests.lua b/tests/save_editor_mod_tests.lua new file mode 100644 index 00000000..aea0ffbf --- /dev/null +++ b/tests/save_editor_mod_tests.lua @@ -0,0 +1,158 @@ +-- Editor mod-awareness (15 testing): with a mod enabled, App.load merges +-- the mod set into Data before the catalogs build, so the species list +-- carries the mod's mon, the flag list carries its MOD_ flags scraped +-- from the mod root, and MonOps stops asserting on the modded species. +-- Runs in its own process (run_tests spawns it): App.load's loader must +-- be the first to merge vanilla records over the singleton Data, or the +-- engine's registrations collide with an earlier loader's merge. +-- Self-contained: own bootstrap, assert-based checks, error() on failure. +package.path = "./?.lua;./?/init.lua;./tools/save-editor/?.lua;" + .. "./tools/save-editor/panels/?.lua;" .. package.path +love = love or require("tests.love_stub") + +local Runtime = require("src.mods.Runtime") +local Assets = require("src.render.Assets") +local SaveData = require("src.core.SaveData") +local Data = require("src.core.Data") + +local function check(value, message) + assert(value, message) +end + +-- the fs surface the editor's loader needs, backed by a flat table +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return (loadstring or load)(files[path], path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local MOD_ROOT = "mods/zz_editor_fixture" + +local MOD_MAIN = [[ +return function(mod) + local giftFlag = "MOD_EDITMON_GIFT" + mod.exports.giftFlag = giftFlag + mod.content.pokemon:register("EDITMON", { + id = "EDITMON", name = "EDITMON", dex = 152, + types = { "NORMAL" }, + baseStats = { hp = 50, attack = 50, defense = 50, speed = 50, special = 50 }, + catchRate = 45, baseExp = 100, + level1Moves = { "GROWL" }, + growthRate = "MEDIUM_FAST", + learnset = { { level = 10, move = "TACKLE" } }, + evolutions = {}, + spriteFront = "editmon_front.png", spriteBack = "editmon_back.png", + frontSize = 5, + }) +end +]] + +-- the loader discovers the fixture through love.filesystem, while the +-- flag scrape lists the mod root with io; the disk copy carries no +-- manifest, so a crash that strands it leaves a directory the game ignores +local savedFS = love.filesystem +local savedEvents, savedHooks, savedErrors = + Runtime.events, Runtime.hooks, Runtime.errors +local savedBridge = Assets.loader + +love.filesystem = memfs({ + [MOD_ROOT .. "/manifest.json"] = + '{"id":"zz_editor_fixture","name":"zz_editor_fixture","version":"1.0.0",' + .. '"entry":"main.lua","dependencies":[],"api":2}', + [MOD_ROOT .. "/main.lua"] = MOD_MAIN, +}) +os.execute('mkdir -p "' .. MOD_ROOT .. '"') +local diskMain = assert(io.open(MOD_ROOT .. "/main.lua", "w")) +diskMain:write(MOD_MAIN) +diskMain:close() + +local tmpPath = os.tmpname() .. "-editor-modaware.lua" +os.remove(tmpPath) + +local ok, err = pcall(function() + local App = require("App") + local MonOps = require("MonOps") + local Growth = require("src.pokemon.Growth") + + App.load(tmpPath) + local S = App.getState() + + local loaded = S.mods:status().loaded + check(#loaded == 1 and loaded[1].id == "zz_editor_fixture", + "fixture mod loads through the editor's loader") + check(loaded[1].path == MOD_ROOT, "loaded mod reports its root path") + + local hasSpecies = false + for _, id in ipairs(S.cat.species) do + if id == "EDITMON" then hasSpecies = true end + end + check(hasSpecies, "editor species catalog carries the mod's mon") + + local hasModFlag, hasVanillaFlag = false, false + for _, name in ipairs(S.events) do + if name == "MOD_EDITMON_GIFT" then hasModFlag = true end + if name:match("^EVENT_") then hasVanillaFlag = true end + end + check(hasModFlag, "editor flag list carries the mod's MOD_ flag") + check(hasVanillaFlag, "vanilla EVENT_ flags still scraped beside the mod's") + + -- MonOps reads the same merged Data the catalog was built from + check(Data.pokemon.EDITMON ~= nil, "merge landed in the Data table MonOps reads") + local mon = MonOps.create(Data, "PIDGEY", 10) + MonOps.setSpecies(Data, mon, "EDITMON") + check(mon.species == "EDITMON", "MonOps.setSpecies accepts the modded species") + check(mon.exp == Growth.expForLevel("MEDIUM_FAST", 10), + "setSpecies resyncs exp against the mod's growth curve") + check(mon.stats.hp > 0, "recalc computes stats from the mod's base stats") + MonOps.recalc(Data, mon) + + -- and the game-side scrub agrees: a save holding the modded mon passes + -- clean instead of quarantining it + local probe = { player = { map = "PALLET_TOWN" }, + party = { { species = "EDITMON", level = 10, + moves = { { id = "TACKLE", pp = 10 } } } } } + local report = SaveData.validate(probe, Data) + check(#report.lostMons == 0 and probe.party[1].species == "EDITMON", + "validate keeps the modded mon while the mod is enabled") +end) + +os.remove(MOD_ROOT .. "/main.lua") +os.execute('rmdir "' .. MOD_ROOT .. '" 2>/dev/null') +os.remove(tmpPath) +love.filesystem = savedFS +-- leave shared singletons the way we found them (the fixture merged one +-- record into Data.pokemon) +Data.pokemon.EDITMON = nil +Assets.loader = savedBridge +Assets.invalidate() +Runtime.install(savedEvents, savedHooks, savedErrors) +if not ok then error(err, 0) end + +print("ok save editor mod awareness") diff --git a/tests/tier_runner.lua b/tests/tier_runner.lua new file mode 100644 index 00000000..02ea8b0f --- /dev/null +++ b/tests/tier_runner.lua @@ -0,0 +1,70 @@ +-- Shared tier runner: globs a directory and runs each suite, reporting a +-- machine-readable failure count (21-testing-and-ci §CI, "the hard-coded +-- parity chain is replaced by directory globbing"). +-- +-- Each suite gets its own process. That is not fastidiousness: the engine +-- is one-loader-per-dataset (a second Builtins.install over the same Data +-- re-registers every built-in id and raises) and Runtime holds +-- process-wide buses, so suites that each stand up a loader cannot share +-- an interpreter. run_tests.lua already isolates one suite this way for +-- exactly that reason. +-- +-- Globbing means a suite drops into the directory and runs -- no array to +-- edit -- which is the whole point of the reorganization. Files starting +-- with "_" are helpers, not suites. + +local Runner = {} + +local function interpreter() + -- arg[-1] is how the suite was invoked (luajit here, lua5.4 elsewhere) + return (arg and arg[-1]) or "luajit" +end + +function Runner.suites(dir) + local files = {} + local pipe = io.popen(("ls -1 '%s'/*.lua 2>/dev/null"):format(dir)) + if not pipe then return files end + for line in pipe:lines() do + local name = line:match("[^/]+$") + -- "_" prefixes helpers; facts.lua is the tier's pinned-value table + -- (a content_/facts.lua is data the suites read, not a suite) + if name and name:sub(1, 1) ~= "_" and name ~= "facts.lua" then + files[#files + 1] = line + end + end + pipe:close() + table.sort(files) + return files +end + +-- runs every suite in `dirs`, prints one line per suite, returns the +-- number that failed +function Runner.run(dirs, label) + local lua = interpreter() + local failed, total = 0, 0 + + for _, dir in ipairs(dirs) do + for _, path in ipairs(Runner.suites(dir)) do + total = total + 1 + local status = os.execute(("%s %s"):format(lua, path)) + local ok = status == 0 or status == true + if ok then + print("ok " .. path) + else + failed = failed + 1 + print("FAIL " .. path) + end + end + end + + print(("\n%s: %d/%d suites passed"):format(label, total - failed, total)) + print(("%s"):format(failed == 0 and "ALL TESTS PASSED" or failed .. " FAILURES")) + return failed, total +end + +function Runner.main(dirs, label) + local failed = Runner.run(dirs, label) + os.exit(failed == 0 and 0 or 1) +end + +return Runner diff --git a/tools/compare_shots.py b/tools/compare_shots.py new file mode 100755 index 00000000..206c30eb --- /dev/null +++ b/tools/compare_shots.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Golden-screenshot diff (21-testing-and-ci "golden screenshots"). + +Compares captured PNGs against committed goldens with a per-pixel channel +tolerance and a budget for how many pixels may differ at all, and writes a +side-by-side image for every regression so a CI artifact shows what moved. + +The two thresholds are separate on purpose. --tolerance absorbs the +harmless drift a different GPU or LOVE build puts into a blend; --max-diff +is what actually fails the run. A shot where one sprite moved trips the +pixel budget even though every differing pixel is far outside the channel +tolerance, and a shot that got globally half a shade darker trips neither +-- which is the intent, because that is not a regression a reviewer wants +to bless a hundred goldens over. + +Usage: + tools/compare_shots.py GOLDEN_DIR SHOT_DIR [--tolerance N] [--max-diff N] + [--diff-dir DIR] [--bless] + +Exits 0 when every shot matches, 1 on any regression, 2 on a usage or +missing-file problem. +""" + +import argparse +import os +import shutil +import sys + +try: + from PIL import Image, ImageChops +except ImportError: + sys.stderr.write( + "compare_shots needs Pillow: python3 -m pip install pillow\n") + sys.exit(2) + + +def load_rgb(path): + """Goldens and captures must compare in one colour space; LOVE writes + RGBA and a hand-made golden is often RGB.""" + with Image.open(path) as image: + return image.convert("RGB") + + +def compare(golden_path, shot_path, tolerance, max_diff): + """Returns (ok, message, diff_image_or_None).""" + golden = load_rgb(golden_path) + shot = load_rgb(shot_path) + + if golden.size != shot.size: + return (False, + "size %dx%d != golden %dx%d" % (shot.width, shot.height, + golden.width, golden.height), + side_by_side(golden, shot, None)) + + delta = ImageChops.difference(golden, shot) + # collapse the three channels to the worst single-channel deviation, so + # a pixel counts as differing on its loudest channel rather than an + # average that would hide a red-only shift + worst = max_channel(delta) + + histogram = worst.histogram() + differing = sum(histogram[tolerance + 1:]) + + if differing > max_diff: + return (False, + "%d pixels differ by more than %d (budget %d)" + % (differing, tolerance, max_diff), + side_by_side(golden, shot, amplify(worst))) + + return (True, "%d pixels differ by more than %d" % (differing, tolerance), None) + + +def max_channel(delta): + """Per-pixel max across R/G/B as an L image.""" + red, green, blue = delta.split() + return ImageChops.lighter(ImageChops.lighter(red, green), blue) + + +def amplify(mask): + """Difference masks are near-black; stretch so the diff is visible.""" + return mask.point(lambda value: 255 if value else 0) + + +def side_by_side(golden, shot, mask): + """golden | shot | mask, for the CI artifact.""" + panels = [golden, shot] + if mask is not None: + panels.append(mask.convert("RGB")) + width = sum(panel.width for panel in panels) + 4 * (len(panels) - 1) + height = max(panel.height for panel in panels) + canvas = Image.new("RGB", (width, height), (255, 0, 255)) + offset = 0 + for panel in panels: + canvas.paste(panel, (offset, 0)) + offset += panel.width + 4 + return canvas + + +def shots_in(directory): + if not os.path.isdir(directory): + return None + return sorted(name for name in os.listdir(directory) + if name.lower().endswith(".png")) + + +def main(argv): + parser = argparse.ArgumentParser(description="diff captured shots against goldens") + parser.add_argument("golden_dir") + parser.add_argument("shot_dir") + parser.add_argument("--tolerance", type=int, default=2, + help="per-channel value a pixel may drift without counting (default 2)") + parser.add_argument("--max-diff", type=int, default=0, + help="how many pixels may exceed the tolerance (default 0)") + parser.add_argument("--diff-dir", default=None, + help="where to write side-by-side images (default SHOT_DIR/diffs)") + parser.add_argument("--bless", action="store_true", + help="overwrite the goldens with the captures instead of comparing") + args = parser.parse_args(argv) + + goldens = shots_in(args.golden_dir) + shots = shots_in(args.shot_dir) + + if shots is None: + sys.stderr.write("no shot directory: %s\n" % args.shot_dir) + return 2 + + if args.bless: + os.makedirs(args.golden_dir, exist_ok=True) + for name in shots: + shutil.copyfile(os.path.join(args.shot_dir, name), + os.path.join(args.golden_dir, name)) + print("blessed %s" % name) + print("\n%d goldens written to %s" % (len(shots), args.golden_dir)) + return 0 + + if goldens is None: + sys.stderr.write( + "no golden directory: %s (capture some and re-run with --bless)\n" + % args.golden_dir) + return 2 + + if not goldens: + sys.stderr.write( + "no goldens in %s -- refusing to pass vacuously\n" % args.golden_dir) + return 2 + + diff_dir = args.diff_dir or os.path.join(args.shot_dir, "diffs") + failures = 0 + + for name in goldens: + golden_path = os.path.join(args.golden_dir, name) + shot_path = os.path.join(args.shot_dir, name) + + if not os.path.exists(shot_path): + print("FAIL %s: no capture for this golden" % name) + failures += 1 + continue + + ok, message, diff = compare(golden_path, shot_path, + args.tolerance, args.max_diff) + if ok: + print("ok %s (%s)" % (name, message)) + else: + failures += 1 + print("FAIL %s: %s" % (name, message)) + if diff is not None: + os.makedirs(diff_dir, exist_ok=True) + out = os.path.join(diff_dir, name) + diff.save(out) + print(" diff written to %s" % out) + + # a capture with no golden is a new screen nobody blessed; report it, + # but it is not a regression in an existing one + for name in shots: + if name not in goldens: + print("note %s: captured but no golden (bless it to pin it)" % name) + + print("\n%d/%d shots matched" % (len(goldens) - failures, len(goldens))) + print("ALL SHOTS MATCHED" if failures == 0 else "%d FAILURES" % failures) + return 0 if failures == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/gen_registry_docs.lua b/tools/gen_registry_docs.lua new file mode 100644 index 00000000..16c77efc --- /dev/null +++ b/tools/gen_registry_docs.lua @@ -0,0 +1,124 @@ +-- Renders Reference-Registries.md from Schemas.REGISTRIES so the reference +-- page cannot drift from the engine. Run from the repo root: +-- luajit tools/gen_registry_docs.lua [outputDir] +-- +-- The book lives in the GitHub wiki, so the target is a wiki checkout: +-- luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki +-- POKEPORT_DOCS_DIR=../project.wiki luajit tools/gen_registry_docs.lua +-- The full doc pipeline moves into the modkit CLI later; this is the +-- Schemas -> markdown seed it will absorb. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local Schemas = require("src.mods.Schemas") + +-- the book lives in the GitHub wiki, so the default target is a sibling +-- wiki checkout; pass a directory or set POKEPORT_DOCS_DIR to override +local DEFAULT_DIR = "../pokemon-gen1-recomp-project.wiki" +local FILE = "Reference-Registries.md" + +-- precedence: argv, env, default -- so a wiki checkout is one flag away and +-- a CI job can set it once for every generator that grows this convention +local outDir = (... or nil) +if outDir == nil or outDir == "" then outDir = os.getenv("POKEPORT_DOCS_DIR") end +if outDir == nil or outDir == "" then outDir = DEFAULT_DIR end +outDir = outDir:gsub("/+$", "") + +local OUT = outDir .. "/" .. FILE + +local names = {} +for name in pairs(Schemas.REGISTRIES) do names[#names + 1] = name end +table.sort(names) + +local out = {} +local function line(fmt, ...) + if select("#", ...) > 0 then + out[#out + 1] = fmt:format(...) + else + out[#out + 1] = fmt + end +end + +line("") +line("") +line("# Registry reference") +line("") +line("One section per registry: merge semantics, the `Data` table the merge") +line("writes, and the value schema. Concepts and verbs:") +line("[Concepts: Registries](Concepts-Registries).") + +for _, name in ipairs(names) do + local spec = Schemas.REGISTRIES[name] + line("") + line("## %s", name) + line("") + line("- semantics: `%s`", spec.semantics) + line("- target: %s", spec.target and ("`Data." .. spec.target .. "`") or "none") + if spec.deprecated then + line("- **deprecated** -- use %s", spec.deprecated.useInstead) + end + if spec.keys then + line("") + line("Id = a top-level key of the target table. Keys not listed here are") + line("accepted and merged as-is.") + line("") + line("| key | type |") + line("|---|---|") + local keyNames = {} + for keyName in pairs(spec.keys) do keyNames[#keyNames + 1] = keyName end + table.sort(keyNames) + for _, keyName in ipairs(keyNames) do + line("| `%s` | %s |", keyName, spec.keys[keyName].desc) + end + elseif spec.fields then + line("") + line("| field | type | required |") + line("|---|---|---|") + local fieldNames = {} + for fieldName in pairs(spec.fields) do fieldNames[#fieldNames + 1] = fieldName end + table.sort(fieldNames) + for _, fieldName in ipairs(fieldNames) do + local ft = spec.fields[fieldName] + line("| `%s` | %s | %s |", fieldName, ft.desc, + ft.kind == "opt" and "no" or "yes") + end + elseif spec.keyValue then + line("") + line("Id = a top-level key of the target table; every key carries the same") + line("shape.") + line("") + line("- value: %s", spec.keyValue.desc) + elseif spec.value then + line("- value: %s", spec.value.desc) + end + if spec.example then + line("") + line("```lua") + line("%s", spec.example) + line("```") + end +end + +line("") +line("## v1 aliases") +line("") +line("| alias | canonical |") +line("|---|---|") +local aliases = {} +for alias in pairs(Schemas.ALIASES) do aliases[#aliases + 1] = alias end +table.sort(aliases) +for _, alias in ipairs(aliases) do + line("| `%s` | `%s` |", alias, Schemas.ALIASES[alias]) +end + +-- a wiki checkout may not have the directory yet; create it before the +-- open so pointing at a fresh clone is not a two-step +local file = io.open(OUT, "w") +if not file then + os.execute('mkdir -p "' .. outDir:gsub('"', '\\"') .. '"') + file = assert(io.open(OUT, "w"), + "cannot write " .. OUT .. " (is the output directory reachable?)") +end +file:write(table.concat(out, "\n") .. "\n") +file:close() +print("wrote " .. OUT) diff --git a/tools/modkit.py b/tools/modkit.py new file mode 100644 index 00000000..44df1c81 --- /dev/null +++ b/tools/modkit.py @@ -0,0 +1,1253 @@ +#!/usr/bin/env python3 +"""modkit: the mod-author CLI (20-developer-tooling.md, D12). + + python3 tools/modkit.py [args] + +Subcommands: + scaffold [--profile content|overhaul|total_conversion] [--api 2] + [--dest DIR] [--force] + validate [--strict] [--base auto|fixture|imported] + lint + pack [-o out.modpkg] + bounce [--seconds N] [--out DIR] + docs [--out DIR] + +Global flags: --repo PATH, --json, --quiet. +Exit codes: 0 success, 1 validation/lint failure, 2 usage error. + +validate drives the real engine loader headlessly (luajit, injected fs) so +a mod that passes here will not surface load errors in-game. --base auto +folds over the player's imported dataset when there is one and falls back +to the ROM-free fixture in tests/fixture_data/ otherwise, which is what +keeps the tool runnable on a CI box with no ROM. Which base ran matters to +MK103: only the imported dataset owns the real vanilla id space, so over the +fixture that rule is reported as skipped rather than guessed at. + +lint is the no-ROM-content distribution gate (MK3xx); pack runs both at +--strict, so any finding -- warning included -- refuses the package. +""" + +import argparse +import hashlib +import io +import json +import os +import re +import subprocess +import sys +import tempfile +import zipfile +from datetime import datetime, timezone + +MODKIT_VERSION = "1.0.0" + +LUAJIT = os.environ.get("MODKIT_LUAJIT", "luajit") + +IMAGE_EXTS = {".png"} +ASSET_EXTS = {".png", ".wav", ".bin"} +ROM_PATCH_EXTS = {".gb", ".gbc", ".ips", ".bps"} +SKIP_DIRS = {".git", ".modkit", "__pycache__", ".vscode"} + +GENERATED_MODULES = [ + "constants", "maps", "tilesets", "text", "text_pointers", + "trainer_headers", "font", "sprites", "pokemon", "moves", "items", + "type_chart", "trainers", "encounters", "field", "battle_anims", + "audio", "palettes", "icons", +] + + +# ---------------------------------------------------------------- findings + +class Finding: + def __init__(self, rule, severity, message, path=None): + self.rule = rule + self.severity = severity # "error" | "warn" + self.message = message + self.path = path + + def as_dict(self): + return {"rule": self.rule, "severity": self.severity, + "message": self.message, "path": self.path} + + def line(self): + where = f"{self.path}: " if self.path else "" + return f"{self.rule} {self.severity.upper():5} {where}{self.message}" + + +def report(findings, args, summary_ok, summary_fail, notes=None): + """notes are rules that could not run, not findings against the mod, so + --strict never promotes them and they never change the exit code.""" + notes = notes or [] + errors = [f for f in findings if f.severity == "error"] + warns = [f for f in findings if f.severity == "warn"] + if getattr(args, "strict", False): + errors, warns = errors + warns, [] + if args.json: + print(json.dumps({"ok": not errors, + "findings": [f.as_dict() for f in findings], + "notes": notes})) + else: + for f in findings: + print(f.line()) + if not args.quiet: + for note in notes: + print(f"modkit: {note}") + print(summary_fail if errors else summary_ok) + return 1 if errors else 0 + + +# ---------------------------------------------------------------- repo/root + +def find_repo(start): + node = os.path.abspath(start) + while True: + if os.path.isfile(os.path.join(node, "tools", "rom_manifest.json")): + return node + parent = os.path.dirname(node) + if parent == node: + return None + node = parent + + +def engine_version(repo): + src = open(os.path.join(repo, "src", "core", "Version.lua"), + encoding="utf-8").read() + match = re.search(r'engine\s*=\s*"([^"]+)"', src) + return match.group(1) if match else "1.0.0" + + +def known_permissions(repo): + """The vocabulary the engine itself enforces (Manifest.PERMISSIONS), read + from the source so a lint rule can never disagree with the loader.""" + try: + src = open(os.path.join(repo, "src", "mods", "Manifest.lua"), + encoding="utf-8").read() + except OSError: + return {"network", "filesystem", "engine_internals"} + block = re.search(r"Manifest\.PERMISSIONS\s*=\s*\{([^}]*)\}", src) + names = set(re.findall(r"(\w+)\s*=\s*true", block.group(1))) \ + if block else set() + return names or {"network", "filesystem", "engine_internals"} + + +def supported_requires(repo): + """The src.* modules the mod surface points authors at; requiring one of + these is not reaching past the API (Loader.lua SUPPORTED_REQUIRES).""" + try: + src = open(os.path.join(repo, "src", "mods", "Loader.lua"), + encoding="utf-8").read() + except OSError: + return {"src.mods.Semver", "src.audio.ChipAsm"} + block = re.search(r"SUPPORTED_REQUIRES\s*=\s*\{(.*?)\}", src, re.S) + names = set(re.findall(r'\["([^"]+)"\]', block.group(1))) \ + if block else set() + return names or {"src.mods.Semver", "src.audio.ChipAsm"} + + +def resolve_mod_dir(repo, arg): + if os.path.isdir(arg): + return os.path.abspath(arg) + candidate = os.path.join(repo, "mods", arg) + if os.path.isdir(candidate): + return candidate + return None + + +def mod_files(mod_dir): + """Sorted relative paths of everything a package would carry.""" + ignored = set() + ignore_file = os.path.join(mod_dir, ".modkitignore") + if os.path.isfile(ignore_file): + for line in open(ignore_file, encoding="utf-8"): + line = line.strip() + if line and not line.startswith("#"): + ignored.add(line) + out = [] + for base, dirs, files in os.walk(mod_dir): + dirs[:] = [d for d in dirs + if d not in SKIP_DIRS and not d.startswith(".")] + for name in files: + if name.startswith(".") and name != ".luarc.json": + continue + rel = os.path.relpath(os.path.join(base, name), mod_dir) + rel = rel.replace(os.sep, "/") + if rel in ignored or rel == ".modkitignore": + continue + out.append(rel) + return sorted(out) + + +def read_manifest(mod_dir): + path = os.path.join(mod_dir, "manifest.json") + if not os.path.isfile(path): + return None, Finding("MK001", "error", "manifest.json missing", + "manifest.json") + try: + manifest = json.load(open(path, encoding="utf-8")) + except ValueError as err: + return None, Finding("MK001", "error", + f"manifest.json unparseable: {err}", + "manifest.json") + mod_id = manifest.get("id") + if not isinstance(mod_id, str) or not re.fullmatch(r"[\w\-]+", mod_id): + return None, Finding("MK001", "error", + "manifest id must match ^[%w_-]+$", + "manifest.json") + return manifest, None + + +# ------------------------------------------------- permissions (MK005/MK006) + +def check_permissions(repo, manifest): + """MK005: every declared permission is from the engine's known set. The + loader turns this into a hard load failure for api 2 and a warning for + api 1, so naming it here is what makes the finding readable either way.""" + findings = [] + declared = manifest.get("permissions", []) + if declared is None: + return findings + if not isinstance(declared, list): + return [Finding("MK005", "error", + "permissions must be an array of strings", + "manifest.json")] + known = known_permissions(repo) + for name in declared: + if not isinstance(name, str) or name not in known: + findings.append(Finding( + "MK005", "error", + f"unknown permission {name!r}; the known set is " + + ", ".join(sorted(known)), "manifest.json")) + return findings + + +def strip_lua(body): + """Blanks comments so a commented-out example never trips a scan, keeping + line numbers intact. A string literal is stepped over rather than blanked + -- the module name a require scan is after IS a string -- so a `--` inside + a path is not read as a comment; the keyword itself is masked inside the + literal so prose quoting a require call cannot look like one.""" + out, index, size = [], 0, len(body) + long_open = re.compile(r"\[(=*)\[") + + def literal(text): + return text.replace("require", " " * len("require")) + + while index < size: + char = body[index] + if char in "\"'": + quote = char + start = index + index += 1 + while index < size: + if body[index] == "\\" and index + 1 < size: + index += 2 + continue + index += 1 + if body[index - 1] == quote: + break + out.append(literal(body[start:index])) + continue + comment = body.startswith("--", index) + opener = long_open.match(body, index + 2 if comment else index) + if comment: + if opener: + close = "]" + opener.group(1) + "]" + end = body.find(close, opener.end()) + chunk = (body[index:] if end < 0 + else body[index:end + len(close)]) + else: + end = body.find("\n", index) + chunk = body[index:] if end < 0 else body[index:end] + out.append("\n" * chunk.count("\n")) + index += len(chunk) + continue + if opener and opener.start() == index: + close = "]" + opener.group(1) + "]" + end = body.find(close, opener.end()) + chunk = body[index:] if end < 0 else body[index:end + len(close)] + out.append(literal(chunk)) + index += len(chunk) + continue + out.append(char) + index += 1 + return "".join(out) + + +REQUIRE_CALL = re.compile(r"""\brequire\s*\(?\s*["']([^"']+)["']""") + + +def check_requires(repo, mod_dir, manifest): + """MK006: a private require of an engine module the mod has no permission + for. Static rather than runtime because the loader's dev tripwire only + sees the requires that actually execute during the entry chunk, and a + require sitting inside a function body is the same reach past the API.""" + declared = manifest.get("permissions") or [] + granted = set(name for name in declared if isinstance(name, str)) \ + if isinstance(declared, list) else set() + supported = supported_requires(repo) + findings = [] + for rel in mod_files(mod_dir): + if os.path.splitext(rel)[1].lower() != ".lua": + continue + body = strip_lua(open(os.path.join(mod_dir, rel), encoding="utf-8", + errors="replace").read()) + for match in REQUIRE_CALL.finditer(body): + name = match.group(1).replace("/", ".") + # the link modules are the one place a mod reaches the wire, so + # network governs them; everything else under src. is internals + if name.startswith("src.link."): + needed = "network" + elif name.startswith("src.") and name not in supported: + needed = "engine_internals" + else: + continue + if needed in granted: + continue + line = body.count("\n", 0, match.start()) + 1 + findings.append(Finding( + "MK006", "warn", + f"private require of {name} without the {needed} permission; " + f"declare it in manifest.json or use the mod API instead", + f"{rel}:{line}")) + return findings + + +# ---------------------------------------------------------------- scaffold + +MANIFEST_TEMPLATE = """{ + "id": "{{id}}", + "name": "{{name}}", + "version": "0.1.0", + "api": 2, + "entry": "main.lua", + "profile": "{{profile}}", + "game_version": ">={{game_version}} <{{next_major}}.0.0", + "category": "GAMEPLAY", + "priority": 100, + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "TODO: one line about {{id}}"{{extra}} +} +""" + +MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). +-- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. +return function(mod) + -- patch, not override: every field you do not name keeps its base value + -- (learnset, sprites, evolutions all survive this speed change) + mod.content.pokemon:patch("MEW", { baseStats = { speed = 110 } }) + + -- mod.events:on("pokemon.caught", function(e) + -- mod.log:info("caught %s at L%d", e.species, e.level) + -- end) +end +""" + +MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). +return function(mod) + mod.options:define({ + { key = "difficulty", label = "DIFFICULTY", kind = "choice", + choices = { "normal", "hard" }, default = "normal" }, + }) + + -- register into content registries here; patch beats override for + -- anything you want to coexist with other mods + -- mod.content.moves:patch("BLIZZARD", { accuracy = 70 }) + + -- mod.hooks:wrap("battle.damage", function(next, ctx, damage) + -- return next(ctx, damage) + -- end) + -- mod.hooks:wrap("catch.rate", function(next, ctx, rate) + -- return next(ctx, rate) + -- end) +end +""" + +MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). +return function(mod) + -- the new game itself: spawn, names, money (field.boot, D11) + -- mod.content.field:patch("boot", { + -- startMap = "MY_TOWN", startX = 5, startY = 6, + -- playerName = "HERO", rivalName = "FOE", startMoney = 5000, + -- }) + + -- own the boot screens (Title/Intro) through the screens registry + -- mod.content.screens:register("MyTitle", { new = function(game) ... end }) +end +""" + +TRANSFORMS_TEMPLATE = """-- Asset transforms ({{id}}): derive art from the PLAYER'S own imported +-- cache at install time. Ship the recipe, never ROM-derived pixels -- +-- this file is the only sanctioned way to base art on vanilla assets. +return function(ctx) + -- local img = ctx.readImage("battle/front/mew.png") + -- ctx.recolor(img, { [2] = 3, [3] = 2 }) + -- ctx.writeImage(img, "battle/front/mew.png") +end +""" + +LUARC_TEMPLATE = """{ + "runtime.version": "LuaJIT", + "diagnostics.globals": ["love"] +} +""" + +README_TEMPLATE = """# {{name}} + +A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). + +## Layout + +- `manifest.json` - identity, version range, load order +- `main.lua` - the entry chunk; receives the `mod` object +{{layout_extra}} +## Loop + +1. `POKEPORT_DEV=1 love .` once, leave it running +2. edit, press F5 to hot-reload, backtick for the dev console +3. `python3 tools/modkit.py validate {{id}}` before sharing +4. `python3 tools/modkit.py pack mods/{{id}}` to ship +""" + + +def cmd_scaffold(args, repo): + profile = args.profile + dest_root = args.dest or os.path.join(repo, "mods") + dest = os.path.join(dest_root, args.id) + if not re.fullmatch(r"[\w\-]+", args.id): + print(f"modkit: bad id {args.id!r} (letters, numbers, _ or -)") + return 2 + if os.path.exists(dest) and not args.force: + print(f"modkit: {dest} exists (use --force to overwrite)") + return 2 + engine = engine_version(repo) + next_major = int(engine.split(".")[0]) + 1 + name = args.id.replace("_", " ").replace("-", " ").title() + + extra = "" + if profile == "total_conversion": + extra = ',\n "assets_transforms": "transforms.lua"' + subst = { + "{{id}}": args.id, "{{name}}": name, "{{profile}}": profile, + "{{game_version}}": engine, "{{next_major}}": str(next_major), + "{{extra}}": extra, + } + + def emit(rel, template): + path = os.path.join(dest, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + body = template + for key, value in subst.items(): + body = body.replace(key, value) + with open(path, "w", encoding="utf-8") as handle: + handle.write(body) + + main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, + "total_conversion": MAIN_TC}[profile] + layout_extra = "" + if profile == "total_conversion": + layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" + subst["{{layout_extra}}"] = layout_extra + + emit("manifest.json", MANIFEST_TEMPLATE) + emit("main.lua", main) + emit("README.md", README_TEMPLATE) + emit(".luarc.json", LUARC_TEMPLATE) + os.makedirs(os.path.join(dest, "assets"), exist_ok=True) + open(os.path.join(dest, "assets", ".gitkeep"), "w").close() + if profile == "total_conversion": + emit("transforms.lua", TRANSFORMS_TEMPLATE) + + if not args.quiet: + print(f"created {dest} ({profile} profile, api 2)") + print(f"next: python3 tools/modkit.py validate {args.id}") + return 0 + + +# ---------------------------------------------------------------- validate + +DRIVER_TEMPLATE = """-- generated by tools/modkit.py; drives the real loader headlessly +package.path = "./?.lua;./?/init.lua;" .. package.path +local data = %s +local FILES = %s +local overlay = {} +local function readDisk(path) + local disk = FILES[path] + if not disk then return nil end + local handle = io.open(disk, "rb") + if not handle then return nil end + local body = handle:read("*a") + handle:close() + return body +end +local fs = { + read = function(path) return overlay[path] or readDisk(path) end, + write = function(path, body) overlay[path] = body return true end, + createDirectory = function() return true end, + getInfo = function(path) + if overlay[path] or FILES[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(FILES) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + local body = overlay[path] or readDisk(path) + if not body then return nil, "no file: " .. path end + return loadstring(body, path) + end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(FILES) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, +} +local Loader = require("src.mods.Loader") +local Schemas = require("src.mods.Schemas") +-- MK103 needs the id space as it stood BEFORE the merge: a patch against a +-- missing id still folds to a value and lands in the target, so the merged +-- view cannot tell an orphan from a real record +local function resolvePath(root, path) + local node = root + for key in path:gmatch("[^%%.]+") do + if type(node) ~= "table" then return nil end + node = node[key] + end + return node +end +local baseIds = {} +for name, spec in pairs(Schemas.REGISTRIES) do + local set = {} + local target = spec.target and resolvePath(data, spec.target) + if type(target) == "table" then + if spec.baseIds then + for _, id in ipairs(spec.baseIds(target)) do set[id] = true end + else + for id in pairs(target) do set[id] = true end + end + end + baseIds[name] = set +end +local loader = Loader.new({ fs = fs }) +local ok, err = pcall(loader.load, loader, data) +-- one tab-separated record per finding; each field is scrubbed on its own so +-- the separators survive (a field that carried its own tab used to collapse +-- the whole row into one column) +local function row(kind, ...) + local parts = { kind } + for index = 1, select("#", ...) do + local field = tostring((select(index, ...))) + parts[#parts + 1] = (field:gsub("[\\t\\r\\n]", " ")) + end + print(table.concat(parts, "\\t")) +end +if not ok then row("ERR", err) end +-- record registries only: deep ones treat patch as register (a new key is +-- the point) and compose ones reject patch outright +for name, registry in pairs(loader.content) do + if registry.spec.semantics == "record" then + local known = baseIds[name] or {} + for id, list in pairs(registry.ops) do + local defined, patcher = known[id], nil + for _, entry in ipairs(list) do + if entry.op == "register" or entry.op == "override" then + defined = true + elseif entry.op == "patch" and entry.owner ~= Schemas.ENGINE then + patcher = patcher or entry.owner + end + end + if patcher and not defined then + row("ORPHAN", name, id, patcher) + end + end + end +end +local Logger = require("src.core.Logger") +for _, line in ipairs(Logger.history or {}) do + if line:find("ignored:", 1, true) then + row("IGN", line) + elseif line:find("^%%[warn%%]") then + row("WARN", line) + end +end +local status = loader:status() +for _, mod in ipairs(status.available) do + row("MOD", mod.id, mod.version, mod.state, mod.error or "") +end +for _, message in ipairs(status.errors) do + row("ERR", message) +end +""" + + +def classify_error(message, fallback="MK100"): + msg = message.lower() + # a reference stranded by a tombstone is its own rule; the generic + # dangling-ref test below would otherwise swallow it as MK102 + if "unresolved reference to removed" in msg: + return "MK104" + if "unresolved reference" in msg: + return "MK102" + if "unknown permission" in msg: + return "MK005" + if ("unknown field" in msg or "missing required field" in msg + or "expected" in msg): + return "MK101" + if "game version" in msg: + return "MK002" + if ("dependency" in msg or "circular" in msg): + return "MK003" + if "conflicts with" in msg: + return "MK004" + if "map_scripts" in msg: + return "MK201" + return fallback + + +FIXTURE_BASE = 'require("tests.fixture_data").load()' +IMPORTED_BASE = ('(function() local D = require("src.core.Data") ' + 'D:load() return D end)()') + + +def resolve_base(repo, choice): + """--base auto prefers the player's imported dataset and falls back to the + ROM-free fixture. Which one ran matters to MK103: the fixture is a + three-species stand-in, so a missing id there proves nothing and the rule + is skipped instead of reported.""" + if choice != "auto": + return choice + imported = os.path.join(repo, "data", "generated", "pokemon.lua") + return "imported" if os.path.isfile(imported) else "fixture" + + +def run_loader(repo, mod_dir, findings, base="fixture", notes=None): + """Drive the engine loader headlessly with the mod mounted; the base + dataset is the ROM-free fixture, or the imported cache with + --base imported (for mods that reference vanilla Red content). + + Rules that only the imported dataset can decide are skipped rather than + downgraded when the fixture stands in, and each one names itself in + notes so a skip is visible instead of silent.""" + mount = "mods/" + os.path.basename(mod_dir) + files = {} + for rel in mod_files(mod_dir): + files[f"{mount}/{rel}"] = os.path.join(mod_dir, rel) + entries = "".join( + " [%s] = %s,\n" % (lua_quote(k), lua_quote(v)) + for k, v in sorted(files.items())) + base = resolve_base(repo, base) + source = IMPORTED_BASE if base == "imported" else FIXTURE_BASE + driver = DRIVER_TEMPLATE % (source, "{\n" + entries + "}") + with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False, + encoding="utf-8") as handle: + handle.write(driver) + driver_path = handle.name + try: + proc = subprocess.run([LUAJIT, driver_path], cwd=repo, + capture_output=True, text=True, timeout=120) + except FileNotFoundError: + findings.append(Finding("MK100", "error", + f"cannot run {LUAJIT} (install luajit or " + "set MODKIT_LUAJIT)")) + return + finally: + os.unlink(driver_path) + if proc.returncode != 0: + findings.append(Finding("MK100", "error", + "loader driver crashed: " + + (proc.stderr or proc.stdout).strip()[-400:])) + return + # a failed mod reports the same message twice -- once in the error feed and + # once as its own state -- so the same rule/text pair is emitted once + seen = set() + skipped = set() + + def add(finding): + key = (finding.rule, finding.severity, finding.message) + if key in seen: + return + seen.add(key) + findings.append(finding) + + for line in proc.stdout.splitlines(): + parts = line.split("\t") + kind = parts[0] + if kind == "ERR" and len(parts) > 1: + message = parts[1] + # check_permissions already named this one against manifest.json, + # with the known set spelled out; the loader's echo adds nothing + if "unknown permission" in message: + continue + add(Finding(classify_error(message), "error", message)) + elif kind == "IGN" and len(parts) > 1: + if "unknown permission" in parts[1]: + continue + add(Finding(classify_error(parts[1], "MK001"), "error", parts[1])) + elif kind == "ORPHAN" and len(parts) >= 4: + registry, target, owner = parts[1], parts[2], parts[3] + # only the imported dataset owns the real vanilla id space. The + # fixture stands in for three species, so "not in base data" there + # is a fact about the fixture, not about the mod -- MK103 has no + # evidence either way and does not get to speak. Emitting it as a + # warning instead would still refuse the package, because pack and + # --strict promote every warning to fatal. + if base != "imported": + skipped.add("MK103") + continue + add(Finding( + "MK103", "error", + f"{owner}: patch target {target!r} exists in neither " + f"{registry} base data nor a dependency's registrations; " + f"check the id spelling or depend on the mod that " + f"registers it")) + elif kind == "WARN" and len(parts) > 1: + message = parts[1] + if "unresolved reference" in message: + # api 1 keeps cross-ref breakage at warning level; the rule id + # still has to distinguish a tombstone from a plain typo + add(Finding(classify_error(message), "warn", message)) + elif "did you mean" in message or "schema" in message: + add(Finding("MK101", "warn", message)) + elif kind == "MOD" and len(parts) >= 4: + mod_id, _version, state, error = (parts[1], parts[2], parts[3], + "\t".join(parts[4:])) + if state not in ("loaded", "disabled") and error: + add(Finding(classify_error(error), "error", + f"{mod_id}: {error}")) + if skipped and notes is not None: + notes.append( + "%s not checked: the ROM-free fixture base only stands in for " + "vanilla content, so it cannot tell a typo from a real id -- " + "re-run with --base imported to check %s" + % (", ".join(sorted(skipped)), + "them" if len(skipped) > 1 else "it")) + + +def lua_quote(text): + return '"' + (text.replace("\\", "\\\\").replace('"', '\\"')) + '"' + + +def cmd_validate(args, repo): + mod_dir = resolve_mod_dir(repo, args.mod) + if not mod_dir: + print(f"modkit: no mod at {args.mod!r}") + return 2 + findings = [] + notes = [] + manifest, problem = read_manifest(mod_dir) + if problem: + findings.append(problem) + else: + findings.extend(check_permissions(repo, manifest)) + run_loader(repo, mod_dir, findings, args.base, notes) + findings.extend(check_requires(repo, mod_dir, manifest)) + findings.extend(lint_dir(repo, mod_dir, manifest)) + name = manifest.get("id") if manifest else os.path.basename(mod_dir) + return report(findings, args, f"ok {name} valid", f"FAIL {name} invalid", + notes) + + +# ---------------------------------------------------------------- lint + +def ahash(image): + """Ink-mask hash over the 8x8 downscale: background (the lightest GB + shade) vs ink. Swapping the three ink shades -- the classic recolor -- + leaves the mask intact, which is exactly what MK302 wants to catch.""" + from PIL import Image + small = image.convert("L").resize((8, 8), Image.LANCZOS) + raw = (small.get_flattened_data() if hasattr(small, "get_flattened_data") + else small.getdata()) + return sum((1 << i) for i, p in enumerate(raw) if p <= 200) + + +def hamming(a, b): + return bin(a ^ b).count("1") + + +class CacheIndex: + """Hashes of the player's ROM-derived cache (assets/generated).""" + + def __init__(self, repo): + self.sha = {} + self.perceptual = [] + root = os.path.join(repo, "assets", "generated") + if not os.path.isdir(root): + return + try: + from PIL import Image + except ImportError: + Image = None + for base, _dirs, files in os.walk(root): + for name in files: + path = os.path.join(base, name) + rel = os.path.relpath(path, repo).replace(os.sep, "/") + body = open(path, "rb").read() + self.sha[hashlib.sha256(body).hexdigest()] = rel + if Image and os.path.splitext(name)[1].lower() in IMAGE_EXTS: + try: + with Image.open(io.BytesIO(body)) as img: + self.perceptual.append( + (rel, img.size, ahash(img))) + except Exception: + pass + + +def lint_dir(repo, mod_dir, manifest): + """MK3xx: the no-ROM-content gate (22-distribution-and-packaging.md).""" + findings = [] + manifest = manifest or {} + transforms_rel = manifest.get("assets_transforms") + has_transforms = bool(transforms_rel) + cache = CacheIndex(repo) + try: + from PIL import Image + except ImportError: + Image = None + + for rel in mod_files(mod_dir): + path = os.path.join(mod_dir, rel) + ext = os.path.splitext(rel)[1].lower() + # MK301: nothing may live in (or point into) the generated trees + if rel.startswith(("data/generated/", "assets/generated/")): + findings.append(Finding( + "MK301", "error", + "path shadows the player's ROM-derived cache", rel)) + continue + if ext in (".lua", ".json") and rel != transforms_rel: + body = open(path, encoding="utf-8", errors="replace").read() + if "assets/generated/" in body or "data/generated/" in body: + findings.append(Finding( + "MK301", "error", + "references the ROM-derived cache; ship your own asset " + "under assets/ or derive it via assets_transforms", rel)) + # MK303: ROM images and ROM-hack patch formats never ship + if ext in ROM_PATCH_EXTS: + findings.append(Finding( + "MK303", "error", "ROM/ROM-hack patch file", rel)) + continue + # MK304: raw chip-audio banks are ROM-derived + base = os.path.basename(rel) + if base == "programs.bin": + findings.append(Finding( + "MK304", "error", + "raw audio bank blob (author chip programs instead)", rel)) + continue + if ext == ".bin": + size = os.path.getsize(path) + if size >= 0x4000 and size % 0x4000 == 0: + findings.append(Finding( + "MK304", "error", + "bank-sized binary blob looks ROM-derived", rel)) + continue + # MK302: byte-identity and perceptual near-duplicates vs the cache + if ext in ASSET_EXTS: + body = open(path, "rb").read() + digest = hashlib.sha256(body).hexdigest() + twin = cache.sha.get(digest) + if twin: + findings.append(Finding( + "MK302", "error", + f"byte-identical to ROM-derived {twin}", rel)) + continue + if Image and ext in IMAGE_EXTS and cache.perceptual: + try: + with Image.open(io.BytesIO(body)) as img: + size, digest = img.size, ahash(img) + except Exception: + continue + for twin_rel, twin_size, twin_hash in cache.perceptual: + if size == twin_size and hamming(digest, twin_hash) <= 4: + severity = "warn" if has_transforms else "error" + remedy = ("allowed (ships assets_transforms)" + if has_transforms else + "ship it as an assets_transforms step " + "instead of a file") + findings.append(Finding( + "MK302", severity, + f"near-duplicate of ROM-derived {twin_rel} -- " + f"{remedy}", rel)) + break + # MK305: bulk dump of an imported data table + if (ext == ".lua" + and os.path.splitext(base)[0] in GENERATED_MODULES + and rel != transforms_rel and rel != "main.lua"): + finding = check_data_dump(repo, path, base, rel) + if finding: + findings.append(finding) + return findings + + +DUMP_DRIVER = """local function keysOf(path) + local handle = io.open(path, "rb") + if not handle then return nil end + local body = handle:read("*a") + handle:close() + local chunk = loadstring(body, path) + if not chunk then return nil end + setfenv(chunk, {}) + local ok, result = pcall(chunk) + if not ok or type(result) ~= "table" then return nil end + local keys = {} + for key in pairs(result) do + if type(key) == "string" then keys[#keys + 1] = key end + end + return keys +end +local shipped = keysOf(%s) +local vanilla = keysOf(%s) +if not shipped or not vanilla or #vanilla < 10 then return print("SKIP") end +local set = {} +for _, key in ipairs(shipped) do set[key] = true end +local hits = 0 +for _, key in ipairs(vanilla) do + if set[key] then hits = hits + 1 end +end +print(hits >= #vanilla * 0.8 and "DUMP" or "OK") +""" + + +def check_data_dump(repo, path, base, rel): + vanilla = os.path.join(repo, "data", "generated", base) + if not os.path.isfile(vanilla): + # no imported dataset to diff against; say so rather than pass + # silently, so a green run never implies this rule actually ran + return Finding("MK305", "warn", + f"dump check skipped: no imported data/generated/{base} " + "to diff against", rel) + driver = DUMP_DRIVER % (lua_quote(path), lua_quote(vanilla)) + try: + proc = subprocess.run([LUAJIT, "-e", driver], cwd=repo, + capture_output=True, text=True, timeout=60) + except FileNotFoundError: + # the gate must fail closed: a missing interpreter is a broken + # environment, not a clean mod + return Finding("MK100", "error", + f"cannot run {LUAJIT} for the dump check (install " + "luajit or set MODKIT_LUAJIT)", rel) + if proc.stdout.strip() == "DUMP": + return Finding("MK305", "error", + "bulk dump of an imported data table; register " + "individual records through the mod API", rel) + return None + + +def cmd_lint(args, repo): + mod_dir = resolve_mod_dir(repo, args.mod) + if not mod_dir: + print(f"modkit: no mod at {args.mod!r}") + return 2 + manifest, problem = read_manifest(mod_dir) + findings = [problem] if problem else [] + findings.extend(lint_dir(repo, mod_dir, manifest)) + name = os.path.basename(mod_dir) + return report(findings, args, f"ok {name}: no ROM-derived content", + f"FAIL {name}: ROM-content gate") + + +# ---------------------------------------------------------------- pack + +def cmd_pack(args, repo): + mod_dir = resolve_mod_dir(repo, args.mod) + if not mod_dir: + print(f"modkit: no mod at {args.mod!r}") + return 2 + manifest, problem = read_manifest(mod_dir) + if problem: + print(problem.line()) + return 1 + findings = list(check_permissions(repo, manifest)) + notes = [] + run_loader(repo, mod_dir, findings, args.base, notes) + findings.extend(check_requires(repo, mod_dir, manifest)) + findings.extend(lint_dir(repo, mod_dir, manifest)) + # pack runs validate --strict (20-developer-tooling.md 5), so a warning + # blocks distribution too: MK006 and the MK3xx gate are documented as + # unbypassable by the packaging path, which only holds if warnings bite + # here even though they are advisory under a bare validate. Notes are not + # findings -- a rule the fixture base could not run has nothing to say + # about the mod, so packing ROM-free stays possible (M13 criterion 4) + for f in findings: + print(f.line()) + if not args.quiet: + for note in notes: + print(f"modkit: {note}") + if findings: + if not args.quiet: + print("modkit: pack refused (pack runs validate --strict, so the " + "warnings above are fatal too)") + return 1 + + mod_id = manifest["id"] + version = manifest.get("version", "0.0.0") + out = args.output or f"{mod_id}-{version}.modpkg" + files = mod_files(mod_dir) + records = [] + for rel in files: + body = open(os.path.join(mod_dir, rel), "rb").read() + records.append({"path": rel, "bytes": len(body), + "sha256": hashlib.sha256(body).hexdigest()}) + pack_meta = { + "modkit": MODKIT_VERSION, + "packed_at": datetime.now(timezone.utc) + .strftime("%Y-%m-%dT%H:%M:%SZ"), + "id": mod_id, + "version": version, + "api": manifest.get("api", 1), + "engine_range": manifest.get("game_version", ""), + "files": records, + "lint": {"no_rom_content": "pass", "schema": "pass", + "cross_refs": "pass"}, + } + # normalized entry order + a fixed timestamp = reproducible archives + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive: + for rel in files: + info = zipfile.ZipInfo(rel, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + archive.writestr(info, + open(os.path.join(mod_dir, rel), "rb").read()) + info = zipfile.ZipInfo(".modkit/pack.json", + date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + archive.writestr(info, json.dumps(pack_meta, indent=2)) + if not args.quiet: + print(f"wrote {out} (reproducible, {len(files)} files " + "+ .modkit/pack.json)") + return 0 + + +# ---------------------------------------------------------------- bounce + +BOUNCE_DRIVER = """-- generated by tools/modkit.py bounce +package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") +-- the render seam reads programs.bin through love.filesystem; back it +-- with the real disk for this offline run +love.filesystem.read = function(path) + local handle = io.open(path, "rb") + if not handle then return nil, "no file: " .. path end + local body = handle:read("*a") + handle:close() + return body +end +love.filesystem.getInfo = function(path) + local handle = io.open(path, "rb") + if handle then handle:close() return { type = "file" } end + return nil +end +local Data = require("src.core.Data") +local ok, err = pcall(Data.load, Data) +if not ok then + io.stderr:write("bounce needs an imported dataset: " .. tostring(err) .. "\\n") + os.exit(3) +end +local ChipAudio = require("src.core.ChipAudio") +local songs = Data.audio and Data.audio.songs or {} +local WANTED = %s +local SECONDS = %d +local OUT = %s +local function isChip(def) + return type(def) == "table" + and (def.chip ~= nil or (def.address and def.bank) or def.program) +end +local function writeWav(path, sd) + local samples = sd:getSampleCount() + local channels = sd:getChannelCount() + local rate = sd:getSampleRate() + local dataBytes = samples * channels * 2 + local function u32(n) + return string.char(n %% 256, math.floor(n / 256) %% 256, + math.floor(n / 65536) %% 256, math.floor(n / 16777216) %% 256) + end + local function u16(n) + return string.char(n %% 256, math.floor(n / 256) %% 256) + end + local handle = assert(io.open(path, "wb")) + handle:write("RIFF", u32(36 + dataBytes), "WAVE") + handle:write("fmt ", u32(16), u16(1), u16(channels), u32(rate), + u32(rate * channels * 2), u16(channels * 2), u16(16)) + handle:write("data", u32(dataBytes)) + local chunk = {} + for index = 0, samples - 1 do + for channel = 1, channels do + local value = sd:getSample(index, channel) + local int = math.floor(value * 32767 + 0.5) + if int < -32768 then int = -32768 end + if int > 32767 then int = 32767 end + if int < 0 then int = int + 65536 end + chunk[#chunk + 1] = u16(int) + end + if #chunk >= 8192 then + handle:write(table.concat(chunk)) + chunk = {} + end + end + handle:write(table.concat(chunk)) + handle:close() +end +local ids = {} +if WANTED then + ids[1] = WANTED +else + for id in pairs(songs) do ids[#ids + 1] = id end + table.sort(ids) +end +local rendered, skipped = 0, 0 +for _, id in ipairs(ids) do + local def = songs[id] + if not def then + io.stderr:write("no such song: " .. id .. "\\n") + os.exit(1) + end + if isChip(def) then + local okRender, sd = pcall(ChipAudio._renderMusicForTest, Data, def, SECONDS) + if okRender and sd then + writeWav(OUT .. "/" .. id .. ".wav", sd) + print("wrote " .. OUT .. "/" .. id .. ".wav") + rendered = rendered + 1 + else + io.stderr:write("render failed for " .. id .. ": " .. tostring(sd) .. "\\n") + end + else + skipped = skipped + 1 + end +end +print(("bounced %%d songs (%%d file-based skipped)"):format(rendered, skipped)) +""" + + +def cmd_bounce(args, repo): + out_dir = args.out or os.path.join(repo, "bounce") + os.makedirs(out_dir, exist_ok=True) + wanted = "nil" if args.all else lua_quote(args.song) + driver = BOUNCE_DRIVER % (wanted, args.seconds, lua_quote(out_dir)) + with tempfile.NamedTemporaryFile("w", suffix=".lua", delete=False, + encoding="utf-8") as handle: + handle.write(driver) + driver_path = handle.name + try: + proc = subprocess.run([LUAJIT, driver_path], cwd=repo) + finally: + os.unlink(driver_path) + return 0 if proc.returncode == 0 else 1 + + +# ---------------------------------------------------------------- docs + +def cmd_docs(args, repo): + """Regenerates the registry reference by driving the Schemas-backed + generator, so the docs cannot drift from the engine.""" + proc = subprocess.run( + [LUAJIT, os.path.join("tools", "gen_registry_docs.lua")], cwd=repo) + if proc.returncode != 0: + return 1 + generated = os.path.join(repo, "docs", "modding", "reference", + "registries.md") + if args.out: + os.makedirs(args.out, exist_ok=True) + target = os.path.join(args.out, "registries.md") + with open(generated, encoding="utf-8") as src_handle, \ + open(target, "w", encoding="utf-8") as dst_handle: + dst_handle.write(src_handle.read()) + if not args.quiet: + print(f"copied to {target}") + return 0 + + +# ---------------------------------------------------------------- main + +def main(argv): + # global flags ride a parent parser so they work on either side of the + # subcommand (modkit --json validate x / modkit validate x --json); + # SUPPRESS keeps the subparser pass from clobbering a value the main + # parser already set (set_defaults would write the fallback back onto + # the shared actions and re-clobber, so absentees are filled post-parse) + shared = argparse.ArgumentParser(add_help=False) + shared.add_argument("--repo", default=argparse.SUPPRESS, + help="repo root override") + shared.add_argument("--json", action="store_true", + default=argparse.SUPPRESS) + shared.add_argument("--quiet", action="store_true", + default=argparse.SUPPRESS) + + parser = argparse.ArgumentParser(prog="modkit", parents=[shared]) + sub = parser.add_subparsers(dest="command") + + p = sub.add_parser("scaffold", parents=[shared]) + p.add_argument("id") + p.add_argument("--profile", default="content", + choices=["content", "overhaul", "total_conversion"]) + p.add_argument("--api", type=int, default=2) + p.add_argument("--dest") + p.add_argument("--force", action="store_true") + + p = sub.add_parser("validate", parents=[shared]) + p.add_argument("mod") + p.add_argument("--strict", action="store_true") + p.add_argument("--base", default="auto", + choices=["auto", "fixture", "imported"]) + + p = sub.add_parser("lint", parents=[shared]) + p.add_argument("mod") + + p = sub.add_parser("pack", parents=[shared]) + p.add_argument("mod") + p.add_argument("-o", "--output") + p.add_argument("--base", default="auto", + choices=["auto", "fixture", "imported"]) + + p = sub.add_parser("bounce", parents=[shared]) + p.add_argument("song", nargs="?") + p.add_argument("--all", action="store_true") + p.add_argument("--seconds", type=int, default=10) + p.add_argument("--out") + + p = sub.add_parser("docs", parents=[shared]) + p.add_argument("--out") + + args = parser.parse_args(argv) + for dest, fallback in (("repo", None), ("json", False), + ("quiet", False)): + if not hasattr(args, dest): + setattr(args, dest, fallback) + if not args.command: + parser.print_help() + return 2 + if args.command == "bounce" and not (args.song or args.all): + print("modkit: bounce needs a song id or --all") + return 2 + + repo = args.repo or find_repo(os.getcwd()) or find_repo( + os.path.dirname(os.path.abspath(__file__))) + if not repo: + print("modkit: cannot find the repo root " + "(looked for tools/rom_manifest.json)") + return 2 + repo = os.path.abspath(repo) + + handler = { + "scaffold": cmd_scaffold, + "validate": cmd_validate, + "lint": cmd_lint, + "pack": cmd_pack, + "bounce": cmd_bounce, + "docs": cmd_docs, + }[args.command] + return handler(args, repo) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index d99cae16..58aa54a0 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -20,6 +20,9 @@ local MonEditor = require("MonEditor") local App = {} local S +-- one loader per process: registries collide if a second load re-registers +-- vanilla records over an already-merged Data +local mods local mouseClicked = false local TABS = { @@ -73,6 +76,15 @@ local function applyLoaded(path, statusVerb) S._openArmed = false S.editingMon = nil require("src.pokemon.Boxes").ensure(S.save) + -- what the running game would quarantine, computed on a copy so the + -- editor never mutates the file behind the user's back + local SaveData = require("src.core.SaveData") + local probe = require("src.mods.Merge").deepCopy(S.save) + S.validation = SaveData.validate(probe, Data) + if not SaveData.emptyReport(S.validation) then + S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps", + #S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps) + end end -- pathOverride lets tests point App.load at a scratch file instead of the @@ -80,9 +92,23 @@ end function App.load(pathOverride) S = State.new() S.data = Data - Data:load() + -- the same mod set the game loads, merged into Data before the catalogs + -- build, so modded species/items/moves are editable and MonOps stops + -- asserting on them + if not mods then + Data:load() + local ModLoader = require("src.mods.Loader") + mods = ModLoader.new() + mods:load(Data) + end + S.mods = mods S.cat = Catalog.build(Data) - S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua") + local modRoots = {} + for _, mod in ipairs(S.mods:status().loaded) do + modRoots[#modRoots + 1] = mod.path + end + S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua", + nil, modRoots) applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded") end diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index 369713b3..d537fa10 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -17,7 +17,9 @@ function Catalog.build(data) } end -function Catalog.scrapeEvents(scriptDir, headerPath, listFiles) +-- extraDirs: loaded mods' roots, so MOD_-prefixed flags defined in mod +-- scripts show up beside the vanilla EVENT_ ones +function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) listFiles = listFiles or function(dir) local out = {} local p = io.popen(string.format('ls "%s"/*.lua 2>/dev/null', dir)) @@ -35,13 +37,22 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles) for name in text:gmatch("EVENT_[A-Z0-9_]+") do found[name] = true end + for name in text:gmatch("MOD_[A-Z0-9_]+") do + found[name] = true + end end - for _, path in ipairs(listFiles(scriptDir)) do - local f = io.open(path, "r") - if f then - eat(f:read("*a")) - f:close() + local dirs = { scriptDir } + for _, dir in ipairs(extraDirs or {}) do + dirs[#dirs + 1] = dir + end + for _, dir in ipairs(dirs) do + for _, path in ipairs(listFiles(dir)) do + local f = io.open(path, "r") + if f then + eat(f:read("*a")) + f:close() + end end end diff --git a/tools/save-editor/SaveIO.lua b/tools/save-editor/SaveIO.lua index 4ee082fa..76c2d81b 100644 --- a/tools/save-editor/SaveIO.lua +++ b/tools/save-editor/SaveIO.lua @@ -22,6 +22,9 @@ local function commandOutput(command) end function SaveIO.defaultPath() + -- same override conf.lua honors, so a mod-dev profile edits the save it + -- actually plays + local identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d" local home = os.getenv("HOME") or os.getenv("USERPROFILE") or "" local uname = io.popen and io.popen("uname -s 2>/dev/null") local sys = uname and uname:read("*l") or "" @@ -29,19 +32,19 @@ function SaveIO.defaultPath() if sys == "Darwin" then -- LÖVE identity folder lives under Application Support/LOVE/ return join(join(join(join(home, "Library"), "Application Support"), "LOVE"), - join("pokemon-love2d", "save.lua")) + join(identity, "save.lua")) end -- Linux LOVE default if home ~= "" and sys ~= "" then return join(join(join(home, ".local/share"), "love"), - join("pokemon-love2d", "save.lua")) + join(identity, "save.lua")) end -- Windows local appdata = os.getenv("APPDATA") if appdata then - return join(join(appdata, "love"), join("pokemon-love2d", "save.lua")) + return join(join(appdata, "love"), join(identity, "save.lua")) end - return join("pokemon-love2d", "save.lua") + return join(identity, "save.lua") end -- Native file picker (same approach as RomImporter). Returns an absolute