mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
feat(mod-api): add trainer battle party scope
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user