mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 20:50:21 +02:00
Merge pull request #1286 from MaxTomahawk/adaptive-trainers/battle-party-scope
feat(mod-api): add trainer battle party scope
This commit is contained in:
@@ -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:
|
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
|
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
|
||||||
own yet.
|
own yet.
|
||||||
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
|
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
|
||||||
|
|||||||
@@ -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
|
checkpointed remain excluded. Exceptions are contained by normal hook isolation
|
||||||
and fall through without advancing a turn.
|
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
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
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
@@ -617,6 +617,44 @@ local function newBattle(game)
|
|||||||
return self
|
return self
|
||||||
end
|
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
|
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
|
||||||
function BattleState.newWild(game, species, level, opts)
|
function BattleState.newWild(game, species, level, opts)
|
||||||
local self = newBattle(game)
|
local self = newBattle(game)
|
||||||
@@ -692,13 +730,15 @@ local function applySpecialMoves(data, oppClass, partyIndex, party)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState.newTrainer(game, oppClass, partyIndex)
|
function BattleState.newTrainer(game, oppClass, partyIndex, opts)
|
||||||
local self = newBattle(game)
|
local self = newBattle(game)
|
||||||
self.kind = "trainer"
|
self.kind = "trainer"
|
||||||
self.oppClass = oppClass
|
self.oppClass = oppClass
|
||||||
-- the object_event trainer arg (roster index). computeMusicKind keys
|
-- the object_event trainer arg (roster index). computeMusicKind keys
|
||||||
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
|
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
|
||||||
self.partyIndex = partyIndex or 1
|
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]
|
self.trainer = game.data.trainers[oppClass]
|
||||||
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
||||||
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
||||||
@@ -742,7 +782,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
self.enemyIndex = 1
|
self.enemyIndex = 1
|
||||||
local playerMon = Party.firstHealthy(game.save.party)
|
local playerMon = Party.firstHealthy(self:playerPartyView())
|
||||||
if not playerMon then
|
if not playerMon then
|
||||||
Logger.warn("trainer battle with no healthy party; skipping")
|
Logger.warn("trainer battle with no healthy party; skipping")
|
||||||
self.dead = true
|
self.dead = true
|
||||||
@@ -2002,7 +2042,7 @@ function BattleState:update(dt)
|
|||||||
-- loops the party menu until a healthy mon is picked, so B and
|
-- loops the party menu until a healthy mon is picked, so B and
|
||||||
-- fainted picks land back here and reopen it
|
-- fainted picks land back here and reopen it
|
||||||
if self.player.mon.hp <= 0 then
|
if self.player.mon.hp <= 0 then
|
||||||
if Party.firstHealthy(self.game.save.party) then
|
if Party.firstHealthy(self:playerPartyView()) then
|
||||||
self:openReplacementMenu()
|
self:openReplacementMenu()
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -3890,7 +3930,8 @@ function BattleState:awardExp()
|
|||||||
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
||||||
-- the surviving participants are counted and paid
|
-- the surviving participants are counted and paid
|
||||||
local participants, alive = 0, {}
|
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
|
if self.participants and self.participants[mon] then
|
||||||
participants = participants + 1
|
participants = participants + 1
|
||||||
if mon.hp > 0 then table.insert(alive, mon) end
|
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
|
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||||
-- summary line
|
-- summary line
|
||||||
for _, mon in ipairs(self.game.save.party) do
|
for _, mon in ipairs(playerParty) do
|
||||||
if mon.hp > 0 then
|
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
|
end
|
||||||
end
|
end
|
||||||
@@ -4027,7 +4068,7 @@ function BattleState:enemyMonFainted()
|
|||||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||||
:lower()
|
:lower()
|
||||||
local partyCount = #self.game.save.party
|
local partyCount = #self:playerPartyView()
|
||||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
-- 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
|
-- 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
|
local game = self.game
|
||||||
Screens.push(game, "PartyMenu", {
|
Screens.push(game, "PartyMenu", {
|
||||||
battle = self,
|
battle = self,
|
||||||
|
party = self:playerPartyView(),
|
||||||
forceSwitch = true,
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon ~= self.player.mon and mon.hp > 0 then
|
if mon ~= self.player.mon and mon.hp > 0 then
|
||||||
@@ -4226,7 +4268,7 @@ function BattleState.isOaksLabStarterRival(self)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:playerMonFainted()
|
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
|
-- 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
|
-- 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
|
-- to residual damage on the turn it lands the KO -- used to hit the
|
||||||
@@ -4299,6 +4341,7 @@ function BattleState:openReplacementMenu()
|
|||||||
self:ui(function()
|
self:ui(function()
|
||||||
return self:buildScreen("PartyMenu", {
|
return self:buildScreen("PartyMenu", {
|
||||||
battle = self,
|
battle = self,
|
||||||
|
party = self:playerPartyView(),
|
||||||
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
||||||
forceSwitch = true,
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
@@ -4779,6 +4822,7 @@ function BattleState:openParty()
|
|||||||
self:ui(function()
|
self:ui(function()
|
||||||
return self:buildScreen("PartyMenu", {
|
return self:buildScreen("PartyMenu", {
|
||||||
battle = self,
|
battle = self,
|
||||||
|
party = self:playerPartyView(),
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon == self.player.mon then
|
if mon == self.player.mon then
|
||||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
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.
|
-- 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
|
-- 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).
|
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
||||||
if self.result ~= "lose" and not self.demo
|
if self.kind ~= "link" and self.result ~= "lose" and not self.demo
|
||||||
and not Party.firstHealthy(self.game.save.party) then
|
and not Party.firstHealthy(self:playerPartyView()) then
|
||||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||||
tostring(self.result))
|
tostring(self.result))
|
||||||
self.result = "lose"
|
self.result = "lose"
|
||||||
@@ -5707,7 +5751,7 @@ function BattleState:drawHUDs(slide)
|
|||||||
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
||||||
hudTile(0x6F, 72, 88)
|
hudTile(0x6F, 72, 88)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
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
|
end
|
||||||
local hidePlayer = self.safari or self.demo
|
local hidePlayer = self.safari or self.demo
|
||||||
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
if showStatus and self.player and not hidePlayer and not self.showPlayerBack
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ local BATTLE_FIELDS = {
|
|||||||
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||||
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||||
"endBattleText",
|
"endBattleText",
|
||||||
|
"playerPartyIndices",
|
||||||
}
|
}
|
||||||
|
|
||||||
local function partyIndex(party, mon)
|
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)
|
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||||
end
|
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)
|
local function validateMoveList(data, moves)
|
||||||
if type(moves) ~= "table" then return false end
|
if type(moves) ~= "table" then return false end
|
||||||
for _, move in ipairs(moves) do
|
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
|
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||||
return nil, "invalid_content", "Player battle state is invalid."
|
return nil, "invalid_content", "Player battle state is invalid."
|
||||||
end
|
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 model.kind == "wild" then
|
||||||
if not validateMon(game.data, model.enemyMon)
|
if not validateMon(game.data, model.enemyMon)
|
||||||
or not validateBattler(game.data, model.enemy, 1) then
|
or not validateBattler(game.data, model.enemy, 1) then
|
||||||
@@ -220,15 +248,18 @@ function BattleCheckpoint.validate(game, checkpoint)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
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."
|
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||||
end
|
end
|
||||||
for _, index in ipairs(indices) do
|
if scopedIndices then
|
||||||
if not integer(index, 1, #party) then
|
for index in pairs(referenced) do
|
||||||
|
if not scopedIndices[index] then
|
||||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
if type(model.mimicRestores) ~= "table" then
|
if type(model.mimicRestores) ~= "table" then
|
||||||
return nil, "invalid_checkpoint", "Mimic restore state is missing."
|
return nil, "invalid_checkpoint", "Mimic restore state is missing."
|
||||||
end
|
end
|
||||||
@@ -276,7 +307,9 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
|||||||
local model = checkpoint.runtime.battle
|
local model = checkpoint.runtime.battle
|
||||||
local battle
|
local battle
|
||||||
if model.kind == "trainer" then
|
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.enemyParty = assert(copy(model.enemyParty))
|
||||||
battle.enemyIndex = model.enemyIndex
|
battle.enemyIndex = model.enemyIndex
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ local function pickTargetAndUse(game, battle, id, list)
|
|||||||
local def = game.data.items[id]
|
local def = game.data.items[id]
|
||||||
local opts = {
|
local opts = {
|
||||||
pickOnly = true,
|
pickOnly = true,
|
||||||
|
battle = battle,
|
||||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
-- HP medicine animates its bar with the picker still up (#252). Only
|
||||||
-- out of battle: the in-battle tail closes the bag list underneath
|
-- out of battle: the in-battle tail closes the bag list underneath
|
||||||
-- first, which needs the picker already gone.
|
-- first, which needs the picker already gone.
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ function PartyMenu.new(game, opts)
|
|||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local self = setmetatable({}, PartyMenu)
|
local self = setmetatable({}, PartyMenu)
|
||||||
self.game = game
|
self.game = game
|
||||||
|
local party = opts.party or (opts.battle and opts.battle.playerParty)
|
||||||
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
|
-- PartyMenuInit (home/pokemon.asm) seeds the cursor from
|
||||||
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
|
-- wPartyAndBillsPCSavedMenuItem rather than from zero, and
|
||||||
-- HandlePartyMenuInput writes wCurrentMenuItem back into it on every
|
-- 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
|
-- both zero the byte, which BattleState mirrors. The clamp covers a
|
||||||
-- party that shrank (deposit / release) while the saved index was
|
-- party that shrank (deposit / release) while the saved index was
|
||||||
-- pointing past the end. #768
|
-- 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),
|
self.index = math.min(math.max(1, game.partyMenuSavedIndex or 1),
|
||||||
math.max(1, count))
|
math.max(1, count))
|
||||||
self.onSwitch = opts.onSwitch
|
self.onSwitch = opts.onSwitch
|
||||||
@@ -314,7 +315,7 @@ function PartyMenu.new(game, opts)
|
|||||||
self.tmhm = opts.tmhm
|
self.tmhm = opts.tmhm
|
||||||
self.forceSwitch = opts.forceSwitch
|
self.forceSwitch = opts.forceSwitch
|
||||||
self.battle = opts.battle
|
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.swapFrom = nil
|
||||||
self.submenu = nil
|
self.submenu = nil
|
||||||
self.subIndex = 1
|
self.subIndex = 1
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ function OverworldState:enter(mapId, x, y, facing, opts)
|
|||||||
-- a fresh entry, or a stale flag can freeze player input forever
|
-- a fresh entry, or a stale flag can freeze player input forever
|
||||||
self.engaging = false
|
self.engaging = false
|
||||||
self.emote = nil
|
self.emote = nil
|
||||||
|
self.cancelledTrainerSight = nil
|
||||||
-- volatile WRAM state in pokered; never serialize across save/load
|
-- volatile WRAM state in pokered; never serialize across save/load
|
||||||
self.wildEncounterGraceSteps = 0
|
self.wildEncounterGraceSteps = 0
|
||||||
-- survives save/load: a loaded game may start inside a building whose
|
-- survives save/load: a loaded game may start inside a building whose
|
||||||
@@ -3092,6 +3093,32 @@ local function meetTrainerTheme(cls)
|
|||||||
or "Music_MeetMaleTrainer"
|
or "Music_MeetMaleTrainer"
|
||||||
end
|
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.
|
-- Run the pre-battle text -> battle -> won text -> flags sequence.
|
||||||
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
|
-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript
|
||||||
-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE
|
-- (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])
|
or (header and header.won and Game.data.text[header.won])
|
||||||
|
|
||||||
local BattleState = require("src.battle.BattleState")
|
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
|
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
|
||||||
-- FIRST and only then runs `call EngageMapTrainer` / `jp
|
-- FIRST and only then runs `call EngageMapTrainer` / `jp
|
||||||
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
|
-- 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)
|
local theme = meetTrainerTheme(d.trainerClass)
|
||||||
if theme then require("src.core.Music").play(Game.data, theme) end
|
if theme then require("src.core.Music").play(Game.data, theme) end
|
||||||
end
|
end
|
||||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
|
||||||
|
options)
|
||||||
battle.checkpointOrigin = {
|
battle.checkpointOrigin = {
|
||||||
kind = "trainer_encounter",
|
kind = "trainer_encounter",
|
||||||
map = self.map.id,
|
map = self.map.id,
|
||||||
@@ -3171,10 +3200,31 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
|||||||
end
|
end
|
||||||
self:pushBattle(battle)
|
self:pushBattle(battle)
|
||||||
end
|
end
|
||||||
if skipBattleText then
|
local function prepareBattle()
|
||||||
|
if not Runtime.wantsHook("trainer.before_battle") then
|
||||||
startBattle()
|
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
|
||||||
|
prepareBattle()
|
||||||
else
|
else
|
||||||
Game.stack:push(TextBox.new(Game, battleText, startBattle))
|
Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3372,11 +3422,18 @@ function OverworldState:checkTrainerSight()
|
|||||||
if self.player.moving or self.engaging then return end
|
if self.player.moving or self.engaging then return end
|
||||||
if Game.stack:top() ~= self then return end
|
if Game.stack:top() ~= self then return end
|
||||||
local p = self.player
|
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
|
for _, npc in ipairs(self.npcs) do
|
||||||
local d = npc.def
|
local d = npc.def
|
||||||
-- CheckFightingMapTrainers engages ANY aligned trainer sprite,
|
-- CheckFightingMapTrainers engages ANY aligned trainer sprite,
|
||||||
-- walkers included (they sight between steps)
|
-- walkers included (they sight between steps)
|
||||||
if d.trainerClass and not npc.moving
|
if d.trainerClass and not npc.moving
|
||||||
|
and not (cancelled and cancelled.npcId == npc.id)
|
||||||
and not self:trainerDefeated(npc)
|
and not self:trainerDefeated(npc)
|
||||||
and not mapScripts.talkScript(self.map.id, d.text)
|
and not mapScripts.talkScript(self.map.id, d.text)
|
||||||
and trainerSpriteOnScreen(npc, p) then
|
and trainerSpriteOnScreen(npc, p) then
|
||||||
|
|||||||
@@ -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 T = require("tests.modkit")
|
||||||
local OW = require("src.world.OverworldController")
|
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 function setUpvalue(fn, name, val)
|
||||||
local i = 1
|
local i = 1
|
||||||
@@ -96,6 +98,89 @@ T.eq(rivalCount, 0, "rival classes play no encounter sting here")
|
|||||||
local _, seenCount = stingFor("OPP_LASS", true)
|
local _, seenCount = stingFor("OPP_LASS", true)
|
||||||
T.eq(seenCount, 0, "self.engaging suppresses a second sting")
|
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
|
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
|
||||||
else package.loaded["src.core.Music"] = nil end
|
else package.loaded["src.core.Music"] = nil end
|
||||||
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
|
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
|
||||||
|
|||||||
@@ -358,11 +358,13 @@ T.same(checkpoints:capture(game), beforeFailure,
|
|||||||
-- The same public facade must carry a real battle checkpoint end to end. The
|
-- 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
|
-- engine-side fixture is deliberately constructed outside the probe mod; the
|
||||||
-- mod sees and calls only mod.checkpoints.
|
-- mod sees and calls only mod.checkpoints.
|
||||||
local function makeBattleGame()
|
local function makeBattleGame(kind)
|
||||||
local data = Fixtures.fresh()
|
local data = Fixtures.fresh()
|
||||||
local save = SaveData.newGame()
|
local save = SaveData.newGame()
|
||||||
save.meta.playthroughId = "public-battle-playthrough"
|
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.
|
-- The tiny fixture registry intentionally omits several full-game defaults.
|
||||||
-- Normalize those once, then place the save on its fixture map.
|
-- Normalize those once, then place the save on its fixture map.
|
||||||
SaveData.validate(save, data)
|
SaveData.validate(save, data)
|
||||||
@@ -387,7 +389,8 @@ local function makeBattleGame()
|
|||||||
self.player = { cellX = x, cellY = y, facing = facing, surfing = false }
|
self.player = { cellX = x, cellY = y, facing = facing, surfing = false }
|
||||||
end
|
end
|
||||||
function battleOw:restoreBattleContinuation(restoredBattle, origin)
|
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
|
return false
|
||||||
end
|
end
|
||||||
restoredBattle.onFinish = function() end
|
restoredBattle.onFinish = function() end
|
||||||
@@ -397,9 +400,19 @@ local function makeBattleGame()
|
|||||||
data = data, save = save, stack = stack, overworld = battleOw,
|
data = data, save = save, stack = stack, overworld = battleOw,
|
||||||
}, { __index = GameMethods })
|
}, { __index = GameMethods })
|
||||||
stack.states[1] = battleOw
|
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.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.musicKind = battle:computeMusicKind()
|
||||||
battle.onFinish = function() end
|
battle.onFinish = function() end
|
||||||
stack.states[2] = battle
|
stack.states[2] = battle
|
||||||
@@ -437,6 +450,62 @@ if battleSnapshot then
|
|||||||
"public battle capture/restore/capture is a normalized differential roundtrip")
|
"public battle capture/restore/capture is a normalized differential roundtrip")
|
||||||
end
|
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
|
-- The mod receives the normal public hook facade, never BattleState. START
|
||||||
-- at the restored safe decision reaches its semantic auxiliary action without
|
-- at the restored safe decision reaches its semantic auxiliary action without
|
||||||
-- selecting a native command.
|
-- 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")
|
||||||
Reference in New Issue
Block a user