Merge pull request #1286 from MaxTomahawk/adaptive-trainers/battle-party-scope

feat(mod-api): add trainer battle party scope
This commit is contained in:
bryanthaboi
2026-08-14 15:14:31 -04:00
committed by GitHub
12 changed files with 674 additions and 27 deletions
+5
View File
@@ -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
+37
View File
@@ -423,6 +423,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
@@ -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.
+55 -11
View File
@@ -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,
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
@@ -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
+38 -5
View File
@@ -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
+1
View File
@@ -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.
+3 -2
View File
@@ -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
+61 -4
View File
@@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts)
-- a fresh entry, or a stale flag can freeze player input forever
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
@@ -3092,6 +3093,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 +3146,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 +3162,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 +3200,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 +3422,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
+131
View File
@@ -0,0 +1,131 @@
-- Trainer battles may use a battle-local view of save-party records without
-- mutating, reordering, or hiding those records in the authoritative save.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("trainer battle party scope")
local BattleState = require("src.battle.BattleState")
local BagMenu = require("src.ui.BagMenu")
local Fixtures = require("tests.modkit").fixtures
local PartyMenu = require("src.ui.PartyMenu")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local Bag = require("src.inventory.Bag")
local Data = Fixtures.fresh()
Data.items.POTION = { id = "POTION", index = 99, name = "POTION",
price = 300, tossable = true }
local function makeGame()
local save = SaveData.newGame()
save.party = {
Pokemon.new(Data, "FIXMON_A", 10),
Pokemon.new(Data, "FIXMON_B", 11),
Pokemon.new(Data, "FIXMON_C", 12),
}
local stack = { states = {} }
function stack:push(value) self.states[#self.states + 1] = value end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack }
end
local game = makeGame()
local originalParty = game.save.party
local first, second, third = unpack(originalParty)
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1, {
playerPartyIndices = { 2, 3 },
})
T.check(game.save.party == originalParty,
"scoping never replaces the authoritative save-party table")
T.check(game.save.party[1] == first and game.save.party[2] == second
and game.save.party[3] == third,
"scoping never reorders authoritative save-party records")
T.same(battle.playerPartyIndices, { 2, 3 },
"the battle records normalized save-party indices")
T.check(battle.playerParty[1] == second and battle.playerParty[2] == third,
"the local party view contains the same selected Pokemon records")
T.check(battle.player.mon == second,
"initial send chooses the first healthy scoped member")
local menu = PartyMenu.new(game, { battle = battle })
T.check(menu.party == battle.playerParty,
"battle party menus traverse only the local eligible view")
Bag.add(game.save, "POTION", 1)
local bag = BagMenu.new(game, { battle = battle })
local potion
for _, row in ipairs(bag.items) do
if row.value == "POTION" then potion = row; break end
end
T.check(potion ~= nil, "the fixture potion is available for target selection")
bag.onChoose(potion, bag)
local targetPicker = game.stack:top()
T.check(targetPicker and targetPicker.party == battle.playerParty,
"in-battle item target selection traverses only eligible members")
second.hp = 0
battle.player.mon.hp = 0
battle:playerMonFainted()
T.eq(battle.result, nil,
"a healthy scoped replacement prevents premature exhaustion")
third.hp = 0
battle:playerMonFainted()
T.eq(battle.result, "lose",
"an excluded healthy save-party member cannot prevent scoped exhaustion")
T.check(first.hp > 0, "the excluded save-party member remains untouched")
local expGame = makeGame()
expGame.save.inventory.EXP_ALL = 1
local expBattle = BattleState.newTrainer(expGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
local excludedExp = expGame.save.party[1].exp
local participantExp = expGame.save.party[2].exp
local sharedExp = expGame.save.party[3].exp
expBattle.participants = { [expGame.save.party[2]] = true }
expBattle:awardExp()
T.eq(expGame.save.party[1].exp, excludedExp,
"EXP.ALL cannot award an excluded save-party member")
T.check(expGame.save.party[2].exp > participantExp,
"a scoped participant receives battle experience")
T.check(expGame.save.party[3].exp > sharedExp,
"EXP.ALL traverses other eligible scoped members")
local fallbackGame = makeGame()
local fallback = BattleState.newTrainer(fallbackGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 0, 99, 1.5, 0 } })
T.eq(fallback.playerParty, nil,
"a malformed or empty scope degrades to the vanilla full-party path")
T.eq(fallback.player.mon, fallbackGame.save.party[1],
"invalid scope fallback preserves vanilla initial send")
local partialGame = makeGame()
local partial = BattleState.newTrainer(partialGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 99 } })
T.eq(partial.playerParty, nil,
"one invalid member makes the entire scope fall back")
local duplicateGame = makeGame()
local duplicate = BattleState.newTrainer(duplicateGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 2 } })
T.eq(duplicate.playerParty, nil,
"duplicate members make the entire scope fall back")
local malformedOptionsGame = makeGame()
local malformedOptions = BattleState.newTrainer(malformedOptionsGame,
"OPP_FIX_YOUNGSTER", 1, 7)
T.eq(malformedOptions.playerParty, nil,
"a malformed options value degrades to the vanilla full-party path")
local linkGame = makeGame()
local linkBattle = BattleState.newTrainer(linkGame,
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
linkBattle.kind = "link"
linkBattle.result = "guestWin"
linkGame.save.party[2].hp, linkGame.save.party[3].hp = 0, 0
linkBattle:finish()
T.eq(linkBattle.result, "guestWin",
"link spectator outcomes are not rewritten by trainer eligibility scope")
T.finish()
@@ -13,6 +13,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local 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
+74 -5
View File
@@ -358,11 +358,13 @@ T.same(checkpoints:capture(game), beforeFailure,
-- The same public facade must carry a real battle checkpoint end to end. The
-- 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.
@@ -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")