feat(mod-api): add trainer battle party scope

This commit is contained in:
MaxTomahawk
2026-08-14 17:33:17 +02:00
parent b6388013ec
commit a77210799f
10 changed files with 507 additions and 27 deletions
+107
View File
@@ -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()
+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,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")