diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 0cbe81c0..47c1a5b3 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -240,7 +240,7 @@ resolves to the weaker claim: | `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 | -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 (`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and 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. `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. +`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 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: +- `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 own yet. - `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The diff --git a/docs/modding.md b/docs/modding.md index 47b0e6ce..8a1a287c 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -147,6 +147,20 @@ Companion UIs and alternate party screens can call operation is accepted only during idle overworld play; menus, movement, 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 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") ``` +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 version is compatibility metadata; physical launcher-slot and path identity stays 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 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)`, -`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same -data-only and transaction contract as `mod.storage`, but remain restricted to -the calling mod's selected existing namespace. It is intended for title tools -that need to browse or manage durable history before the first normal SAVE. +`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`, +`list(prefix)`, and `delete(key)` methods have the same scoped and +transactional contract as `mod.storage`, but remain restricted to the calling +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 -slash-separated segments (letters, digits, `_`, `-`); paths and filesystem -handles are never exposed. Writes are staged and decode-verified, reads recover -from a valid staged/backup generation, and methods return structured errors for -normal data or I/O failures. The playthrough identity is allocated lazily on the -first storage/checkpoint call, so an unused API changes no save bytes. +Table values must contain serializable data only. Opaque values must be Lua +strings. Keys are conservative slash-separated segments (letters, digits, `_`, +`-`); paths and filesystem handles are never exposed. Table writes are staged +and decode-verified; opaque writes are staged and byte-verified; reads recover +from a valid staged/backup generation. Methods return structured errors for +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: @@ -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 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 Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index eb6500cf..cf4eec9d 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -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.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.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.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 | @@ -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 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 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 diff --git a/docs/rfcs/0003-playthrough-storage.md b/docs/rfcs/0003-playthrough-storage.md index 96c574e2..e9e4b5c2 100644 --- a/docs/rfcs/0003-playthrough-storage.md +++ b/docs/rfcs/0003-playthrough-storage.md @@ -26,8 +26,8 @@ wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior. ## The exact API delta 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 -filesystem handles or physical paths. +facade to the calling mod id. Mods receive logical keys and either decoded table +values or exact opaque byte strings, never filesystem handles or physical paths. ### 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 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])` 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` Stable error codes are `not_in_playthrough`, `storage_unavailable`, -`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and -`not_found`. Ordinary data and I/O failures are return values, not callback- -terminating errors. +`invalid_key`, `encode_failed`, `invalid_bytes`, `size_limit`, `type_conflict`, +`type_mismatch`, `write_failed`, `verify_failed`, and `not_found`. Ordinary +data and I/O failures are return values, not callback-terminating errors. The restricted serializer's recursive writer runs outside LuaJIT traces. A 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 playthrough id is created unless a mod invokes `mod.storage` or `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 @@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or - **Engine identity:** lazy allocation, save/load preservation, stable legacy mapping, fresh-playthrough replacement, and version/slot isolation. - **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, - 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 diff --git a/docs/rfcs/0010-trainer-battle-party-scope.md b/docs/rfcs/0010-trainer-battle-party-scope.md new file mode 100644 index 00000000..9d6ffda4 --- /dev/null +++ b/docs/rfcs/0010-trainer-battle-party-scope.md @@ -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. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 90fe49b8..38c1bdd7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -638,6 +638,44 @@ local function newBattle(game) return self 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 function BattleState.newWild(game, species, level, opts) local self = newBattle(game) @@ -713,13 +751,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party) end end -function BattleState.newTrainer(game, oppClass, partyIndex) +function BattleState.newTrainer(game, oppClass, partyIndex, opts) local self = newBattle(game) self.kind = "trainer" self.oppClass = oppClass -- the object_event trainer arg (roster index). computeMusicKind keys -- data/scripts/victories.lua on class#party, so keep it on the battle (#782). 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] assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName @@ -763,7 +803,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex) end end self.enemyIndex = 1 - local playerMon = Party.firstHealthy(game.save.party) + local playerMon = Party.firstHealthy(self:playerPartyView()) if not playerMon then Logger.warn("trainer battle with no healthy party; skipping") self.dead = true @@ -2022,7 +2062,7 @@ function BattleState:update(dt) -- loops the party menu until a healthy mon is picked, so B and -- fainted picks land back here and reopen it if self.player.mon.hp <= 0 then - if Party.firstHealthy(self.game.save.party) then + if Party.firstHealthy(self:playerPartyView()) then self:openReplacementMenu() end return @@ -3910,7 +3950,8 @@ function BattleState:awardExp() -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- the surviving participants are counted and paid 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 participants = participants + 1 if mon.hp > 0 then table.insert(alive, mon) end @@ -4004,9 +4045,9 @@ function BattleState:awardExp() -- experience.asm:9-13); each mon gets its own GainedText with the -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- summary line - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(playerParty) do 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 @@ -4047,7 +4088,7 @@ function BattleState:enemyMonFainted() local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() - local partyCount = #self.game.save.party + local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- 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 @@ -4074,6 +4115,7 @@ function BattleState:enemyMonFainted() local game = self.game Screens.push(game, "PartyMenu", { battle = self, + party = self:playerPartyView(), forceSwitch = true, onSwitch = function(mon) if mon ~= self.player.mon and mon.hp > 0 then @@ -4246,7 +4288,7 @@ function BattleState.isOaksLabStarterRival(self) end 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 -- 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 @@ -4319,6 +4361,7 @@ function BattleState:openReplacementMenu() self:ui(function() return self:buildScreen("PartyMenu", { battle = self, + party = self:playerPartyView(), -- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL) forceSwitch = true, onSwitch = function(mon) @@ -4799,6 +4842,7 @@ function BattleState:openParty() self:ui(function() return self:buildScreen("PartyMenu", { battle = self, + party = self:playerPartyView(), onSwitch = function(mon) if mon == self.player.mon then self:say(Strings("%s is\nalready out!", self.player.name)) @@ -4845,8 +4889,8 @@ function BattleState:finish() -- 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 -- (Yellow's Pallet intro runs before the player owns a mon at all). - if self.result ~= "lose" and not self.demo - and not Party.firstHealthy(self.game.save.party) then + if self.kind ~= "link" and self.result ~= "lose" and not self.demo + and not Party.firstHealthy(self:playerPartyView()) then Logger.warn("battle finished %s with no healthy party; forcing blackout", tostring(self.result)) self.result = "lose" @@ -5727,7 +5771,7 @@ function BattleState:drawHUDs(slide) for i = 10, 17 do hudTile(0x76, i * 8, 88) end hudTile(0x6F, 72, 88) 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 local hidePlayer = self.safari or self.demo if showStatus and self.player and not hidePlayer and not self.showPlayerBack diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index c3319ab2..b933bd7c 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -44,6 +44,7 @@ local BATTLE_FIELDS = { "sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall", "lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed", "endBattleText", + "playerPartyIndices", } 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) 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) if type(moves) ~= "table" then return false end 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 return nil, "invalid_content", "Player battle state is invalid." 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 not validateMon(game.data, model.enemyMon) or not validateBattler(game.data, model.enemy, 1) then @@ -220,12 +248,15 @@ function BattleCheckpoint.validate(game, checkpoint) end end 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." end - for _, index in ipairs(indices) do - if not integer(index, 1, #party) then - return nil, "invalid_checkpoint", "Battle party reference is invalid." + if scopedIndices then + for index in pairs(referenced) do + if not scopedIndices[index] then + return nil, "invalid_checkpoint", "Battle party reference is invalid." + end end end end @@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy) local model = checkpoint.runtime.battle local battle 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.enemyIndex = model.enemyIndex else diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d37ae295..5311ee1e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2356,6 +2356,20 @@ function RomImporter:_updatePadCursor(dt) local ny = self._padCursor.y + dy * speed * dt self._padCursor.x = math.max(ox, math.min(ox + w, nx)) 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. -- NX: the getPosition bridge already returns pad coords — skip setPosition. if not self.isNX and love.mouse.setPosition then diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index 22a03c07..72b66d30 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -737,7 +737,8 @@ COVERAGE["src.pokemon.Boxes"] = { COVERAGE["src.world.WorldAPI"] = { kind = "alias", target = "src.world.gen2.WorldAPI", backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock " - .. "spawnNpc removeNpc npc queueScript invalidateMap", + .. "spawnNpc removeNpc npc queueScript invalidateMap " + .. "availableFieldActions useFieldAction", warned = "setFlag getFlag", absent = "", notes = { diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 0d017cf6..caf92dee 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1069,7 +1069,8 @@ function Loader:_api(mod) bucket[key] = value 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; -- callers never receive paths or a raw filesystem handle. storage = { @@ -1077,6 +1078,10 @@ function Loader:_api(mod) selected = function(_, game) return storage:selected(game) end, write = function(_, game, key, value) return storage:write(game, key, value) 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, delete = function(_, game, key) return storage:delete(game, key) end, }, diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua index 31456566..d9cb23ab 100644 --- a/src/mods/Storage.lua +++ b/src/mods/Storage.lua @@ -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. local SaveData = require("src.core.SaveData") @@ -7,6 +8,7 @@ local Version = require("src.core.Version") local Storage = {} Storage.__index = Storage +Storage.MAX_BYTES = 512 * 1024 * 1024 local ROOT = "mod_storage" @@ -49,6 +51,20 @@ local function decodeAt(fs, path) return data, body 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) assert(validSegment(modId), "Storage.new needs a safe mod id") return setmetatable({ modId = modId, injectedFs = fs }, Storage) @@ -132,6 +148,10 @@ function Storage:selected(game) end, read = function(_, key) return self:read(selectedGame, key) 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, delete = function(_, key) return self:delete(selectedGame, key) end, } @@ -147,7 +167,7 @@ function Storage:context(game) } end -function Storage:_names(game, key, allowEmpty) +function Storage:_names(game, key, allowEmpty, extension) if not validKey(key, allowEmpty) then return failure("invalid_key", "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) if not scope then return nil, code, message end 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 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 + 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 return false, "encode_failed", "Storage values must be data-only tables." end @@ -170,7 +197,6 @@ function Storage:write(game, key, value) .. tostring(encoded) end - local fs = scope.fs ensureParent(fs, main) local _, previous = decodeAt(fs, main) if not previous then _, previous = decodeAt(fs, bak) end @@ -206,9 +232,13 @@ function Storage:write(game, key, value) end 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 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) if data then return data end @@ -226,6 +256,80 @@ function Storage:read(game, key) return data 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) prefix = prefix or "" local scope, main, codeOrBak = self:_names(game, prefix, true) @@ -237,13 +341,23 @@ function Storage:list(game, prefix) local base = scope.base 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 info = fs.getInfo(path) if not info then return end 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 end 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. if fs.getInfo(start .. ".lua") then - out[#out + 1] = prefix + add(prefix) + elseif fs.getInfo(start .. ".bin") then + add(prefix) else walk(start, prefix) end @@ -263,15 +379,20 @@ function Storage:list(game, prefix) end 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 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." end remove(fs, main) remove(fs, bak) remove(fs, tmp) + remove(fs, byteMain) + remove(fs, byteBak) + remove(fs, byteTmp) return true end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 25440f22..e8cb6630 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -383,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list) local def = game.data.items[id] local opts = { pickOnly = true, + battle = battle, -- HP medicine animates its bar with the picker still up (#252). Only -- out of battle: the in-battle tail closes the bag list underneath -- first, which needs the picker already gone. diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index ee471590..f056418c 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -289,6 +289,7 @@ function PartyMenu.new(game, opts) opts = opts or {} local self = setmetatable({}, PartyMenu) self.game = game + local party = opts.party or (opts.battle and opts.battle.playerParty) -- PartyMenuInit (home/pokemon.asm) seeds the cursor from -- wPartyAndBillsPCSavedMenuItem rather than from zero, and -- 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 -- party that shrank (deposit / release) while the saved index was -- 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), math.max(1, count)) self.onSwitch = opts.onSwitch @@ -314,7 +315,7 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch 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.submenu = nil self.subIndex = 1 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 325991d0..0b845d84 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts) -- a fresh entry, or a stale flag can freeze player input forever self.engaging = false self.emote = nil + self.cancelledTrainerSight = nil -- volatile WRAM state in pokered; never serialize across save/load self.wildEncounterGraceSteps = 0 -- survives save/load: a loaded game may start inside a building whose @@ -762,6 +763,27 @@ function OverworldState:bikeAllowed(mapId) return false 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 -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- inclusive map-id ranges -- faithful to the original's omissions @@ -1725,6 +1747,12 @@ function OverworldState:goFishing(rod) 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). function OverworldState:flyTo(mapId) local spot = Game.data.field.flyWarps[mapId] @@ -3092,6 +3120,32 @@ local function meetTrainerTheme(cls) or "Music_MeetMaleTrainer" 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. -- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript -- (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]) 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 -- FIRST and only then runs `call EngageMapTrainer` / `jp -- 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) if theme then require("src.core.Music").play(Game.data, theme) end end - local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) + local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty, + options) battle.checkpointOrigin = { kind = "trainer_encounter", map = self.map.id, @@ -3171,10 +3227,31 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText end self:pushBattle(battle) 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 - startBattle() + prepareBattle() else - Game.stack:push(TextBox.new(Game, battleText, startBattle)) + Game.stack:push(TextBox.new(Game, battleText, prepareBattle)) end end @@ -3372,11 +3449,18 @@ function OverworldState:checkTrainerSight() if self.player.moving or self.engaging then return end if Game.stack:top() ~= self then return end 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 local d = npc.def -- CheckFightingMapTrainers engages ANY aligned trainer sprite, -- walkers included (they sight between steps) if d.trainerClass and not npc.moving + and not (cancelled and cancelled.npcId == npc.id) and not self:trainerDefeated(npc) and not mapScripts.talkScript(self.map.id, d.text) and trainerSpriteOnScreen(npc, p) then diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 64283bea..a907e8f1 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -15,6 +15,7 @@ local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" +local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" } local function acceptsMenuInput(game, ow) local stack = game and game.stack @@ -86,6 +87,60 @@ function WorldAPI:reorderParty(fromSlot, toSlot) return true 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. -- `rows` describes collision terrain; optional `tileRows` reduces each real -- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index 09830721..c391678a 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -27,11 +27,15 @@ local Movement = require("src.script.gen2.Movement") local Runtime = require("src.mods.Runtime") local HiddenItems = require("src.world.gen2.HiddenItems") 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 = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" +local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" } function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) @@ -52,6 +56,77 @@ function WorldAPI:current() facing = p and p.facing } 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 -- visibility rules supplying the semantic markers. function WorldAPI:mapOverview() diff --git a/tests/engine/trainer_battle_party_scope.lua b/tests/engine/trainer_battle_party_scope.lua new file mode 100644 index 00000000..14ac2e81 --- /dev/null +++ b/tests/engine/trainer_battle_party_scope.lua @@ -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() diff --git a/tests/engine/trainer_talk_sting_bug764.lua b/tests/engine/trainer_talk_sting_bug764.lua index 838f5f05..7587cf9e 100644 --- a/tests/engine/trainer_talk_sting_bug764.lua +++ b/tests/engine/trainer_talk_sting_bug764.lua @@ -13,6 +13,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") 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 i = 1 @@ -96,6 +98,89 @@ T.eq(rivalCount, 0, "rival classes play no encounter sting here") local _, seenCount = stingFor("OPP_LASS", true) 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 else package.loaded["src.core.Music"] = nil end if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index 141036b9..76c91eae 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -358,11 +358,13 @@ T.same(checkpoints:capture(game), beforeFailure, -- 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 -- mod sees and calls only mod.checkpoints. -local function makeBattleGame() +local function makeBattleGame(kind) local data = Fixtures.fresh() local save = SaveData.newGame() 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. -- Normalize those once, then place the save on its fixture map. SaveData.validate(save, data) @@ -387,7 +389,8 @@ local function makeBattleGame() self.player = { cellX = x, cellY = y, facing = facing, surfing = false } end 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 end restoredBattle.onFinish = function() end @@ -397,9 +400,19 @@ local function makeBattleGame() data = data, save = save, stack = stack, overworld = battleOw, }, { __index = GameMethods }) 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.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.onFinish = function() end stack.states[2] = battle @@ -437,6 +450,62 @@ if battleSnapshot then "public battle capture/restore/capture is a normalized differential roundtrip") 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 -- at the restored safe decision reaches its semantic auxiliary action without -- selecting a native command. diff --git a/tests/modkit/cases/storage.lua b/tests/modkit/cases/storage.lua index a48d0131..626922bc 100644 --- a/tests/modkit/cases/storage.lua +++ b/tests/modkit/cases/storage.lua @@ -7,6 +7,7 @@ love = love or require("tests.love_stub") local T = require("tests.harness").suite("mod storage") local Loader = require("src.mods.Loader") local Runtime = require("src.mods.Runtime") +local Storage = require("src.mods.Storage") local Version = require("src.core.Version") 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.write(path, body) 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 return true end @@ -107,6 +110,50 @@ T.same(loaded, payload, "stored payload roundtrips as data") T.check(loaded ~= payload and loaded.nested ~= payload.nested, "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 }) T.check(not bad and badCode == "encode_failed", "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, "settings", { enabled = true }), "write settings") 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") -- Mod, playthrough, and game namespaces cannot observe each other. local missing, missingCode = beta:read(current, "states/quick/q1") T.check(missing == nil and missingCode == "not_found", "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") T.check(missing == nil and missingCode == "not_found", "another playthrough cannot read the payload") missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1") T.check(missing == nil and missingCode == "not_found", "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 -- on public read behavior, not the path shape. @@ -142,6 +203,12 @@ local function mainFor(fragment) 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") T.check(type(q1Main) == "string", "failure fixture locates the persisted q1") 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 }, "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. T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target") T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor") diff --git a/tests/modkit/cases/title_playthrough_context.lua b/tests/modkit/cases/title_playthrough_context.lua index 850a3da6..9be61c13 100644 --- a/tests/modkit/cases/title_playthrough_context.lua +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -135,6 +135,17 @@ if type(storage) == "table" then "title binding supports safe same-namespace durable operations") T.same(selected:read("history/title-operation"), { allowed = true }, "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 T.check(title.save.meta.playthroughId == nil, "opening title history never allocates or adopts a playthrough identity") diff --git a/tests/modkit/cases/trainer_before_battle.lua b/tests/modkit/cases/trainer_before_battle.lua new file mode 100644 index 00000000..cad894c6 --- /dev/null +++ b/tests/modkit/cases/trainer_before_battle.lua @@ -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") diff --git a/tests/modkit/cases/world_field_items.lua b/tests/modkit/cases/world_field_items.lua new file mode 100644 index 00000000..a5a4a869 --- /dev/null +++ b/tests/modkit/cases/world_field_items.lua @@ -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()