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
+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")