Compare commits

...

14 Commits

Author SHA1 Message Date
bryanthaboi 84de8c9cb1 Merge pull request #1306 from bryanthaboi/dev
for greg
2026-08-14 17:10:29 -04:00
bryanthaboi 6307bc92f6 Merge branch 'main' into dev 2026-08-14 17:07:19 -04:00
bryanthaboi 052dd26b3e Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-14 17:07:08 -04:00
bryanthaboi c8f6c7241b i did it for greg 2026-08-14 17:07:06 -04:00
bryanthaboi b3928388ef Merge pull request #1304 from ShaneMcGovernIE/agent/mod-storage-opaque-bytes
Add opaque byte storage to mod API
2026-08-14 16:59:06 -04:00
bryanthaboi a38fae5a97 Merge pull request #1302 from AverageConsumer/feat/mod-field-items
feat(mods): expose contextual field items
2026-08-14 16:57:16 -04:00
Shane McGovern 9fab992d42 Add opaque byte storage to mod API 2026-08-14 21:42:45 +01:00
AverageConsumer dcc388a942 feat(mods): expose contextual field items 2026-08-14 21:39:18 +02:00
bryanthaboi 797a6bebfe Merge pull request #1253 from mleo2003/padcursor-edge-scroll
launcher: pad cursor edge-scroll for stickless handhelds
2026-08-14 15:14:45 -04:00
bryanthaboi 97a9c0f58f Merge pull request #1286 from MaxTomahawk/adaptive-trainers/battle-party-scope
feat(mod-api): add trainer battle party scope
2026-08-14 15:14:31 -04:00
MaxTomahawk 407f649e9d fix(mod-api): harden deferred trainer preparation 2026-08-14 17:57:59 +02:00
github-actions 84635cdfdf chore(ios): update app-repo.json [skip ci] 2026-08-14 11:52:58 -04:00
MaxTomahawk a77210799f feat(mod-api): add trainer battle party scope 2026-08-14 17:33:17 +02:00
mleo2003 5192106730 launcher: pad cursor edge-scroll for stickless handhelds
The launcher's SHORT-WINDOW SCROLL engages correctly on a 480px-tall
panel, but _pageScroll has only three inputs -- mouse wheel, touch drag,
and the right analog stick -- and a device with none of them cannot
reach anything below the fold. On an RG35XXSP (muOS, 640x480)
/proc/bus/input/devices lists one gpio-keys-polled device reporting the
D-pad as a hat: no analog axes exist. There is also no scroll-to-focus
anywhere, so moving the pad cursor never touches _pageScroll.

The pad cursor already computes the motion: it is clamped to the safe
area and discarded. Spend that overshoot on the existing wheel path
instead -- the same one the right stick feeds a few lines below.

Only the overshoot scrolls, so parking the cursor at the edge does
nothing; it has to be actively pushed. The enclosing block runs only on
non-zero pad input, so a real mouse never reaches it and desktop
behaviour is unchanged.

Tested on an RG35XXSP at stock Kit.lua scale (0.9): the pager and footer
become reachable, pushing up at the top scrolls back, and parking at the
edge does nothing. Refs #1142.
2026-08-13 18:14:28 -07:00
31 changed files with 1488 additions and 74 deletions
+9 -1
View File
@@ -240,7 +240,7 @@ resolves to the weaker claim:
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed | | `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
| `absent` | deliberately not served; a nil read is the honest failure | | `absent` | deliberately not served; a nil read is the honest failure |
Today that is 288 backed, 32 warned and 161 absent across the fifteen modules. Today that is 291 backed, 32 warned and 161 absent across the fifteen modules.
`notes` keys are documentation topics rather than a member list -- dotted paths `notes` keys are documentation topics rather than a member list -- dotted paths
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and (`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
@@ -486,6 +486,9 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
**by name, before the first row runs**, so a mod never gets a half-run queue. **by name, before the first row runs**, so a mod never gets a half-run queue.
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no `marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
byte for it) and returns `nil, reason` rather than approximating one. byte for it) and returns `nil, reason` rather than approximating one.
`availableFieldActions` and `useFieldAction` expose the same contextual
bicycle and fishing records in both games. Each engine keeps ownership of its
inventory, terrain, surfing, bike, and fishing rules.
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name **Hooks and events that fire on Gold.** Every name below is the Gen 1 name
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
@@ -763,6 +766,11 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
The list is much shorter than it was. What is outstanding, in descending value: The list is much shorter than it was. What is outstanding, in descending value:
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
preparation boundary or a battle-local player-party view. Gen 1 mods can use
the hook documented in `docs/modding.md`; do not claim Gold compatibility
when that selection is required.
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its - `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
own yet. own yet.
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The - `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
+84 -11
View File
@@ -147,6 +147,20 @@ Companion UIs and alternate party screens can call
operation is accepted only during idle overworld play; menus, movement, operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched. scripts, battles, and transitions leave the party untouched.
## Contextual field items
`mod.world:availableFieldActions()` returns the field items that can start at
the player's current position. Red and Gold currently expose `bicycle` and
`fish`; fishing rows include the owned rods that are valid choices. The list
is empty while the world is busy, while riding states or terrain forbid an
action, or when the required item is not owned.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Invalid, stale, and
busy requests return `nil` plus a reason without changing game state. Mods do
not need generation-specific bike, collision, or fishing logic.
## Rendering pipelines ## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it Most registries hand the engine *content*. `render_pipelines` hands it
@@ -308,6 +322,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
``` ```
For independently generated binary data, use the opaque byte methods. They
accept and return the exact Lua string of bytes, including NUL bytes and bytes
that are not valid text:
```lua
local ok, code, message = mod.storage:writeBytes(
game, "cache/maps/pallet/terrain", encodedMesh)
local encodedMesh, code, message = mod.storage:readBytes(
game, "cache/maps/pallet/terrain")
```
Opaque values are limited to 512 MiB per key. The engine stores them without
decoding, compression, or an engine-defined file format, and never executes
them. A consuming mod owns validation of its format, fingerprint, checksum,
and compression metadata. Byte writes are staged and compared byte-for-byte
before replacement, and reads can recover a valid backup after an interrupted
write. Existing table values and opaque byte values use one shared logical key
space; delete a key before changing its value from one type to the other.
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine `context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays version is compatibility metadata; physical launcher-slot and path identity stays
private. A title-selected context may additionally contain `normalSavedAt`, the private. A title-selected context may additionally contain `normalSavedAt`, the
@@ -316,19 +349,22 @@ progress or a slot/path handle.
At the title screen only, `mod.storage:selected(game)` returns a bound storage At the title screen only, `mod.storage:selected(game)` returns a bound storage
facade for the launcher-selected existing playthrough, or `nil, code, message`. facade for the launcher-selected existing playthrough, or `nil, code, message`.
Resolving this facade is read-only: it never allocates an identity, adopts a Resolving this facade is non-allocating: it never allocates an identity, adopts a
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`, fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same `write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
data-only and transaction contract as `mod.storage`, but remain restricted to `list(prefix)`, and `delete(key)` methods have the same scoped and
the calling mod's selected existing namespace. It is intended for title tools transactional contract as `mod.storage`, but remain restricted to the calling
that need to browse or manage durable history before the first normal SAVE. mod's selected existing namespace. It is intended for title tools that need to
browse or manage durable history before the first normal SAVE.
Values must be tables containing serializable data only. Keys are conservative Table values must contain serializable data only. Opaque values must be Lua
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem strings. Keys are conservative slash-separated segments (letters, digits, `_`,
handles are never exposed. Writes are staged and decode-verified, reads recover `-`); paths and filesystem handles are never exposed. Table writes are staged
from a valid staged/backup generation, and methods return structured errors for and decode-verified; opaque writes are staged and byte-verified; reads recover
normal data or I/O failures. The playthrough identity is allocated lazily on the from a valid staged/backup generation. Methods return structured errors for
first storage/checkpoint call, so an unused API changes no save bytes. normal data, byte validation, and I/O failures. The playthrough identity is
allocated lazily on the first storage/checkpoint call, so an unused API changes
no save bytes.
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state: `mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
@@ -423,6 +459,43 @@ animation/messages, forced choices, and every phase that cannot safely be
checkpointed remain excluded. Exceptions are contained by normal hook isolation checkpointed remain excluded. Exceptions are contained by normal hook isolation
and fall through without advancing a turn. and fall through without advancing a turn.
Gen 1 trainer encounters also expose `trainer.before_battle` after the
challenge text and immediately before battle construction. This lets a mod
defer the encounter while it collects a player choice through a registered
screen, then resume with a battle-local view of the save party:
```lua
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId }
mod.ui.push(game, "party_registration", {
onConfirm = function(indices)
continue({ playerPartyIndices = indices })
end,
onCancel = function()
continue({ cancel = true })
end,
})
return true
end)
```
Return `true` only when retaining `continue` for a later callback. Calling
`continue({ cancel = true })` ends the encounter without constructing a battle;
the normal encounter completion callback returns control to the overworld and
no trainer-defeated state is written. A cancelled sight encounter is suppressed
at the current player cell so it cannot immediately reopen; moving one cell or
talking to the trainer permits a new challenge. Calling `continue()` uses the
full save party; passing
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
members for initial send, switching and forced replacement, exhaustion,
experience traversal, and battle party displays. The continuation is one-shot.
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
back to the full party. The view references the original Pokemon records and
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
the selected indices. Mods remain responsible for selection policy and should
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
contract and compatibility guarantees.
## Developer console ## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload Boot with developer mode on to unlock the in-game console and hot-reload
+7
View File
@@ -929,6 +929,10 @@ mod.content.sfx:register("SFX_MOD_CHIME", { file = "chime.ogg" })
| field | type | required | | field | type | required |
|---|---|---| |---|---|---|
| `anchorX` | number | no |
| `anchorY` | number | no |
| `frameHeight` | integer >= 1 | no |
| `frameWidth` | integer >= 1 | no |
| `frames` | integer >= 1 | yes | | `frames` | integer >= 1 | yes |
| `id` | string | no | | `id` | string | no |
| `image` | file path | yes | | `image` | file path | yes |
@@ -1100,6 +1104,7 @@ mod.content.tokens:register("CLOCK", function(game) return "12" end)
| `paletteSource` | string | no | | `paletteSource` | string | no |
| `parties` | list of list of {level, species} | yes | | `parties` | list of list of {level, species} | yes |
| `pic` | file path | no | | `pic` | file path | no |
| `trueColor` | boolean | no |
```lua ```lua
mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 }) mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })
@@ -1122,7 +1127,9 @@ do not.
| `index` | integer 0..255 | no | | `index` | integer 0..255 | no |
| `items` | list of items id | no | | `items` | list of items id | no |
| `name` | string | yes | | `name` | string | yes |
| `pic` | file path | no |
| `trainers` | list of {id?, index?, name, party, trainerType?} | yes | | `trainers` | list of {id?, index?, name, party, trainerType?} | yes |
| `trueColor` | boolean | no |
```lua ```lua
mod.content.trainers:patch("BEAUTY", { baseMoney = 99 }) mod.content.trainers:patch("BEAUTY", { baseMoney = 99 })
+2 -2
View File
@@ -325,7 +325,7 @@ This is not a dev-mode feature; it installs on any Gold boot that has mods.
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 | | `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 | | `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 |
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 | | `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 12 / 2 / 0 | | `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 |
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 | | `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 |
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 | | `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 |
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 | | `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
@@ -774,7 +774,7 @@ profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served - **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
out of a much larger engine, and within those 15 the coverage table records out of a much larger engine, and within those 15 the coverage table records
288 backed members against 32 warned and 161 absent. The absent ones are not 291 backed members against 32 warned and 161 absent. The absent ones are not
a backlog; most are absent because there is no honest Gen 2 answer, and each a backlog; most are absent because there is no honest Gen 2 answer, and each
one carries its reason. The counts move as the adapter learns something: a one carries its reason. The counts move as the adapter learns something: a
member that turns out to answer nil is demoted from backed to warned or member that turns out to answer nil is demoted from backed to warned or
+29 -6
View File
@@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
## The exact API delta ## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage` Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
facade to the calling mod id. Mods receive logical keys and decoded values, never facade to the calling mod id. Mods receive logical keys and either decoded table
filesystem handles or physical paths. values or exact opaque byte strings, never filesystem handles or physical paths.
### Lazy opaque playthrough identity ### Lazy opaque playthrough identity
@@ -75,6 +75,25 @@ Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
then backup data. A valid staged/backup value is returned and promoted then backup data. A valid staged/backup value is returned and promoted
best-effort; corrupt bytes are never executed. best-effort; corrupt bytes are never executed.
### `mod.storage:writeBytes(game, key, bytes)`
Accepts a Lua string containing opaque bytes and returns `true`, or
`false, code, message`. Empty strings are valid. Payloads are limited to 512
MiB per key. The engine writes the supplied bytes exactly as received, without
decoding, compression, checksums, or an engine-defined envelope. The consuming
mod owns semantic validation of its format.
Byte records use private `.bin`, `.bin.tmp`, and `.bin.bak` witnesses. A staged
and replacement write is read back and compared byte-for-byte before it is
committed. A failed write leaves the previous verified generation readable.
Byte storage never passes its payload to the Lua serializer, loader, or module
resolver.
Table and byte records share one logical key namespace and a key has one type.
Writing one type over the other returns `type_conflict`; callers must delete the
key before changing its type. `mod.storage:selected(game)` exposes the same
`readBytes` and `writeBytes` operations for the selected playthrough facade.
### `mod.storage:list(game[, prefix])` ### `mod.storage:list(game[, prefix])`
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
@@ -92,9 +111,9 @@ Physical records are scoped as:
`persistence root / mod_storage / game version / playthrough id / mod id` `persistence root / mod_storage / game version / playthrough id / mod id`
Stable error codes are `not_in_playthrough`, `storage_unavailable`, Stable error codes are `not_in_playthrough`, `storage_unavailable`,
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and `invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`,
`not_found`. Ordinary data and I/O failures are return values, not callback- `type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary
terminating errors. data and I/O failures are return values, not callback-terminating errors.
The restricted serializer's recursive writer runs outside LuaJIT traces. A The restricted serializer's recursive writer runs outside LuaJIT traces. A
1,000-process GC stress regression found compiled recursion could intermittently 1,000-process GC stress regression found compiled recursion could intermittently
@@ -107,6 +126,8 @@ boundary.
**Nothing.** No API is removed, no manifest field changes, and no storage path or **Nothing.** No API is removed, no manifest field changes, and no storage path or
playthrough id is created unless a mod invokes `mod.storage` or playthrough id is created unless a mod invokes `mod.storage` or
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path. `mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
Existing files outside the scoped storage contract are not imported; a caller
must rebuild them through `writeBytes`.
## Parity tests ## Parity tests
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
- **Engine identity:** lazy allocation, save/load preservation, stable legacy - **Engine identity:** lazy allocation, save/load preservation, stable legacy
mapping, fresh-playthrough replacement, and version/slot isolation. mapping, fresh-playthrough replacement, and version/slot isolation.
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip, - **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
opaque byte roundtrip including NUL bytes, no execution, size/type rejection,
deterministic listing, key rejection, mod/game/playthrough isolation, deterministic listing, key rejection, mod/game/playthrough isolation,
corrupt-main recovery, failure retention, exact delete, and no-mod no-write. corrupt-main recovery, failure retention, selected-playthrough access, exact
delete, and no-mod no-write.
## Deprecation etiquette ## Deprecation etiquette
@@ -0,0 +1,92 @@
# RFC 0010: Deferred trainer preparation and battle-local party scope
## Status
Proposed.
## Motivation
Challenge and tournament mods sometimes need a player to choose an eligible
subset of the save party before a trainer battle. The current public surface
can replace the opponent through `trainer.party` and observe
`world.trainer_engaged`, but it cannot pause the engagement before battle
construction or keep unselected save-party members out of initial send,
switch, replacement, exhaustion, experience, and party-menu traversal.
Temporarily rewriting `game.save.party` is not a safe substitute: it changes
authoritative save state, composes poorly with checkpoints and other mods, and
can strand excluded Pokémon if a callback or process fails.
## Decision and plan extended
This implements **D-AT-001: battle-local Gym registration without save-party
mutation**, the consuming design decision tracked as capability `AT-SP-001` in
the Adaptive Trainers implementation plan. The plan file is
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
Task 5. The engine delta also extends the additive, guarded public-hook
decision used by RFC 0007 and the screen facade documented in
`docs/modding.md`; it deliberately contains none of the consuming mod's Gym
or party-size policy.
## Exact API delta
Add the guarded hook:
```lua
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId }
-- Return true only when the battle has been deferred.
-- continue({ cancel = true }) returns without constructing a battle.
-- Call continue() for the full save party, or:
-- continue({ playerPartyIndices = { 2, 4, 5 } })
end)
```
The hook runs after the trainer's challenge text and immediately before the
trainer battle is constructed. A mod may push a registered screen with
`mod.ui.push`, return `true`, and retain `continue` for its confirm/cancel
callback. `continue` is one-shot and returns `false` after the first call.
Returning anything other than `true` without calling it continues immediately
with vanilla scope. With no subscriber, no context or continuation is built.
`playerPartyIndices` is an ordered, one-based list into `game.save.party`.
Valid unique indices create `battle.playerParty` as a battle-local view of the
same Pokémon records; the save party itself is never reordered or replaced.
Malformed or empty scopes degrade to the full party. The view governs initial
send, all battle party menus and targets, voluntary and forced replacement,
exhaustion/blackout checks, participant and EXP.ALL traversal, party counts,
and party-ball presentation. Checkpoints preserve the index list and rebuild
the same view before restoring battlers.
`{ cancel = true }` ends a deferred encounter through its normal completion
callback without constructing a battle or writing trainer-defeated state. A
cancelled sight encounter is suppressed while the player remains on the same
cell, preventing immediate reacquisition; moving or directly talking permits a
new challenge. Cancellation is also one-shot; if supplied alongside a party
index list, cancellation wins.
The API sets no maximum, chooses no members, identifies no boss, and contains
no scaling or challenge policy.
## Migration and compatibility
Existing mods change nothing. `BattleState.newTrainer(game, class, index)`
keeps its current behavior; the optional fourth argument is additive. Existing
battle checkpoints without a party scope restore against the full save party.
Wild, Safari, link, and no-mod battles are unchanged.
## Verification
- The catalog-driven hook gate proves empty-chain parity and the guarded hot
path proves no-mod engagement starts exactly once without allocation.
- A sandboxed fixture mod defers through its public hook facade, inspects the
data-only context, and resumes once with ordered indices.
- Engine tests cover initial send, party menus, replacement/exhaustion,
EXP traversal, invalid-scope fallback, and save-party identity.
- Battle-checkpoint tests prove scoped capture/restore and old-checkpoint
compatibility.
## Deprecation etiquette
Nothing is deprecated. The hook and optional constructor argument are
additive.
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8", "tintColor": "3b5ca8",
"category": "games", "category": "games",
"versions": [ "versions": [
{
"version": "0.1.86",
"date": "2026-08-14",
"size": 11306689,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.86/gen1recomp++-0.1.86-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1283 Intro broken and leading to softlock (R,B,Y currently unplayable without save file)\n\n## Contributors\n\n- @bryanthaboi"
},
{ {
"version": "0.1.85", "version": "0.1.85",
"date": "2026-08-14", "date": "2026-08-14",
+78 -14
View File
@@ -316,6 +316,27 @@ function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
return base and base.pic or nil return base and base.pic or nil
end end
-- trueColor on the trainer record, or on the basePic it reuses when the
-- subclass does not set the flag itself. Explicit false stays false.
function BattleState.trainerTrueColor(data, trainer)
if not trainer then return false end
if trainer.trueColor ~= nil then
return trainer.trueColor and true or false
end
local base = trainer.basePic and data and data.trainers
and data.trainers[trainer.basePic]
return (base and base.trueColor) and true or false
end
-- Load a trainer frontpic through getImage so a trueColor portrait skips
-- the 4-shade quantize the same way a species pic does.
function BattleState.trainerSprite(data, trainer, oppClass, partyIndex)
return getImage(
BattleState.trainerPicPath(data, trainer, oppClass, partyIndex),
BattleState.trainerPalette(data, trainer),
BattleState.trainerTrueColor(data, trainer))
end
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the -- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB -- SetAnimationBGPalette effects remap the four BG shades; on the SGB
-- the colorizer then colors the REMAPPED shade, so a faded pic shows -- the colorizer then colors the REMAPPED shade, so a faded pic shows
@@ -617,6 +638,44 @@ local function newBattle(game)
return self return self
end end
local function scopedPlayerParty(game, indices)
if indices == nil then return nil, nil end
if type(indices) ~= "table" then
Logger.warn("trainer battle party scope is not a table; using full party")
return nil, nil
end
local count = #indices
local keyCount = 0
for key in pairs(indices) do
keyCount = keyCount + 1
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then
Logger.warn("trainer battle party scope is malformed; using full party")
return nil, nil
end
end
if count == 0 or keyCount ~= count then
Logger.warn("trainer battle party scope is empty or sparse; using full party")
return nil, nil
end
local party, normalized, seen = {}, {}, {}
for i = 1, count do
local index = indices[i]
if type(index) ~= "number" or index % 1 ~= 0
or not game.save.party[index] or seen[index] then
Logger.warn("trainer battle party scope contains an invalid index; using full party")
return nil, nil
end
seen[index] = true
normalized[i] = index
party[i] = game.save.party[index]
end
return party, normalized
end
function BattleState:playerPartyView()
return self.playerParty or self.game.save.party
end
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText -- opts.hooked: rod encounter, announced with _HookedMonAttackedText
function BattleState.newWild(game, species, level, opts) function BattleState.newWild(game, species, level, opts)
local self = newBattle(game) local self = newBattle(game)
@@ -692,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
end end
end end
function BattleState.newTrainer(game, oppClass, partyIndex) function BattleState.newTrainer(game, oppClass, partyIndex, opts)
local self = newBattle(game) local self = newBattle(game)
self.kind = "trainer" self.kind = "trainer"
self.oppClass = oppClass self.oppClass = oppClass
-- the object_event trainer arg (roster index). computeMusicKind keys -- the object_event trainer arg (roster index). computeMusicKind keys
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782). -- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1 self.partyIndex = partyIndex or 1
self.playerParty, self.playerPartyIndices = scopedPlayerParty(game,
type(opts) == "table" and opts.playerPartyIndices or nil)
self.trainer = game.data.trainers[oppClass] self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
@@ -742,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
end end
end end
self.enemyIndex = 1 self.enemyIndex = 1
local playerMon = Party.firstHealthy(game.save.party) local playerMon = Party.firstHealthy(self:playerPartyView())
if not playerMon then if not playerMon then
Logger.warn("trainer battle with no healthy party; skipping") Logger.warn("trainer battle with no healthy party; skipping")
self.dead = true self.dead = true
@@ -756,9 +817,8 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes -- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage( self.trainerPic = BattleState.trainerSprite(
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex), game.data, self.trainer, oppClass, partyIndex)
BattleState.trainerPalette(game.data, self.trainer))
self.introText = Strings("%s wants\nto fight!", self.trainer.name) self.introText = Strings("%s wants\nto fight!", self.trainer.name)
return self return self
end end
@@ -2002,7 +2062,7 @@ function BattleState:update(dt)
-- loops the party menu until a healthy mon is picked, so B and -- loops the party menu until a healthy mon is picked, so B and
-- fainted picks land back here and reopen it -- fainted picks land back here and reopen it
if self.player.mon.hp <= 0 then if self.player.mon.hp <= 0 then
if Party.firstHealthy(self.game.save.party) then if Party.firstHealthy(self:playerPartyView()) then
self:openReplacementMenu() self:openReplacementMenu()
end end
return return
@@ -3890,7 +3950,8 @@ function BattleState:awardExp()
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- the surviving participants are counted and paid -- the surviving participants are counted and paid
local participants, alive = 0, {} local participants, alive = 0, {}
for _, mon in ipairs(self.game.save.party) do local playerParty = self:playerPartyView()
for _, mon in ipairs(playerParty) do
if self.participants and self.participants[mon] then if self.participants and self.participants[mon] then
participants = participants + 1 participants = participants + 1
if mon.hp > 0 then table.insert(alive, mon) end if mon.hp > 0 then table.insert(alive, mon) end
@@ -3984,9 +4045,9 @@ function BattleState:awardExp()
-- experience.asm:9-13); each mon gets its own GainedText with the -- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line -- summary line
for _, mon in ipairs(self.game.save.party) do for _, mon in ipairs(playerParty) do
if mon.hp > 0 then if mon.hp > 0 then
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll") ctx.applyShare(mon, math.max(1, ctx.participants) * #playerParty * 2, "expAll")
end end
end end
end end
@@ -4027,7 +4088,7 @@ function BattleState:enemyMonFainted()
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift") local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower() :lower()
local partyCount = #self.game.save.party local partyCount = #self:playerPartyView()
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into -- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
@@ -4054,6 +4115,7 @@ function BattleState:enemyMonFainted()
local game = self.game local game = self.game
Screens.push(game, "PartyMenu", { Screens.push(game, "PartyMenu", {
battle = self, battle = self,
party = self:playerPartyView(),
forceSwitch = true, forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then if mon ~= self.player.mon and mon.hp > 0 then
@@ -4226,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self)
end end
function BattleState:playerMonFainted() function BattleState:playerMonFainted()
local nextMon = Party.firstHealthy(self.game.save.party) local nextMon = Party.firstHealthy(self:playerPartyView())
-- Being out of useable POKéMON blacks you out even when the battle was -- Being out of useable POKéMON blacks you out even when the battle was
-- already decided in our favour. A double faint -- our last mon dying -- already decided in our favour. A double faint -- our last mon dying
-- to residual damage on the turn it lands the KO -- used to hit the -- to residual damage on the turn it lands the KO -- used to hit the
@@ -4299,6 +4361,7 @@ function BattleState:openReplacementMenu()
self:ui(function() self:ui(function()
return self:buildScreen("PartyMenu", { return self:buildScreen("PartyMenu", {
battle = self, battle = self,
party = self:playerPartyView(),
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL) -- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true, forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
@@ -4779,6 +4842,7 @@ function BattleState:openParty()
self:ui(function() self:ui(function()
return self:buildScreen("PartyMenu", { return self:buildScreen("PartyMenu", {
battle = self, battle = self,
party = self:playerPartyView(),
onSwitch = function(mon) onSwitch = function(mon)
if mon == self.player.mon then if mon == self.player.mon then
self:say(Strings("%s is\nalready out!", self.player.name)) self:say(Strings("%s is\nalready out!", self.player.name))
@@ -4825,8 +4889,8 @@ function BattleState:finish()
-- here it did not, so say so rather than silently papering over it. -- here it did not, so say so rather than silently papering over it.
-- The old-man / PROF.OAK demo also skips it: the party never fought -- The old-man / PROF.OAK demo also skips it: the party never fought
-- (Yellow's Pallet intro runs before the player owns a mon at all). -- (Yellow's Pallet intro runs before the player owns a mon at all).
if self.result ~= "lose" and not self.demo if self.kind ~= "link" and self.result ~= "lose" and not self.demo
and not Party.firstHealthy(self.game.save.party) then and not Party.firstHealthy(self:playerPartyView()) then
Logger.warn("battle finished %s with no healthy party; forcing blackout", Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result)) tostring(self.result))
self.result = "lose" self.result = "lose"
@@ -5707,7 +5771,7 @@ function BattleState:drawHUDs(slide)
for i = 10, 17 do hudTile(0x76, i * 8, 88) end for i = 10, 17 do hudTile(0x76, i * 8, 88) end
hudTile(0x6F, 72, 88) hudTile(0x6F, 72, 88)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) self:drawBallRow(self:playerPartyView(), 88, 80, 8)
end end
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
if showStatus and self.player and not hidePlayer and not self.showPlayerBack if showStatus and self.player and not hidePlayer and not self.showPlayerBack
+38 -5
View File
@@ -44,6 +44,7 @@ local BATTLE_FIELDS = {
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall", "sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed", "lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
"endBattleText", "endBattleText",
"playerPartyIndices",
} }
local function partyIndex(party, mon) local function partyIndex(party, mon)
@@ -91,6 +92,23 @@ local function integer(value, min, max)
and value >= (min or -math.huge) and value <= (max or math.huge) and value >= (min or -math.huge) and value <= (max or math.huge)
end end
local function exactIndexSet(indices, maxIndex, requireMember)
if type(indices) ~= "table" then return nil end
local count, keys = #indices, 0
for key in pairs(indices) do
keys = keys + 1
if not integer(key, 1, count) then return nil end
end
if keys ~= count or (requireMember and count == 0) then return nil end
local seen = {}
for i = 1, count do
local index = indices[i]
if not integer(index, 1, maxIndex) or seen[index] then return nil end
seen[index] = true
end
return seen
end
local function validateMoveList(data, moves) local function validateMoveList(data, moves)
if type(moves) ~= "table" then return false end if type(moves) ~= "table" then return false end
for _, move in ipairs(moves) do for _, move in ipairs(moves) do
@@ -200,6 +218,16 @@ function BattleCheckpoint.validate(game, checkpoint)
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
return nil, "invalid_content", "Player battle state is invalid." return nil, "invalid_content", "Player battle state is invalid."
end end
local scopedIndices
if model.playerPartyIndices ~= nil then
if model.kind ~= "trainer" then
return nil, "invalid_checkpoint", "Battle party scope is invalid."
end
scopedIndices = exactIndexSet(model.playerPartyIndices, #party, true)
if not scopedIndices or not scopedIndices[model.player.index] then
return nil, "invalid_checkpoint", "Battle party scope is invalid."
end
end
if model.kind == "wild" then if model.kind == "wild" then
if not validateMon(game.data, model.enemyMon) if not validateMon(game.data, model.enemyMon)
or not validateBattler(game.data, model.enemy, 1) then or not validateBattler(game.data, model.enemy, 1) then
@@ -220,12 +248,15 @@ function BattleCheckpoint.validate(game, checkpoint)
end end
end end
for _, indices in ipairs({ model.participants, model.leveledUp }) do for _, indices in ipairs({ model.participants, model.leveledUp }) do
if type(indices) ~= "table" then local referenced = exactIndexSet(indices, #party, false)
if not referenced then
return nil, "invalid_checkpoint", "Battle party reference set is missing." return nil, "invalid_checkpoint", "Battle party reference set is missing."
end end
for _, index in ipairs(indices) do if scopedIndices then
if not integer(index, 1, #party) then for index in pairs(referenced) do
return nil, "invalid_checkpoint", "Battle party reference is invalid." if not scopedIndices[index] then
return nil, "invalid_checkpoint", "Battle party reference is invalid."
end
end end
end end
end end
@@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
local model = checkpoint.runtime.battle local model = checkpoint.runtime.battle
local battle local battle
if model.kind == "trainer" then if model.kind == "trainer" then
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex) battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex, {
playerPartyIndices = model.playerPartyIndices,
})
battle.enemyParty = assert(copy(model.enemyParty)) battle.enemyParty = assert(copy(model.enemyParty))
battle.enemyIndex = model.enemyIndex battle.enemyIndex = model.enemyIndex
else else
+14
View File
@@ -2356,6 +2356,20 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny)) self._padCursor.y = math.max(oy, math.min(oy + h, ny))
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
-- The cursor is clamped to the safe area above, so on a short window the
-- rows below the fold are unreachable on a stickless handheld: no mouse
-- wheel, no touchscreen, and no right stick to feed the existing wheel
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
-- nothing, it has to be actively pushed -- and this block only runs on pad
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
-- LauncherView.draw multiplies back out.
local overY = 0
if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end
if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer. -- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
-- NX: the getPosition bridge already returns pad coords — skip setPosition. -- NX: the getPosition bridge already returns pad coords — skip setPosition.
if not self.isNX and love.mouse.setPosition then if not self.isNX and love.mouse.setPosition then
+6 -3
View File
@@ -737,7 +737,8 @@ COVERAGE["src.pokemon.Boxes"] = {
COVERAGE["src.world.WorldAPI"] = { COVERAGE["src.world.WorldAPI"] = {
kind = "alias", target = "src.world.gen2.WorldAPI", kind = "alias", target = "src.world.gen2.WorldAPI",
backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock " backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
.. "spawnNpc removeNpc npc queueScript invalidateMap", .. "spawnNpc removeNpc npc queueScript invalidateMap "
.. "availableFieldActions useFieldAction",
warned = "setFlag getFlag", warned = "setFlag getFlag",
absent = "", absent = "",
notes = { notes = {
@@ -1797,7 +1798,8 @@ local function buildBattleState()
"throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage", "throwBall", "ballChain", "tossAnimFor", "ballFlicker", "ballMissMessage",
"storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow", "storeCaughtMon", "safariAction", "safariEnemyTurn", "drawBallRow",
"drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize", "drawClassic", "isWideBattleLayout", "wideLayout", "bgMode", "uiSize",
"sgbPalettes", "trainerPalette", "trainerPicPath", "invalidate", "sgbPalettes", "trainerPalette", "trainerPicPath", "trainerTrueColor",
"trainerSprite", "invalidate",
"imageBattleScale", "resolveBattleScale", "backPlacement", "imageBattleScale", "resolveBattleScale", "backPlacement",
"frontPlacement", "StatBox", "enter", "exit", "frontPlacement", "StatBox", "enter", "exit",
}) do }) do
@@ -1884,7 +1886,8 @@ COVERAGE["src.battle.BattleState"] = {
absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn " absent = "newWild newTrainer makeSafari makeGhost makeBattler resolveTurn "
.. "computeDamage catchAttempt runRoll enter exit sgbPalettes " .. "computeDamage catchAttempt runRoll enter exit sgbPalettes "
.. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite " .. "isWideBattleLayout wideLayout bgMode uiSize letterboxWhite "
.. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath invalidate " .. "holdsUIAnchors BG_WORLD_DIM trainerPalette trainerPicPath "
.. "trainerTrueColor trainerSprite invalidate "
.. "backPlacement frontPlacement StatBox drawClassic drawBallRow " .. "backPlacement frontPlacement StatBox drawClassic drawBallRow "
.. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset " .. "safariAction safariEnemyTurn throwBall storeCaughtMon field ruleset "
.. "rng oppClass partyIndex aiUses introText dead", .. "rng oppClass partyIndex aiUses introText dead",
+6 -1
View File
@@ -1069,7 +1069,8 @@ function Loader:_api(mod)
bucket[key] = value bucket[key] = value
end, end,
}, },
-- Data-only state independent of the vanilla progress checkpoint. The -- Data-only and opaque-byte state independent of the vanilla progress
-- checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence; -- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle. -- callers never receive paths or a raw filesystem handle.
storage = { storage = {
@@ -1077,6 +1078,10 @@ function Loader:_api(mod)
selected = function(_, game) return storage:selected(game) end, selected = function(_, game) return storage:selected(game) end,
write = function(_, game, key, value) return storage:write(game, key, value) end, write = function(_, game, key, value) return storage:write(game, key, value) end,
read = function(_, game, key) return storage:read(game, key) end, read = function(_, game, key) return storage:read(game, key) end,
writeBytes = function(_, game, key, bytes)
return storage:writeBytes(game, key, bytes)
end,
readBytes = function(_, game, key) return storage:readBytes(game, key) end,
list = function(_, game, prefix) return storage:list(game, prefix) end, list = function(_, game, prefix) return storage:list(game, prefix) end,
delete = function(_, game, key) return storage:delete(game, key) end, delete = function(_, game, key) return storage:delete(game, key) end,
}, },
+8
View File
@@ -1017,6 +1017,9 @@ R.trainers = {
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
-- unused vanilla classes ship without a pic, so it cannot be required -- unused vanilla classes ship without a pic, so it cannot be required
pic = f.opt(f.path), pic = f.opt(f.path),
-- Full-color portrait: skip the 4-shade SGB/GBC remap, same flag pokemon
-- and sprites already carry.
trueColor = f.opt(f.bool),
-- Optional Advanced-mode OBJ palette source for a custom trainer portrait. -- Optional Advanced-mode OBJ palette source for a custom trainer portrait.
-- It follows the same ROM crosswalk form as sprites.paletteSource. -- It follows the same ROM crosswalk form as sprites.paletteSource.
paletteSource = f.opt(f.str), paletteSource = f.opt(f.str),
@@ -1068,6 +1071,11 @@ R.trainers = {
gen2Fields = { gen2Fields = {
id = f.opt(f.str), name = f.str, id = f.opt(f.str), name = f.str,
index = f.opt(f.int(0, 255)), index = f.opt(f.int(0, 255)),
-- class frontpic; when set, this wins over menu_gfx.battleHud.trainerPics
pic = f.opt(f.path),
-- Full-color portrait: skip the GBC 4-shade remap, same flag Gen 1
-- trainers and pokemon already carry.
trueColor = f.opt(f.bool),
baseMoney = f.opt(f.int(0)), baseMoney = f.opt(f.int(0)),
-- the class's battle theme; Gen 1 spells the same idea `battleTheme`, -- the class's battle theme; Gen 1 spells the same idea `battleTheme`,
-- but this is the extractor's own key and a strict rename would reject -- but this is the extractor's own key and a strict rename would reject
+132 -11
View File
@@ -1,4 +1,5 @@
-- Data-only per-mod persistence, scoped by game version and opaque playthrough. -- Data-only and opaque-byte per-mod persistence, scoped by game version and
-- opaque playthrough.
-- This module is engine-private; Loader exposes only the bound facade methods. -- This module is engine-private; Loader exposes only the bound facade methods.
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
@@ -7,6 +8,7 @@ local Version = require("src.core.Version")
local Storage = {} local Storage = {}
Storage.__index = Storage Storage.__index = Storage
Storage.MAX_BYTES = 512 * 1024 * 1024
local ROOT = "mod_storage" local ROOT = "mod_storage"
@@ -49,6 +51,20 @@ local function decodeAt(fs, path)
return data, body return data, body
end end
local function readOpaqueAt(fs, path)
if not (fs.getInfo and fs.getInfo(path)) then return nil end
local body = fs.read and fs.read(path)
if type(body) ~= "string" then return nil end
return body
end
local function hasAny(fs, paths)
for _, path in ipairs(paths) do
if fs.getInfo(path) then return true end
end
return false
end
function Storage.new(modId, fs) function Storage.new(modId, fs)
assert(validSegment(modId), "Storage.new needs a safe mod id") assert(validSegment(modId), "Storage.new needs a safe mod id")
return setmetatable({ modId = modId, injectedFs = fs }, Storage) return setmetatable({ modId = modId, injectedFs = fs }, Storage)
@@ -132,6 +148,10 @@ function Storage:selected(game)
end, end,
read = function(_, key) return self:read(selectedGame, key) end, read = function(_, key) return self:read(selectedGame, key) end,
write = function(_, key, value) return self:write(selectedGame, key, value) end, write = function(_, key, value) return self:write(selectedGame, key, value) end,
readBytes = function(_, key) return self:readBytes(selectedGame, key) end,
writeBytes = function(_, key, bytes)
return self:writeBytes(selectedGame, key, bytes)
end,
list = function(_, prefix) return self:list(selectedGame, prefix) end, list = function(_, prefix) return self:list(selectedGame, prefix) end,
delete = function(_, key) return self:delete(selectedGame, key) end, delete = function(_, key) return self:delete(selectedGame, key) end,
} }
@@ -147,7 +167,7 @@ function Storage:context(game)
} }
end end
function Storage:_names(game, key, allowEmpty) function Storage:_names(game, key, allowEmpty, extension)
if not validKey(key, allowEmpty) then if not validKey(key, allowEmpty) then
return failure("invalid_key", return failure("invalid_key",
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.") "Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
@@ -155,12 +175,19 @@ function Storage:_names(game, key, allowEmpty)
local scope, code, message = self:_scope(game) local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end if not scope then return nil, code, message end
local path = scope.base .. (key ~= "" and ("/" .. key) or "") local path = scope.base .. (key ~= "" and ("/" .. key) or "")
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" extension = extension or ".lua"
return scope, path .. extension, path .. extension .. ".bak",
path .. extension .. ".tmp", path
end end
function Storage:write(game, key, value) function Storage:write(game, key, value)
local scope, main, bak, tmp = self:_names(game, key, false) local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return false, main, bak end if not scope then return false, main, bak end
local fs = scope.fs
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
return false, "type_conflict",
"A byte value already exists for this storage key; delete it first."
end
if type(value) ~= "table" then if type(value) ~= "table" then
return false, "encode_failed", "Storage values must be data-only tables." return false, "encode_failed", "Storage values must be data-only tables."
end end
@@ -170,7 +197,6 @@ function Storage:write(game, key, value)
.. tostring(encoded) .. tostring(encoded)
end end
local fs = scope.fs
ensureParent(fs, main) ensureParent(fs, main)
local _, previous = decodeAt(fs, main) local _, previous = decodeAt(fs, main)
if not previous then _, previous = decodeAt(fs, bak) end if not previous then _, previous = decodeAt(fs, bak) end
@@ -206,9 +232,13 @@ function Storage:write(game, key, value)
end end
function Storage:read(game, key) function Storage:read(game, key)
local scope, main, bak, tmp = self:_names(game, key, false) local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return nil, main, bak end if not scope then return nil, main, bak end
local fs = scope.fs local fs = scope.fs
if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then
return failure("type_mismatch",
"This storage key contains opaque bytes; use readBytes instead.")
end
local data, body = decodeAt(fs, main) local data, body = decodeAt(fs, main)
if data then return data end if data then return data end
@@ -226,6 +256,80 @@ function Storage:read(game, key)
return data return data
end end
function Storage:writeBytes(game, key, bytes)
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
if not scope then return false, main, bak end
if type(bytes) ~= "string" then
return false, "invalid_bytes", "Opaque storage values must be strings."
end
if #bytes > Storage.MAX_BYTES then
return false, "size_limit",
("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES)
end
local fs = scope.fs
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
return false, "type_conflict",
"A table value already exists for this storage key; delete it first."
end
ensureParent(fs, main)
local previous = readOpaqueAt(fs, main)
if previous == nil then previous = readOpaqueAt(fs, bak) end
local ok, err = fs.write(tmp, bytes)
if not ok then
return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err)
end
local staged = readOpaqueAt(fs, tmp)
if staged == nil or staged ~= bytes then
remove(fs, tmp)
return false, "verify_failed", "Staged opaque storage data could not be verified."
end
if previous ~= nil then fs.write(bak, previous) end
ok, err = fs.write(main, bytes)
if not ok then
remove(fs, tmp)
return false, "write_failed",
"Could not replace opaque storage data: " .. tostring(err)
end
local verified = readOpaqueAt(fs, main)
if verified == nil or verified ~= bytes then
remove(fs, main)
remove(fs, tmp)
return false, "verify_failed",
"Replacement opaque storage data could not be verified."
end
fs.write(bak, bytes)
remove(fs, tmp)
return true
end
function Storage:readBytes(game, key)
local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin")
if not scope then return nil, main, bak end
local fs = scope.fs
if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then
return failure("type_mismatch",
"This storage key contains table data; use read instead.")
end
local bytes = readOpaqueAt(fs, main)
if bytes ~= nil then return bytes end
bytes = readOpaqueAt(fs, tmp)
if bytes == nil then bytes = readOpaqueAt(fs, bak) end
if bytes == nil then
return nil, "not_found", "No valid opaque value exists for this key."
end
ensureParent(fs, main)
if fs.write(main, bytes) then fs.write(bak, bytes) end
remove(fs, tmp)
return bytes
end
function Storage:list(game, prefix) function Storage:list(game, prefix)
prefix = prefix or "" prefix = prefix or ""
local scope, main, codeOrBak = self:_names(game, prefix, true) local scope, main, codeOrBak = self:_names(game, prefix, true)
@@ -237,13 +341,23 @@ function Storage:list(game, prefix)
local base = scope.base local base = scope.base
local start = prefix == "" and base or (base .. "/" .. prefix) local start = prefix == "" and base or (base .. "/" .. prefix)
local out = {} local out, seen = {}, {}
local function add(logical)
if not seen[logical] then
seen[logical] = true
out[#out + 1] = logical
end
end
local function walk(path, logical) local function walk(path, logical)
local info = fs.getInfo(path) local info = fs.getInfo(path)
if not info then return end if not info then return end
if info.type == "file" then if info.type == "file" then
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end local suffix = path:sub(-4)
if suffix == ".lua" or suffix == ".bin" then
add(logical:sub(1, -5))
end
return return
end end
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
@@ -254,7 +368,9 @@ function Storage:list(game, prefix)
-- A prefix may identify one exact key or a directory of keys. -- A prefix may identify one exact key or a directory of keys.
if fs.getInfo(start .. ".lua") then if fs.getInfo(start .. ".lua") then
out[#out + 1] = prefix add(prefix)
elseif fs.getInfo(start .. ".bin") then
add(prefix)
else else
walk(start, prefix) walk(start, prefix)
end end
@@ -263,15 +379,20 @@ function Storage:list(game, prefix)
end end
function Storage:delete(game, key) function Storage:delete(game, key)
local scope, main, bak, tmp = self:_names(game, key, false) local scope, main, bak, tmp, path = self:_names(game, key, false)
if not scope then return false, main, bak end if not scope then return false, main, bak end
local fs = scope.fs local fs = scope.fs
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp"
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)
or fs.getInfo(byteMain) or fs.getInfo(byteBak) or fs.getInfo(byteTmp)) then
return false, "not_found", "No stored value exists for this key." return false, "not_found", "No stored value exists for this key."
end end
remove(fs, main) remove(fs, main)
remove(fs, bak) remove(fs, bak)
remove(fs, tmp) remove(fs, tmp)
remove(fs, byteMain)
remove(fs, byteBak)
remove(fs, byteTmp)
return true return true
end end
+1
View File
@@ -383,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
local def = game.data.items[id] local def = game.data.items[id]
local opts = { local opts = {
pickOnly = true, pickOnly = true,
battle = battle,
-- HP medicine animates its bar with the picker still up (#252). Only -- HP medicine animates its bar with the picker still up (#252). Only
-- out of battle: the in-battle tail closes the bag list underneath -- out of battle: the in-battle tail closes the bag list underneath
-- first, which needs the picker already gone. -- first, which needs the picker already gone.
+7 -3
View File
@@ -98,14 +98,14 @@ function OakSpeech.resolvePic(game, desc, speech)
local t = desc.type local t = desc.type
if t == "trainer" then if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false, false return speech.oakPic, false, speech.oakTrueColor or false
end end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false, false return speech.rivalPic, false, speech.rivalTrueColor or false
end end
local trainers = game.data.trainers or {} local trainers = game.data.trainers or {}
local tr = trainers[desc.id] local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false, false return tryImage(tr and tr.pic), false, tr and tr.trueColor or false
elseif t == "pokemon" then elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
@@ -241,7 +241,11 @@ function OakSpeech.new(game, onDone)
self.answers = {} self.answers = {}
local trainers = game.data.trainers or {} local trainers = game.data.trainers or {}
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic) self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
self.oakTrueColor = self.oakPic
and trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.trueColor or false
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic) self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
self.rivalTrueColor = self.rivalPic
and trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.trueColor or false
local oakGfx = (game.data.field and game.data.field.oakSpeech) or {} local oakGfx = (game.data.field and game.data.field.oakSpeech) or {}
self.cfg = oakGfx self.cfg = oakGfx
-- the show-off mon and the name length cap come from data; the vanilla -- the show-off mon and the name length cap come from data; the vanilla
+3 -2
View File
@@ -289,6 +289,7 @@ function PartyMenu.new(game, opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, PartyMenu) local self = setmetatable({}, PartyMenu)
self.game = game self.game = game
local party = opts.party or (opts.battle and opts.battle.playerParty)
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from -- PartyMenuInit (home/pokemon.asm) seeds the cursor from
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and -- wPartyAndBillsPCSavedMenuItem rather than from zero, and
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every -- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
@@ -297,7 +298,7 @@ function PartyMenu.new(game, opts)
-- both zero the byte, which BattleState mirrors. The clamp covers a -- both zero the byte, which BattleState mirrors. The clamp covers a
-- party that shrank (deposit / release) while the saved index was -- party that shrank (deposit / release) while the saved index was
-- pointing past the end. #768 -- pointing past the end. #768
local count = #(opts.party or (game.save and game.save.party) or {}) local count = #(party or (game.save and game.save.party) or {})
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1), self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
math.max(1, count)) math.max(1, count))
self.onSwitch = opts.onSwitch self.onSwitch = opts.onSwitch
@@ -314,7 +315,7 @@ function PartyMenu.new(game, opts)
self.tmhm = opts.tmhm self.tmhm = opts.tmhm
self.forceSwitch = opts.forceSwitch self.forceSwitch = opts.forceSwitch
self.battle = opts.battle self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies self.party = party -- link/scoped battles pass their local party view
self.swapFrom = nil self.swapFrom = nil
self.submenu = nil self.submenu = nil
self.subIndex = 1 self.subIndex = 1
+23 -4
View File
@@ -185,6 +185,19 @@ function BattleState:statusHUDVisible()
self) ~= false self) ~= false
end end
-- Class frontpic for the battle intro. A trainers-registry `pic` wins over
-- the extracted menu_gfx sheet; `trueColor` skips the GBC 4-shade remap.
-- Returns path, trueColor.
function BattleState.trainerArt(data, classId)
if not classId then return nil, false end
local classes = data and data.gen2Trainers and data.gen2Trainers.classes
local classDef = classes and classes[classId]
local hud = data and data.gen2MenuGfx and data.gen2MenuGfx.battleHud
local path = (classDef and classDef.pic)
or (hud and hud.trainerPics and hud.trainerPics[classId])
return path, (classDef and classDef.trueColor) and true or false
end
-- opts: battle (a Battle), onDone(outcome), save -- opts: battle (a Battle), onDone(outcome), save
function BattleState.new(game, opts) function BattleState.new(game, opts)
opts = opts or {} opts = opts or {}
@@ -308,6 +321,7 @@ function BattleState.new(game, opts)
-- pic is a cache asset, so an import made before the extractor grew that -- pic is a cache asset, so an import made before the extractor grew that
-- stage has none and the mon stands in for the whole intro. -- stage has none and the mon stands in for the whole intro.
self.showEnemyTrainer = false self.showEnemyTrainer = false
self.enemyTrainerTrueColor = false
-- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the -- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the
-- pic up in are keyed by: menu_gfx's trainerPics is written out of -- pic up in are keyed by: menu_gfx's trainerPics is written out of
-- constants.trainerClassOrder, and palettes.trainers out of the same names. -- constants.trainerClassOrder, and palettes.trainers out of the same names.
@@ -317,17 +331,19 @@ function BattleState.new(game, opts)
-- no palette for every trainer the world starts, which is all of them. -- no palette for every trainer the world starts, which is all of them.
-- `classId` is the trainers.lua key, i.e. the constant; `className` is the -- `classId` is the trainers.lua key, i.e. the constant; `className` is the
-- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all. -- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all.
-- A class record's own `pic` / `trueColor` (the trainers registry) wins
-- over the extracted sheet, so a mod can drop in full-color art.
local enemyTrainer = self.battle and self.battle.trainer local enemyTrainer = self.battle and self.battle.trainer
self.enemyTrainerClass = enemyTrainer self.enemyTrainerClass = enemyTrainer
and (enemyTrainer.classId or enemyTrainer.class) and (enemyTrainer.classId or enemyTrainer.class)
local trainerPics = hudGfx and hudGfx.trainerPics local trainerPath, trainerTrueColor =
local trainerPath = self.enemyTrainerClass and trainerPics BattleState.trainerArt(data, self.enemyTrainerClass)
and trainerPics[self.enemyTrainerClass]
if trainerPath then if trainerPath then
local ok, image = pcall(Assets.image, trainerPath) local ok, image = pcall(Assets.image, trainerPath)
if ok and image then if ok and image then
self.enemyTrainerImage = image self.enemyTrainerImage = image
self.enemyTrainerPath = trainerPath self.enemyTrainerPath = trainerPath
self.enemyTrainerTrueColor = trainerTrueColor and true or false
self.showEnemyTrainer = true self.showEnemyTrainer = true
end end
end end
@@ -588,7 +604,10 @@ function BattleState:drawPic(mon, back)
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848). -- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
local enemyTrainer = (not back) and self.showEnemyTrainer local enemyTrainer = (not back) and self.showEnemyTrainer
and self.enemyTrainerImage and self.enemyTrainerImage
if enemyTrainer then image, path = enemyTrainer, self.enemyTrainerPath end if enemyTrainer then
image, path = enemyTrainer, self.enemyTrainerPath
trueColor = self.enemyTrainerTrueColor
end
if not image then return end if not image then return end
local side = back and "player" or "enemy" local side = back and "player" or "enemy"
local anim = self:animPicState(side) local anim = self:animPicState(side)
+88 -4
View File
@@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts)
-- a fresh entry, or a stale flag can freeze player input forever -- a fresh entry, or a stale flag can freeze player input forever
self.engaging = false self.engaging = false
self.emote = nil self.emote = nil
self.cancelledTrainerSight = nil
-- volatile WRAM state in pokered; never serialize across save/load -- volatile WRAM state in pokered; never serialize across save/load
self.wildEncounterGraceSteps = 0 self.wildEncounterGraceSteps = 0
-- survives save/load: a loaded game may start inside a building whose -- survives save/load: a loaded game may start inside a building whose
@@ -762,6 +763,27 @@ function OverworldState:bikeAllowed(mapId)
return false return false
end end
-- Field-item entry points keep presentation and state transitions in the
-- owning world instead of asking a supported facade to reproduce either one.
function OverworldState:useBicycle()
local name = Game.save.player.name
if Game.save.onBike then
if Game.save.forcedBike then return false end
Game.save.onBike = false
require("src.core.Music").playMap(Game.data, self.map.id, false)
Game.stack:push(TextBox.new(Game,
Strings("%s got off\nthe BICYCLE.", name)))
elseif self:bikeAllowed(self.map.id) and not self.player.surfing then
Game.save.onBike = true
require("src.core.Music").playMap(Game.data, self.map.id, true)
Game.stack:push(TextBox.new(Game,
Strings("%s got on\nthe BICYCLE!", name)))
else
return false
end
return true
end
-- The battle transition's dungeon wipe uses the explicit map lists in -- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions -- inclusive map-id ranges -- faithful to the original's omissions
@@ -1725,6 +1747,12 @@ function OverworldState:goFishing(rod)
end)) end))
end end
function OverworldState:useFishingRod(rod)
if self.player.surfing or not self:facingIsShoreOrWater() then return false end
self:goFishing(rod)
return true
end
-- Fly to a visited town (called from the party menu). -- Fly to a visited town (called from the party menu).
function OverworldState:flyTo(mapId) function OverworldState:flyTo(mapId)
local spot = Game.data.field.flyWarps[mapId] local spot = Game.data.field.flyWarps[mapId]
@@ -3092,6 +3120,32 @@ local function meetTrainerTheme(cls)
or "Music_MeetMaleTrainer" or "Music_MeetMaleTrainer"
end end
-- Public pre-trainer gate. A mod may retain continueBattle while a registered
-- preparation screen is on top, then resume once with an optional ordered
-- save-party index scope. The hook is cold on a no-mod boot.
function OverworldState.prepareTrainerBattle(game, context, startBattle,
cancelBattle)
if not Runtime.wantsHook("trainer.before_battle") then
startBattle()
return false
end
local started = false
local function continueBattle(options)
if started then return false end
started = true
if type(options) == "table" and options.cancel == true then
if cancelBattle then cancelBattle() end
else
startBattle(options)
end
return true
end
local deferred = Runtime.call("trainer.before_battle",
function() return false end, game, context, continueBattle)
if deferred ~= true and not started then continueBattle() end
return deferred == true
end
-- Run the pre-battle text -> battle -> won text -> flags sequence. -- Run the pre-battle text -> battle -> won text -> flags sequence.
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript -- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE -- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE
@@ -3119,7 +3173,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
or (header and header.won and Game.data.text[header.won]) or (header and header.won and Game.data.text[header.won])
local BattleState = require("src.battle.BattleState") local BattleState = require("src.battle.BattleState")
local function startBattle() local function startBattle(options)
self.cancelledTrainerSight = nil
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp -- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting -- StartTrainerBattle`, so a trainer challenged on foot gets the sting
@@ -3134,7 +3189,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
local theme = meetTrainerTheme(d.trainerClass) local theme = meetTrainerTheme(d.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end if theme then require("src.core.Music").play(Game.data, theme) end
end end
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
options)
battle.checkpointOrigin = { battle.checkpointOrigin = {
kind = "trainer_encounter", kind = "trainer_encounter",
map = self.map.id, map = self.map.id,
@@ -3171,10 +3227,31 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
end end
self:pushBattle(battle) self:pushBattle(battle)
end end
local function prepareBattle()
if not Runtime.wantsHook("trainer.before_battle") then
startBattle()
return
end
OverworldState.prepareTrainerBattle(Game, {
trainerClass = d.trainerClass,
partyIndex = d.trainerParty or 1,
mapId = self.map.id,
npcId = npc.id,
}, startBattle, function()
if self.player then
self.cancelledTrainerSight = {
npcId = npc.id,
playerX = self.player.cellX,
playerY = self.player.cellY,
}
end
if onDone then onDone() end
end)
end
if skipBattleText then if skipBattleText then
startBattle() prepareBattle()
else else
Game.stack:push(TextBox.new(Game, battleText, startBattle)) Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
end end
end end
@@ -3372,11 +3449,18 @@ function OverworldState:checkTrainerSight()
if self.player.moving or self.engaging then return end if self.player.moving or self.engaging then return end
if Game.stack:top() ~= self then return end if Game.stack:top() ~= self then return end
local p = self.player local p = self.player
local cancelled = self.cancelledTrainerSight
if cancelled and (cancelled.playerX ~= p.cellX
or cancelled.playerY ~= p.cellY) then
self.cancelledTrainerSight = nil
cancelled = nil
end
for _, npc in ipairs(self.npcs) do for _, npc in ipairs(self.npcs) do
local d = npc.def local d = npc.def
-- CheckFightingMapTrainers engages ANY aligned trainer sprite, -- CheckFightingMapTrainers engages ANY aligned trainer sprite,
-- walkers included (they sight between steps) -- walkers included (they sight between steps)
if d.trainerClass and not npc.moving if d.trainerClass and not npc.moving
and not (cancelled and cancelled.npcId == npc.id)
and not self:trainerDefeated(npc) and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text) and not mapScripts.talkScript(self.map.id, d.text)
and trainerSpriteOnScreen(npc, p) then and trainerSpriteOnScreen(npc, p) then
+55
View File
@@ -15,6 +15,7 @@ local WorldAPI = {}
WorldAPI.__index = WorldAPI WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld" local NO_OVERWORLD = "no overworld"
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local function acceptsMenuInput(game, ow) local function acceptsMenuInput(game, ow)
local stack = game and game.stack local stack = game and game.stack
@@ -86,6 +87,60 @@ function WorldAPI:reorderParty(fromSlot, toSlot)
return true return true
end end
-- Contextual field-item shortcuts. Only actions that can start immediately
-- are listed; callers receive copied labels and never inspect world internals.
function WorldAPI:availableFieldActions()
local game, ow, out = self.game, self:overworld(), {}
if not (game and game.save and ow and ow.map and ow.player)
or not acceptsMenuInput(game, ow) then return out end
local save, inventory = game.save, game.save.inventory or {}
local items = game.data and game.data.items or {}
if (inventory.BICYCLE or 0) > 0 and not ow.player.surfing
and not (save.onBike and save.forcedBike)
and (save.onBike or ow:bikeAllowed(ow.map.id)) then
out[#out + 1] = { id = "bicycle",
label = save.onBike and "BIKE OFF" or "BICYCLE" }
end
if not ow.player.surfing and ow:facingIsShoreOrWater() then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
local def = items[id]
rods[#rods + 1] = { id = id, label = def and def.name or id }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
if ow:useBicycle() then return true end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod and ow:useFishingRod(rod) then return true end
end
return nil, "fishing rod unavailable"
end
return nil, "field action unavailable"
end
-- A compact, read-only view of the active map for minimaps and companion UIs. -- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real -- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). -- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
+75
View File
@@ -27,11 +27,15 @@ local Movement = require("src.script.gen2.Movement")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local HiddenItems = require("src.world.gen2.HiddenItems") local HiddenItems = require("src.world.gen2.HiddenItems")
local MapOverview = require("src.world.MapOverview") local MapOverview = require("src.world.MapOverview")
local Bike = require("src.world.gen2.Bike")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Permissions = require("src.world.gen2.Permissions")
local WorldAPI = {} local WorldAPI = {}
WorldAPI.__index = WorldAPI WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld" local NO_OVERWORLD = "no overworld"
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
function WorldAPI.new(game, modId) function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI) return setmetatable({ game = game, modId = modId }, WorldAPI)
@@ -52,6 +56,77 @@ function WorldAPI:current()
facing = p and p.facing } facing = p and p.facing }
end end
local function itemLabel(game, id)
local def = game and game.data and game.data.items
and game.data.items[id]
return (def and def.name) or id
end
-- The same field-item contract as Gen 1, resolved through Gold's own bike,
-- collision and fishing rules.
function WorldAPI:availableFieldActions()
local world, game, out = self:overworld(), self.game, {}
if not (world and game and game.save and world.map and world.player)
or not world:acceptsMenuInput() then return out end
local inventory = game.save.inventory or {}
if (inventory.BICYCLE or 0) > 0 then
local bike = Bike.tryBike({
state = world.playerState,
environment = world.map.def and world.map.def.environment,
collision = world:playerCollision(),
alwaysOnBike = world:alwaysOnBike(),
})
if bike == "mount" or bike == "dismount" then
out[#out + 1] = { id = "bicycle",
label = bike == "dismount" and "BIKE OFF" or "BICYCLE" }
end
end
local context = world:fieldContext()
if not FieldMoves.isSurfing(world.playerState)
and Permissions.isWater(context.facingColl) then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
rods[#rods + 1] = { id = id, label = itemLabel(game, id) }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not world:acceptsMenuInput() then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
local outcome = world:useFieldItem("BICYCLE")
if outcome and outcome ~= "nowhere" then return true end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod then
local outcome = world:useFieldItem(rod)
if outcome and outcome ~= "nowhere" then return true end
break
end
end
return nil, "fishing rod unavailable"
end
return nil, "field action unavailable"
end
-- The same read-only minimap contract as Gen 1, with Gold's object/event -- The same read-only minimap contract as Gen 1, with Gold's object/event
-- visibility rules supplying the semantic markers. -- visibility rules supplying the semantic markers.
function WorldAPI:mapOverview() function WorldAPI:mapOverview()
+131
View File
@@ -0,0 +1,131 @@
-- Trainer battles may use a battle-local view of save-party records without
-- mutating, reordering, or hiding those records in the authoritative save.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("trainer battle party scope")
local BattleState = require("src.battle.BattleState")
local BagMenu = require("src.ui.BagMenu")
local Fixtures = require("tests.modkit").fixtures
local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Bag = require("src.inventory.Bag")
local Data = Fixtures.fresh()
Data.items.POTION = { id = "POTION", index = 99, name = "POTION",
price = 300, tossable = true }
local function makeGame()
local save = SaveData.newGame()
save.party = {
Pokemon.new(Data, "FIXMON_A", 10),
Pokemon.new(Data, "FIXMON_B", 11),
Pokemon.new(Data, "FIXMON_C", 12),
}
local stack = { states = {} }
function stack:push(value) self.states[#self.states + 1] = value 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 }
end
local game = makeGame()
local originalParty = game.save.party
local first, second, third = unpack(originalParty)
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1, {
playerPartyIndices = { 2, 3 },
})
T.check(game.save.party == originalParty,
"scoping never replaces the authoritative save-party table")
T.check(game.save.party[1] == first and game.save.party[2] == second
and game.save.party[3] == third,
"scoping never reorders authoritative save-party records")
T.same(battle.playerPartyIndices, { 2, 3 },
"the battle records normalized save-party indices")
T.check(battle.playerParty[1] == second and battle.playerParty[2] == third,
"the local party view contains the same selected Pokemon records")
T.check(battle.player.mon == second,
"initial send chooses the first healthy scoped member")
local menu = PartyMenu.new(game, { battle = battle })
T.check(menu.party == battle.playerParty,
"battle party menus traverse only the local eligible view")
Bag.add(game.save, "POTION", 1)
local bag = BagMenu.new(game, { battle = battle })
local potion
for _, row in ipairs(bag.items) do
if row.value == "POTION" then potion = row; break end
end
T.check(potion ~= nil, "the fixture potion is available for target selection")
bag.onChoose(potion, bag)
local targetPicker = game.stack:top()
T.check(targetPicker and targetPicker.party == battle.playerParty,
"in-battle item target selection traverses only eligible members")
second.hp = 0
battle.player.mon.hp = 0
battle:playerMonFainted()
T.eq(battle.result, nil,
"a healthy scoped replacement prevents premature exhaustion")
third.hp = 0
battle:playerMonFainted()
T.eq(battle.result, "lose",
"an excluded healthy save-party member cannot prevent scoped exhaustion")
T.check(first.hp > 0, "the excluded save-party member remains untouched")
local expGame = makeGame()
expGame.save.inventory.EXP_ALL = 1
local expBattle = BattleState.newTrainer(expGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
local excludedExp = expGame.save.party[1].exp
local participantExp = expGame.save.party[2].exp
local sharedExp = expGame.save.party[3].exp
expBattle.participants = { [expGame.save.party[2]] = true }
expBattle:awardExp()
T.eq(expGame.save.party[1].exp, excludedExp,
"EXP.ALL cannot award an excluded save-party member")
T.check(expGame.save.party[2].exp > participantExp,
"a scoped participant receives battle experience")
T.check(expGame.save.party[3].exp > sharedExp,
"EXP.ALL traverses other eligible scoped members")
local fallbackGame = makeGame()
local fallback = BattleState.newTrainer(fallbackGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 0, 99, 1.5, 0 } })
T.eq(fallback.playerParty, nil,
"a malformed or empty scope degrades to the vanilla full-party path")
T.eq(fallback.player.mon, fallbackGame.save.party[1],
"invalid scope fallback preserves vanilla initial send")
local partialGame = makeGame()
local partial = BattleState.newTrainer(partialGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 99 } })
T.eq(partial.playerParty, nil,
"one invalid member makes the entire scope fall back")
local duplicateGame = makeGame()
local duplicate = BattleState.newTrainer(duplicateGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 2 } })
T.eq(duplicate.playerParty, nil,
"duplicate members make the entire scope fall back")
local malformedOptionsGame = makeGame()
local malformedOptions = BattleState.newTrainer(malformedOptionsGame,
"OPP_FIX_YOUNGSTER", 1, 7)
T.eq(malformedOptions.playerParty, nil,
"a malformed options value degrades to the vanilla full-party path")
local linkGame = makeGame()
local linkBattle = BattleState.newTrainer(linkGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
linkBattle.kind = "link"
linkBattle.result = "guestWin"
linkGame.save.party[2].hp, linkGame.save.party[3].hp = 0, 0
linkBattle:finish()
T.eq(linkBattle.result, "guestWin",
"link spectator outcomes are not rewritten by trainer eligibility scope")
T.finish()
@@ -13,6 +13,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit") local T = require("tests.modkit")
local OW = require("src.world.OverworldController") local OW = require("src.world.OverworldController")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local function setUpvalue(fn, name, val) local function setUpvalue(fn, name, val)
local i = 1 local i = 1
@@ -96,6 +98,89 @@ T.eq(rivalCount, 0, "rival classes play no encounter sting here")
local _, seenCount = stingFor("OPP_LASS", true) local _, seenCount = stingFor("OPP_LASS", true)
T.eq(seenCount, 0, "self.engaging suppresses a second sting") T.eq(seenCount, 0, "self.engaging suppresses a second sting")
-- A deferred preparation may cancel instead of constructing a battle. For a
-- sight trainer, that must leave a one-position latch: otherwise the still
-- undefeated adjacent trainer sees the stationary player again next frame and
-- immediately reopens the preparation screen.
local oldEvents, oldHooks, oldErrors = Runtime.events, Runtime.hooks,
Runtime.errors
local cancelHooks = Hooks.new()
Runtime.install(oldEvents, cancelHooks, oldErrors)
cancelHooks:wrap("trainer.before_battle", function(_, _, _, continue)
continue({ cancel = true })
return true
end, 0, "cancel_probe")
local cancelNpc = { id = "npc#cancel", cellX = 0, cellY = -1,
facing = "down", moving = false, def = { trainerClass = "OPP_LASS",
trainerParty = 1, index = 1 } }
fakeSelf.player = { cellX = 0, cellY = 0, moving = false }
fakeSelf.map.id = "FIX_ROUTE"
fakeSelf.engaging = false
pushed, plays = {}, {}
local completed = 0
fakeSelf:engageTrainer(cancelNpc, function() completed = completed + 1 end)
pushed[1].onDone()
T.eq(completed, 1, "cancel completes the deferred encounter without a battle")
T.same(fakeSelf.cancelledTrainerSight, {
npcId = "npc#cancel", playerX = 0, playerY = 0,
}, "cancel suppresses immediate sight re-entry at the current player cell")
local approaches = 0
fakeSelf.npcs = { cancelNpc }
fakeSelf.trainerDefeated = function() return false end
fakeSelf.startTrainerApproach = function() approaches = approaches + 1 end
fakeGame.stack.top = function() return fakeSelf end
fakeGame.data.trainerHeader = function() return { range = 2 } end
T.check(setUpvalue(OW.checkTrainerSight, "mapScripts", {
talkScript = function() return nil end,
}), "mapScripts upvalue on checkTrainerSight")
fakeSelf:checkTrainerSight()
T.eq(approaches, 0,
"a cancelled adjacent trainer cannot reacquire the stationary player")
fakeSelf.player.cellX = 1
fakeSelf:checkTrainerSight()
T.eq(fakeSelf.cancelledTrainerSight, nil,
"moving one cell releases the cancelled sight latch")
fakeSelf.player.cellX = 0
fakeSelf:checkTrainerSight()
T.eq(approaches, 1,
"returning to the sight line permits a fresh trainer challenge")
Runtime.install(oldEvents, oldHooks, oldErrors)
-- OverworldState is a singleton reused by StateStack. A title/load cycle must
-- clear this volatile latch too, or CONTINUE at the same map and cell inherits
-- the cancelled sight suppression from the previous session.
local Camera = require("src.render.Camera")
local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
local ScriptRunner = require("src.script.ScriptRunner")
local oldCameraNew, oldCollisionLoad = Camera.new, Collision.load
local oldEncounterLoad, oldRunnerNew = Encounter.load, ScriptRunner.new
local oldGameModule = package.loaded["src.core.Game"]
local oldScriptsModule = package.loaded["data.scripts.init"]
Camera.new = function() return {} end
Collision.load = function() end
Encounter.load = function() end
ScriptRunner.new = function() return {} end
package.loaded["src.core.Game"] = {
data = {}, save = { lastOutdoor = "FIX_ROUTE" },
}
package.loaded["data.scripts.init"] = {}
local lifecycle = setmetatable({
cancelledTrainerSight = {
npcId = "FIX_ROUTE_obj_1", playerX = 0, playerY = 0,
},
setMap = function() end,
refreshStandingOnWarp = function() end,
}, { __index = OW })
lifecycle:enter("FIX_ROUTE", 0, 0, "down", { via = "boot" })
T.eq(lifecycle.cancelledTrainerSight, nil,
"fresh overworld entry clears a cancelled trainer sight latch")
Camera.new, Collision.load = oldCameraNew, oldCollisionLoad
Encounter.load, ScriptRunner.new = oldEncounterLoad, oldRunnerNew
package.loaded["src.core.Game"] = oldGameModule
package.loaded["data.scripts.init"] = oldScriptsModule
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
else package.loaded["src.core.Music"] = nil end else package.loaded["src.core.Music"] = nil end
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
+92
View File
@@ -0,0 +1,92 @@
-- trainers.trueColor: the same 4-shade opt-out pokemon and sprites already
-- carry, now on the trainers registry. ROM-free.
-- luajit tests/engine/trainer_true_color.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Gen2Battle = require("src.ui.gen2.BattleState")
local Schemas = require("src.mods.Schemas")
local OakSpeech = require("src.ui.OakSpeech")
local spec = Schemas.REGISTRIES.trainers
T.check(spec.fields.trueColor ~= nil,
"Gen 1 trainers schema lists trueColor")
T.check(Schemas.check(spec, "trainers", "OPP_BROCK",
{ trueColor = true }, "patch"),
"a trueColor patch validates")
T.check(not Schemas.check(spec, "trainers", "OPP_BROCK",
{ trueColor = "yes" }, "patch"),
"trueColor rejects a non-boolean")
local gen2 = Schemas.shapeFor("trainers", spec, 2)
T.check(gen2.fields.trueColor ~= nil and gen2.fields.pic ~= nil,
"Gold trainers schema lists pic and trueColor")
T.check(Schemas.check(spec, "trainers", "BEAUTY",
{ pic = "mods/x/beauty.png", trueColor = true },
"patch", 2),
"a Gold pic+trueColor patch validates")
T.eq(BattleState.trainerTrueColor(nil, nil), false,
"no trainer is not trueColor")
T.eq(BattleState.trainerTrueColor(nil, { pic = "a.png" }), false,
"a vanilla portrait is not trueColor")
T.eq(BattleState.trainerTrueColor(nil, { trueColor = true }), true,
"the record's own flag wins")
T.eq(BattleState.trainerTrueColor(nil, { trueColor = false }), false,
"explicit false stays false")
local data = {
trainers = {
BASE = { pic = "base.png", trueColor = true },
VANILLA = { pic = "vanilla.png" },
},
}
T.eq(BattleState.trainerTrueColor(data, { basePic = "BASE" }), true,
"a basePic reuse inherits the base flag")
T.eq(BattleState.trainerTrueColor(data,
{ basePic = "BASE", trueColor = false }), false,
"an explicit false on the subclass beats the base")
T.eq(BattleState.trainerTrueColor(data, { basePic = "VANILLA" }), false,
"reusing a vanilla base stays unshaded-off")
local goldData = {
gen2Trainers = {
classes = {
BEAUTY = { pic = "mods/x/beauty.png", trueColor = true },
BUG_CATCHER = {},
},
},
gen2MenuGfx = {
battleHud = {
trainerPics = {
BEAUTY = "assets/generated/trainers/beauty.png",
BUG_CATCHER = "assets/generated/trainers/bug_catcher.png",
},
},
},
}
local beautyPath, beautyTc = Gen2Battle.trainerArt(goldData, "BEAUTY")
T.eq(beautyPath, "mods/x/beauty.png",
"a class pic wins over the extracted sheet")
T.eq(beautyTc, true, "and keeps trueColor")
local bugPath, bugTc = Gen2Battle.trainerArt(goldData, "BUG_CATCHER")
T.eq(bugPath, "assets/generated/trainers/bug_catcher.png",
"a class without pic keeps the extracted sheet")
T.eq(bugTc, false, "and is not trueColor")
T.eq(select(1, Gen2Battle.trainerArt(goldData, nil)), nil,
"no class is no pic")
local game = {
data = {
trainers = {
OPP_BROCK = { pic = "brock.png", trueColor = true },
OPP_PROF_OAK = { pic = "oak.png", trueColor = true },
},
},
}
local _, _, oakTc = OakSpeech.resolvePic(game,
{ type = "trainer", id = "OPP_BROCK" })
T.eq(oakTc, true, "OakSpeech reports a trainer record's trueColor")
T.finish("trainer true color")
+15
View File
@@ -23,6 +23,7 @@ local MoveEffects = require("src.battle.MoveEffects")
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local Schemas = require("src.mods.Schemas")
local Status = require("src.battle.Status") local Status = require("src.battle.Status")
local TrainerAI = require("src.battle.TrainerAI") local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
@@ -346,6 +347,20 @@ do
check(BattleState.trainerPicPath(Data, { basePic = "OPP_ENGINEER" }) check(BattleState.trainerPicPath(Data, { basePic = "OPP_ENGINEER" })
== Data.trainers.OPP_ENGINEER.pic, == Data.trainers.OPP_ENGINEER.pic,
"a custom trainer can reuse a base trainer portrait by id") "a custom trainer can reuse a base trainer portrait by id")
check(BattleState.trainerTrueColor(Data, { trueColor = true }) == true,
"a trainer record's trueColor flag is readable")
check(BattleState.trainerTrueColor(Data, { trueColor = false }) == false,
"explicit false stays false")
check(BattleState.trainerTrueColor(Data, { basePic = "OPP_ENGINEER" })
== false,
"a vanilla base portrait is not trueColor")
check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "OPP_BROCK",
{ trueColor = true }, "patch"),
"a trueColor trainers patch validates against the catalog schema")
check(Schemas.check(Schemas.REGISTRIES.trainers, "trainers", "BEAUTY",
{ pic = "mods/x/beauty.png", trueColor = true },
"patch", 2),
"a Gold trainers patch can carry pic and trueColor")
end end
do do
+26
View File
@@ -370,6 +370,32 @@ check(math.abs(r - 0.4) < 1e-6 and math.abs(g - 0.7) < 1e-6
and math.abs(b - 0.9) < 1e-6, and math.abs(b - 0.9) < 1e-6,
"a trueColor pic keeps a pixel no 4-shade palette contains") "a trueColor pic keeps a pixel no 4-shade palette contains")
-- trainers.trueColor is the same opt-out on a class portrait
BattleState.invalidate()
local trainerPicData = {
trainers = {
SHADED = { pic = "assets/generated/battle/front/shaded.png" },
FULLCOLOR = { pic = "assets/generated/battle/front/full.png",
trueColor = true },
REUSED = { basePic = "FULLCOLOR" },
},
palettes = { palettes = { MEWMON = monPalette }, pokemon = {} },
}
local shadedTrainer = BattleState.trainerSprite(trainerPicData,
trainerPicData.trainers.SHADED)
r, g, b = shadedTrainer.data:getPixel(0, 0)
check(r == 0 and g == 0 and b == 1,
"a 4-shade trainer pic is palette-quantized onto its shade bucket")
local fullTrainer = BattleState.trainerSprite(trainerPicData,
trainerPicData.trainers.FULLCOLOR)
r, g, b = fullTrainer.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 trainer pic keeps a pixel no 4-shade palette contains")
check(BattleState.trainerTrueColor(trainerPicData,
trainerPicData.trainers.REUSED) == true,
"a basePic reuse inherits the base portrait's trueColor flag")
-- ------- trueColor: the colors == false zone sentinel -- ------- trueColor: the colors == false zone sentinel
check(PaletteFX.zone(nil, 0, 0, 1, 1) == nil, "nil colors is still no zone") check(PaletteFX.zone(nil, 0, 0, 1, 1) == nil, "nil colors is still no zone")
+74 -5
View File
@@ -358,11 +358,13 @@ T.same(checkpoints:capture(game), beforeFailure,
-- The same public facade must carry a real battle checkpoint end to end. The -- The same public facade must carry a real battle checkpoint end to end. The
-- engine-side fixture is deliberately constructed outside the probe mod; the -- engine-side fixture is deliberately constructed outside the probe mod; the
-- mod sees and calls only mod.checkpoints. -- mod sees and calls only mod.checkpoints.
local function makeBattleGame() local function makeBattleGame(kind)
local data = Fixtures.fresh() local data = Fixtures.fresh()
local save = SaveData.newGame() local save = SaveData.newGame()
save.meta.playthroughId = "public-battle-playthrough" save.meta.playthroughId = "public-battle-playthrough"
save.party = { Pokemon.new(data, "FIXMON_A", 20) } save.party = { Pokemon.new(data, "FIXMON_A", 20),
Pokemon.new(data, "FIXMON_B", 19),
Pokemon.new(data, "FIXMON_C", 18) }
-- The tiny fixture registry intentionally omits several full-game defaults. -- The tiny fixture registry intentionally omits several full-game defaults.
-- Normalize those once, then place the save on its fixture map. -- Normalize those once, then place the save on its fixture map.
SaveData.validate(save, data) SaveData.validate(save, data)
@@ -387,7 +389,8 @@ local function makeBattleGame()
self.player = { cellX = x, cellY = y, facing = facing, surfing = false } self.player = { cellX = x, cellY = y, facing = facing, surfing = false }
end end
function battleOw:restoreBattleContinuation(restoredBattle, origin) function battleOw:restoreBattleContinuation(restoredBattle, origin)
if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then local expected = kind == "trainer" and "trainer_encounter" or "wild_encounter"
if origin.kind ~= expected or origin.map ~= self.map.id then
return false return false
end end
restoredBattle.onFinish = function() end restoredBattle.onFinish = function() end
@@ -397,9 +400,19 @@ local function makeBattleGame()
data = data, save = save, stack = stack, overworld = battleOw, data = data, save = save, stack = stack, overworld = battleOw,
}, { __index = GameMethods }) }, { __index = GameMethods })
stack.states[1] = battleOw stack.states[1] = battleOw
local battle = BattleState.newWild(battleGame, "FIXMON_B", 12) local battle
if kind == "trainer" then
battle = BattleState.newTrainer(battleGame, "OPP_FIX_YOUNGSTER", 1, {
playerPartyIndices = { 2, 3 },
})
else
battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
end
battle.phase, battle.queue = "menu", {} battle.phase, battle.queue = "menu", {}
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } battle.checkpointOrigin = kind == "trainer"
and { kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1 }
or { kind = "wild_encounter", map = "FIX_TOWN" }
battle.musicKind = battle:computeMusicKind() battle.musicKind = battle:computeMusicKind()
battle.onFinish = function() end battle.onFinish = function() end
stack.states[2] = battle stack.states[2] = battle
@@ -437,6 +450,62 @@ if battleSnapshot then
"public battle capture/restore/capture is a normalized differential roundtrip") "public battle capture/restore/capture is a normalized differential roundtrip")
end end
checkpointRngState = "scoped-trainer-rng-A"
local scopedGame, scopedBattle = makeBattleGame("trainer")
local scopedSnapshot, scopedCaptureCode = checkpoints:capture(scopedGame)
T.check(scopedSnapshot ~= nil,
"public checkpoints capture a scoped trainer battle: "
.. tostring(scopedCaptureCode))
if scopedSnapshot then
T.same(scopedSnapshot.runtime.battle.playerPartyIndices, { 2, 3 },
"capture stores the battle-local save-party index scope")
local restored, code, message = checkpoints:restore(scopedGame, scopedSnapshot)
T.check(restored == true,
"public checkpoints restore a scoped trainer battle: "
.. tostring(code) .. " / " .. tostring(message))
local scopedRestored = scopedGame.stack:top()
T.same(scopedRestored.playerPartyIndices, { 2, 3 },
"restore reconstructs the same ordered party scope")
T.check(scopedRestored.playerParty[1] == scopedGame.save.party[2]
and scopedRestored.playerParty[2] == scopedGame.save.party[3],
"restored scope points at authoritative save-party records")
scopedSnapshot.runtime.battle.playerPartyIndices = nil
local oldRestored, oldCode = checkpoints:restore(scopedGame, scopedSnapshot)
T.check(oldRestored == true,
"an old checkpoint without party scope remains compatible: "
.. tostring(oldCode))
T.eq(scopedGame.stack:top().playerParty, nil,
"an old checkpoint restores the vanilla full-party view")
end
local function scopedCheckpoint()
local freshGame = makeBattleGame("trainer")
local snapshot = assert(checkpoints:capture(freshGame))
return freshGame, snapshot
end
local excludedGame, excludedSnapshot = scopedCheckpoint()
excludedSnapshot.runtime.battle.player.index = 1
local excludedRestored, excludedCode = checkpoints:restore(excludedGame,
excludedSnapshot)
T.check(excludedRestored == false and excludedCode == "invalid_checkpoint",
"a scoped checkpoint rejects an active battler outside the eligible view")
local malformedGame, malformedSnapshot = scopedCheckpoint()
malformedSnapshot.runtime.battle.playerPartyIndices.extra = 3
local malformedRestored, malformedCode = checkpoints:restore(malformedGame,
malformedSnapshot)
T.check(malformedRestored == false and malformedCode == "invalid_checkpoint",
"a scoped checkpoint rejects non-array scope members instead of failing open")
local participantGame, participantSnapshot = scopedCheckpoint()
participantSnapshot.runtime.battle.participants = { 1 }
local participantRestored, participantCode = checkpoints:restore(
participantGame, participantSnapshot)
T.check(participantRestored == false and participantCode == "invalid_checkpoint",
"a scoped checkpoint rejects excluded participant references")
-- The mod receives the normal public hook facade, never BattleState. START -- The mod receives the normal public hook facade, never BattleState. START
-- at the restored safe decision reaches its semantic auxiliary action without -- at the restored safe decision reaches its semantic auxiliary action without
-- selecting a native command. -- selecting a native command.
+110 -2
View File
@@ -7,6 +7,7 @@ love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod storage") local T = require("tests.harness").suite("mod storage")
local Loader = require("src.mods.Loader") local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Storage = require("src.mods.Storage")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks local savedEvents, savedHooks = Runtime.events, Runtime.hooks
@@ -22,7 +23,9 @@ local function memfs(files)
function fs.read(path) return files[path] end function fs.read(path) return files[path] end
function fs.write(path, body) function fs.write(path, body)
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then
return false, "main denied"
end
files[path] = body files[path] = body
return true return true
end end
@@ -107,6 +110,50 @@ T.same(loaded, payload, "stored payload roundtrips as data")
T.check(loaded ~= payload and loaded.nested ~= payload.nested, T.check(loaded ~= payload and loaded.nested ~= payload.nested,
"read returns decoded data rather than the caller's live table") "read returns decoded data rather than the caller's live table")
T.check(type(alpha.writeBytes) == "function"
and type(alpha.readBytes) == "function",
"mod.storage exposes opaque byte read/write methods")
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true"
local binaryOk, binaryCode, binaryMessage =
alpha:writeBytes(current, "states/quick/blob", binary)
T.check(binaryOk == true,
"opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage))
local binaryLoaded, binaryReadCode =
alpha:readBytes(current, "states/quick/blob")
T.eq(binaryLoaded, binary,
"opaque bytes round-trip without text or Lua decoding")
T.eq(binaryReadCode, nil, "successful opaque byte read has no error")
T.eq(_G.MOD_STORAGE_EXECUTED, nil,
"Lua-looking opaque bytes are never executed")
local emptyOk = alpha:writeBytes(current, "binary/empty", "")
T.check(emptyOk == true, "empty opaque byte payloads are valid")
T.eq(alpha:readBytes(current, "binary/empty"), "",
"empty opaque byte payloads round-trip")
local badBytes, badBytesCode =
alpha:writeBytes(current, "binary/bad-type", { byte = true })
T.check(not badBytes and badBytesCode == "invalid_bytes",
"non-string opaque payloads are rejected")
local savedLimit = Storage.MAX_BYTES
Storage.MAX_BYTES = 4
local tooLarge, tooLargeCode =
alpha:writeBytes(current, "binary/too-large", "12345")
Storage.MAX_BYTES = savedLimit
T.check(not tooLarge and tooLargeCode == "size_limit",
"opaque payloads over the per-key limit are rejected")
local tableConflict, tableConflictCode =
alpha:writeBytes(current, "states/quick/q1", "table-key-conflict")
T.check(not tableConflict and tableConflictCode == "type_conflict",
"bytes cannot replace a table record without deletion")
local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob")
T.check(wrongType == nil and wrongTypeCode == "type_mismatch",
"table reads identify byte records as the wrong storage type")
end
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end }) local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
T.check(not bad and badCode == "encode_failed", T.check(not bad and badCode == "encode_failed",
"functions are rejected with a stable data-only error") "functions are rejected with a stable data-only error")
@@ -120,19 +167,33 @@ T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha") T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
T.check(alpha:write(current, "settings", { enabled = true }), "write settings") T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
local keys = alpha:list(current, "states/quick") local keys = alpha:list(current, "states/quick")
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" }, T.same(keys, { "states/quick/alpha", "states/quick/blob",
"states/quick/q1", "states/quick/zeta" },
"list returns sorted logical keys under the requested prefix") "list returns sorted logical keys under the requested prefix")
-- Mod, playthrough, and game namespaces cannot observe each other. -- Mod, playthrough, and game namespaces cannot observe each other.
local missing, missingCode = beta:read(current, "states/quick/q1") local missing, missingCode = beta:read(current, "states/quick/q1")
T.check(missing == nil and missingCode == "not_found", T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's payload") "another mod cannot read the first mod's payload")
if type(alpha.readBytes) == "function" then
missing, missingCode = beta:readBytes(current, "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's opaque payload")
end
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1") missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found", T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the payload") "another playthrough cannot read the payload")
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1") missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found", T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the payload") "another game version cannot read the payload")
if type(alpha.readBytes) == "function" then
missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the opaque payload")
missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the opaque payload")
end
-- Find the implementation-owned file only to inject corruption; assertions stay -- Find the implementation-owned file only to inject corruption; assertions stay
-- on public read behavior, not the path shape. -- on public read behavior, not the path shape.
@@ -142,6 +203,12 @@ local function mainFor(fragment)
end end
end end
local function byteMainFor(fragment)
for path in pairs(files) do
if path:find(fragment, 1, true) and path:sub(-4) == ".bin" then return path end
end
end
local q1Main = mainFor("q1") local q1Main = mainFor("q1")
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1") T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
files[q1Main] = "not a serialized table" files[q1Main] = "not a serialized table"
@@ -158,6 +225,47 @@ T.check(not ok and code == "write_failed", "staging failure is reported")
T.same(alpha:read(current, "replace"), { version = 1 }, T.same(alpha:read(current, "replace"), { version = 1 },
"staging failure leaves the prior value readable") "staging failure leaves the prior value readable")
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"),
"seed opaque recovery value")
local recoverMain = byteMainFor("binary/recover")
T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data")
files[recoverMain] = nil
T.eq(alpha:readBytes(current, "binary/recover"), "old-bytes",
"missing opaque main recovers the last verified backup")
T.check(alpha:writeBytes(current, "binary/replace", "version-1"),
"seed opaque replacement value")
fs.failTmp = true
ok, code = alpha:writeBytes(current, "binary/replace", "version-2")
fs.failTmp = false
T.check(not ok and code == "write_failed",
"opaque staging failure is reported")
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
"opaque staging failure leaves the prior value readable")
fs.failMain = true
ok, code = alpha:writeBytes(current, "binary/replace", "version-3")
fs.failMain = false
T.check(not ok and code == "write_failed",
"opaque replacement failure is reported")
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
"opaque replacement failure leaves the prior value readable")
local byteConflict, byteConflictCode =
alpha:write(current, "binary/replace", { version = 3 })
T.check(not byteConflict and byteConflictCode == "type_conflict",
"tables cannot replace a byte record without deletion")
T.check(alpha:writeBytes(current, "binary/delete", "delete-me"),
"seed opaque delete target")
T.check(alpha:delete(current, "binary/delete") == true,
"delete removes an opaque record")
missing, missingCode = alpha:readBytes(current, "binary/delete")
T.check(missing == nil and missingCode == "not_found",
"deleted opaque key is unavailable")
end
-- Delete is exact and idempotent-not-found is explicit. -- Delete is exact and idempotent-not-found is explicit.
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target") T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor") T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")
@@ -135,6 +135,17 @@ if type(storage) == "table" then
"title binding supports safe same-namespace durable operations") "title binding supports safe same-namespace durable operations")
T.same(selected:read("history/title-operation"), { allowed = true }, T.same(selected:read("history/title-operation"), { allowed = true },
"title durable operation remains scoped to the selected playthrough") "title durable operation remains scoped to the selected playthrough")
T.check(type(selected.writeBytes) == "function"
and type(selected.readBytes) == "function",
"selected storage exposes opaque byte methods")
if type(selected.writeBytes) == "function"
and type(selected.readBytes) == "function" then
local titleBytes = "TITLE\0\255-cache"
T.check(selected:writeBytes("history/title-bytes", titleBytes) == true,
"title binding writes opaque bytes in the selected namespace")
T.eq(selected:readBytes("history/title-bytes"), titleBytes,
"title binding reads opaque bytes in the selected namespace")
end
end end
T.check(title.save.meta.playthroughId == nil, T.check(title.save.meta.playthroughId == nil,
"opening title history never allocates or adopts a playthrough identity") "opening title history never allocates or adopts a playthrough identity")
@@ -0,0 +1,92 @@
-- A sandboxed mod can defer an ordinary trainer engagement and later resume
-- it with a battle-local player-party scope, using only public mod surfaces.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local OW = require("src.world.OverworldController")
local FIXTURE = {
["mods/scope_probe/manifest.json"] = [[{
"id": "scope_probe",
"name": "Scope Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/scope_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
mod.exports.game = game
mod.exports.context = context
mod.exports.continue = continue
return true
end)
]],
}
local vanilla = T.sdk.loadNone({})
local vanillaCalls, vanillaOptions = 0
OW.prepareTrainerBattle({ id = "game" }, {
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
mapId = "FIX_ROUTE", npcId = "TRAINER_1",
}, function(options)
vanillaCalls, vanillaOptions = vanillaCalls + 1, options
end)
T.eq(vanillaCalls, 1, "no mod starts the trainer battle exactly once")
T.eq(vanillaOptions, nil, "no mod supplies no battle-local party scope")
vanilla.release()
local run = T.sdk.loadMods({ "mods/scope_probe" }, {
fs = T.sdk.memfs(FIXTURE),
})
T.eq(#run.errors, 0,
"the public preparation probe loads clean (" .. tostring(run.errors[1]) .. ")")
local game = { id = "live-game" }
local calls, options = 0
OW.prepareTrainerBattle(game, {
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
}, function(value)
calls, options = calls + 1, value
end)
T.eq(calls, 0, "a claiming public hook defers battle construction")
local out = run.loader.exports.scope_probe or {}
T.check(out.game == game, "the hook receives the live game")
T.same(out.context, {
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
}, "the hook receives data-only trainer identity context")
T.eq(out.continue({ playerPartyIndices = { 2, 4 } }), true,
"the retained continuation resumes the deferred battle")
T.eq(calls, 1, "resume constructs the battle exactly once")
T.same(options, { playerPartyIndices = { 2, 4 } },
"ordered eligible indices cross the public seam unchanged")
T.eq(out.continue({ playerPartyIndices = { 1 } }), false,
"the continuation refuses a second invocation")
T.eq(calls, 1, "a duplicate resume cannot start a second battle")
run.release()
local cancelRun = T.sdk.loadMods({ "mods/scope_probe" }, {
fs = T.sdk.memfs(FIXTURE),
})
local starts, cancels = 0, 0
OW.prepareTrainerBattle(game, {
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
}, function()
starts = starts + 1
end, function()
cancels = cancels + 1
end)
local cancelOut = cancelRun.loader.exports.scope_probe or {}
T.eq(cancelOut.continue({ cancel = true }), true,
"the retained continuation can cancel a deferred encounter")
T.eq(starts, 0, "cancelling never constructs a trainer battle")
T.eq(cancels, 1, "cancelling invokes the encounter's completion callback")
T.eq(cancelOut.continue(), false,
"a cancelled continuation remains one-shot")
cancelRun.release()
T.finish("trainer_before_battle")
+88
View File
@@ -0,0 +1,88 @@
-- Contextual bicycle and fishing actions share one public contract in both
-- generations while each engine keeps ownership of its own field-item path.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness").suite("mod world field items")
local facingWater = false
local redWorld = {
isOverworld = true,
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
player = { moving = false, inputLocked = false, surfing = false },
runner = { isRunning = function() return false end },
scriptMoves = {},
bikeAllowed = function() return true end,
facingIsShoreOrWater = function() return facingWater end,
useBicycle = function(self) self.bikeUsed = true return true end,
useFishingRod = function(self, rod) self.rodUsed = rod return true end,
}
local redGame = {
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
save = { player = { name = "RED" }, party = {},
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
stack = { states = { redWorld } },
overworld = redWorld,
}
function redGame.stack:top() return self.states[#self.states] end
local RedAPI = require("src.world.WorldAPI")
local red = RedAPI.new(redGame, "fixture")
local RedWorld = require("src.world.OverworldController")
T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function",
"Red keeps field-item execution in its world")
local actions = red:availableFieldActions()
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle")
T.check(redWorld.bikeUsed, "Red delegates to its world-owned bicycle path")
facingWater = true
actions = red:availableFieldActions()
T.eq(actions[2].rods[1].id, "OLD_ROD", "Red lists owned rods at water")
T.check(red:useFieldAction("fish", { rod = "OLD_ROD" }),
"Red accepts a listed rod")
T.eq(redWorld.rodUsed, "OLD_ROD", "Red delegates to its fishing path")
local used = redWorld.rodUsed
local ok, err = red:useFieldAction("fish", { rod = "SUPER_ROD" })
T.check(not ok and err == "fishing rod unavailable",
"Red rejects an unowned rod")
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
redWorld.player.moving = true
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
ok, err = red:useFieldAction("bicycle")
T.check(not ok and err == "world is busy",
"Red refuses a stale action while busy")
local goldWorld = {
map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
player = {}, playerState = "normal",
acceptsMenuInput = function() return true end,
playerCollision = function() return 0x00 end,
alwaysOnBike = function() return false end,
fieldContext = function() return { facingColl = 0x20 } end,
useFieldItem = function(self, item) self.itemUsed = item return "used" end,
}
local goldGame = {
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } },
world = goldWorld,
}
local GoldAPI = require("src.world.gen2.WorldAPI")
local gold = GoldAPI.new(goldGame, "fixture")
actions = gold:availableFieldActions()
T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id")
T.eq(actions[2].rods[1].id, "OLD_ROD", "Gold shares the rod shape")
T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }),
"Gold accepts the same fishing request")
T.eq(goldWorld.itemUsed, "OLD_ROD",
"Gold delegates to its own field-item path")
used = goldWorld.itemUsed
ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" })
T.check(not ok and err == "fishing rod unavailable",
"Gold rejects an unowned rod")
T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing")
T.finish()