From a77210799f6feee7f57df7cf37626de16e492a93 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 14 Aug 2026 17:33:17 +0200 Subject: [PATCH] feat(mod-api): add trainer battle party scope --- docs/mod-api-gen2-compat.md | 5 + docs/modding.md | 32 ++++++ docs/rfcs/0010-trainer-battle-party-scope.md | 84 +++++++++++++++ src/battle/BattleState.lua | 66 ++++++++++-- src/core/BattleCheckpoint.lua | 43 +++++++- src/ui/PartyMenu.lua | 5 +- src/world/OverworldController.lua | 42 +++++++- tests/engine/trainer_battle_party_scope.lua | 107 +++++++++++++++++++ tests/modkit/cases/checkpoints.lua | 79 +++++++++++++- tests/modkit/cases/trainer_before_battle.lua | 71 ++++++++++++ 10 files changed, 507 insertions(+), 27 deletions(-) create mode 100644 docs/rfcs/0010-trainer-battle-party-scope.md create mode 100644 tests/engine/trainer_battle_party_scope.lua create mode 100644 tests/modkit/cases/trainer_before_battle.lua diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 0cbe81c0..cb04fbf6 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -763,6 +763,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..17d08cea 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -423,6 +423,38 @@ 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() + end, + }) + return true +end) +``` + +Return `true` only when retaining `continue` for a later callback. 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/rfcs/0010-trainer-battle-party-scope.md b/docs/rfcs/0010-trainer-battle-party-scope.md new file mode 100644 index 00000000..507e8459 --- /dev/null +++ b/docs/rfcs/0010-trainer-battle-party-scope.md @@ -0,0 +1,84 @@ +# 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. + -- 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. + +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 2a23d96c..78655ee2 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -617,6 +617,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) @@ -692,13 +730,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, + opts and opts.playerPartyIndices) self.trainer = game.data.trainers[oppClass] assert(self.trainer, "unknown trainer class " .. tostring(oppClass)) -- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName @@ -742,7 +782,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 @@ -2002,7 +2042,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 @@ -3890,7 +3930,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 @@ -3984,9 +4025,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 @@ -4027,7 +4068,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 @@ -4054,6 +4095,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 @@ -4226,7 +4268,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 @@ -4299,6 +4341,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) @@ -4779,6 +4822,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)) @@ -4825,8 +4869,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" @@ -5707,7 +5751,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/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..e2110a78 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3092,6 +3092,27 @@ 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) + 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 + startBattle(options) + 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 +3140,7 @@ 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) -- 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 +3155,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 +3193,22 @@ 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) + 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 diff --git a/tests/engine/trainer_battle_party_scope.lua b/tests/engine/trainer_battle_party_scope.lua new file mode 100644 index 00000000..a424fa35 --- /dev/null +++ b/tests/engine/trainer_battle_party_scope.lua @@ -0,0 +1,107 @@ +-- 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 Fixtures = require("tests.modkit").fixtures +local PartyMenu = require("src.ui.PartyMenu") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local Data = Fixtures.fresh() + +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), + } + return { data = Data, save = save, stack = { + push = function() end, pop = function() end, top = function() end, + } } +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") + +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 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/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/trainer_before_battle.lua b/tests/modkit/cases/trainer_before_battle.lua new file mode 100644 index 00000000..b95aff1c --- /dev/null +++ b/tests/modkit/cases/trainer_before_battle.lua @@ -0,0 +1,71 @@ +-- 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() + +T.finish("trainer_before_battle")