Merge pull request #1766 from MaxTomahawk/adaptive-trainers/field-residual-hook

feat(mod-api): expose field residual descriptors
This commit is contained in:
bryanthaboi
2026-08-24 08:35:10 -04:00
committed by GitHub
7 changed files with 772 additions and 0 deletions
+3
View File
@@ -783,6 +783,9 @@ 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:
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
processing. Gold already has a native weather/between-turn pipeline but does
not yet expose the shared data-only descriptor hook.
- `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
+41
View File
@@ -931,6 +931,47 @@ which stay unconditional and are never visible to a subscriber.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
## Battle field residual hook
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
damage without mutating live battlers. It is guarded and runs after vanilla
status residuals, before field-token expiry and `battle.turn_ended`. The
wrapper receives `(next, context)`, calls `next(context)` for the existing
descriptor list, and appends data-only rows:
```lua
mod.hooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
rows[#rows + 1] = {
side = "enemy", amount = 7,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
```
`context.field` is a detached, data-only view with the same
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
`field.sides` or any live battler aliases. The projection recursively retains
raw tables and finite numbers, strings, and booleans under scalar keys. It
strips metatables and omits functions, userdata, threads, unsupported keys, and
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
even if a live field token uses one internally, and changing any nested view
value cannot change live field state. `context.battlers.player` and `.enemy` are
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
is the current turn number. A descriptor accepts `side`
(`player` or `enemy`), a positive, finite integer number `amount`, and an
optional string `message`.
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
The engine retains HP-bar, faint, experience, and replacement authority. If
both active battlers take terminal residual damage together and the player has
no healthy reserve, this hook batch queues only the player faint authority and
resolves as a blackout loss without an enemy-faint EXP award or replacement.
That precedence is local to accepted rows from this hook; native faint paths
are unchanged when no hook is active. Hook callbacks remain process-local;
checkpoints serialize only field data. Gold does not yet raise this hook; its
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
## Process-lifecycle hooks
These exist so a platform-specific launcher integration (a native shell
+123
View File
@@ -0,0 +1,123 @@
# RFC 0016: Engine-owned field residual descriptors
## Status
Proposed.
## Motivation
Battle-rule mods can keep deterministic data-only state in the public battle
field and observe `battle.turn_ended`, but that event fires after the engine's
residual and faint pipeline. A listener cannot safely deal end-of-round field
damage: directly changing live HP bypasses bar drains, faint messages,
experience, replacements, double-faint resolution, and checkpoint continuation.
Putting callbacks into `battle.field` is also rejected by the checkpoint
serializer, correctly, because executable state is not save-safe.
## Decision and plan extended
This implements **D-AT-004: public engine-owned field residual execution**, the
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
RESIDUALS`. The plan 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 8. The engine delta defines only a generic end-of-round extension point;
it contains no weather names, immunities, damage formula, trainer identity, or
Adaptive Trainers policy.
## Exact API delta
Add the guarded Gen 1 hook:
```lua
mod.hooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
rows[#rows + 1] = {
side = "enemy",
amount = 7,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
```
The hook runs once during an undecided battle's end-of-round processing, after
vanilla status residuals and before field/side token expiry and
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
changes nothing. Vanilla contributes an empty list.
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
recursive projection retains raw tables and finite numbers, strings, and
booleans under scalar keys. It strips metatables and omits functions, userdata,
threads, unsupported keys, and cyclic edges. Thus it exposes neither
`field.sides` nor a graph or executable callback back into live engine state.
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
change the live battle. `turn` is the current Gen 1 turn counter.
A result row is `{ side = "player"|"enemy", amount =
positive_finite_integer_number, message = optional_string }`. Numeric strings,
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
messages fail closed. Damage is clamped to current HP. The engine owns
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
accepted row before scheduling newly fainted battlers. If this hook batch
terminally faints the player with no healthy reserve, it queues only the player
faint authority, so descriptor order cannot race a blackout against an enemy
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
player/enemy order. This precedence is scoped to this hook response; native
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
Wrappers compose by calling `next(context)` and appending their own rows.
Callbacks are not part of the descriptor contract and hook functions are never
stored in the battle.
Gold already owns native weather and a generation-specific between-turn order;
this first additive call site is Gen 1-only. A future Gold site must keep the
same context and descriptor contract and choose its native ordering explicitly.
## Migration and compatibility
Existing mods change nothing. No hook name or payload changes. With no wrapper,
Gen 1 performs the same residual, token, event, and faint work as before,
including native simultaneous-faint resolution, and allocates no context. Gen
2 is unchanged. Existing and new checkpoints keep
serializing only the data stored in `battle.field`; hook callbacks remain
process-local loader state and are never serialized.
The v1 surface remains unchanged: `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
`pokemon.before_give` keep their existing behavior.
## Verification
- The catalog hook parity gate proves null and live-empty buses return the
vanilla list unchanged.
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
detached checkpoint-shaped context, applies damage, and reaches the engine
faint pipeline.
- Engine validation tests cover strict number validation (including numeric
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
omission of functions/userdata/threads/cycles and metatables from the public
field projection, optional messages, non-table results, clamping, and
settled-battle suppression.
- Both descriptor orders are driven through queue completion for a simultaneous
terminal residual. Each proves player blackout loss with no EXP event,
enemy replacement, or replacement UI.
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
context construction. An ordering probe proves the enabled hook runs after
vanilla status residuals and before token expiry and `battle.turn_ended`.
- A no-hook native regression proves a simultaneous zero-HP state still enters
the pre-existing enemy-faint EXP and win authority outside this hook batch.
- Capture/restore/capture evidence proves checkpointed field state round-trips
while the enabled process-local hook remains installed and callable.
## Docs with the change
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
validation, simultaneous-terminal result, and checkpoint boundary.
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
No registry or schema changes are involved, so generated registry docs do not
change.
## Deprecation etiquette
Nothing is deprecated. The hook is additive.
+106
View File
@@ -2792,6 +2792,111 @@ function BattleState:queueResidual(b, opp)
end
end
local function fieldBattlerView(battler, side)
local types = {}
for index, typeId in ipairs(battler.curTypes or {}) do
types[index] = typeId
end
local mon = battler.mon or {}
return {
side = side,
name = battler.name,
hp = tonumber(mon.hp) or 0,
maxHp = tonumber(mon.stats and mon.stats.hp) or tonumber(mon.hp) or 0,
types = types,
vanished = battler.invulnerable and true or false,
}
end
local function publicScalar(value)
local kind = type(value)
if kind == "string" or kind == "boolean" then return value, true end
if kind == "number" and value == value
and value < math.huge and value > -math.huge then
return value, true
end
return nil, false
end
local function publicDataCopy(value, visiting)
local scalar, ok = publicScalar(value)
if ok then return scalar, true end
if type(value) ~= "table" then return nil, false end
visiting = visiting or {}
if visiting[value] then return nil, false end
visiting[value] = true
local copy = {}
for key, child in next, value do
local copiedKey, keyOk = publicScalar(key)
local copiedChild, childOk = publicDataCopy(child, visiting)
if keyOk and childOk then copy[copiedKey] = copiedChild end
end
visiting[value] = nil
return copy, true
end
local function checkpointFieldView(field)
field = field or {}
local view = publicDataCopy({
weather = field.weather,
tokens = field.tokens or {},
})
return view
end
-- Public field residuals are data-only requests. Mods can inspect a detached
-- checkpoint-shaped field view and detached battler views, but only the engine
-- mutates HP, animates the bar, or enters the faint pipeline.
function BattleState:applyFieldResiduals()
if not Runtime.wantsHook("battle.field_residual") then return end
local views = {
player = fieldBattlerView(self.player, "player"),
enemy = fieldBattlerView(self.enemy, "enemy"),
}
local rows = Runtime.call("battle.field_residual", function() return {} end, {
field = checkpointFieldView(self.field),
battlers = views,
turn = self.turnCount or 0,
})
if type(rows) ~= "table" then return end
local fainted = {}
for _, row in ipairs(rows) do
local battler = type(row) == "table" and row.side == "player"
and self.player or type(row) == "table" and row.side == "enemy"
and self.enemy or nil
local amount = type(row) == "table" and row.amount or nil
if battler and battler.mon.hp > 0 and type(amount) == "number"
and amount > 0
and amount < math.huge and amount == math.floor(amount)
and (row.message == nil or type(row.message) == "string") then
amount = math.min(amount, battler.mon.hp)
if type(row.message) == "string" and row.message ~= "" then
self:sayNext(row.message)
end
battler.mon.hp = battler.mon.hp - amount
self:drainNext(battler, battler.mon.hp)
if battler.mon.hp <= 0 then fainted[battler] = true end
end
end
-- A terminal player faint owns a simultaneous field-residual batch. Queue
-- only that authority so its blackout cannot race an enemy EXP/replacement
-- path from the same hook response. Native faint paths remain untouched.
if fainted[self.player]
and not Party.firstHealthy(self:playerPartyView()) then
self:onFaint(self.player)
return
end
-- Otherwise resolve the two sides in engine order after every accepted
-- descriptor has landed. Descriptor order must not decide resolution.
for _, battler in ipairs({ self.player, self.enemy }) do
if fainted[battler] then self:onFaint(battler) end
end
end
function BattleState:endOfTurn()
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
@@ -2845,6 +2950,7 @@ function BattleState:endOfTurn()
b.trappingTurns = nil
end
end
self:applyFieldResiduals()
self:tickTokens()
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
end
@@ -0,0 +1,336 @@
-- Validation and fail-closed behavior for battle.field_residual descriptors.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Events = require("src.mods.Events")
local GameMethods = require("src.core.Game")
local Hooks = require("src.mods.Hooks")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TypeChart = require("src.battle.TypeChart")
local data = T.fixtures.fresh()
TypeChart.load(data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
return battle
end
local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks,
Runtime.errors
local hooks = Hooks.new()
Runtime.install(savedEvents, hooks, {})
local battle = newBattle()
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
local playerType = battle.player.curTypes[1]
local cyclic = { label = "cycle-data" }
cyclic.self = cyclic
local metatableData = setmetatable({ label = "plain-data" }, {
__index = { hidden = "metatable-data" },
})
battle.field.weather = {
id = "probe", turns = 3,
callback = function() end,
handle = io.stdout,
worker = coroutine.create(function() end),
cyclic = cyclic,
metatableData = metatableData,
}
battle.field.tokens[1] = { id = "nested", turns = 2,
state = { intensity = 4 } }
battle:enter()
hooks:wrap("battle.field_residual", function(next, context)
local vanilla = next(context)
T.same(vanilla, {}, "the vanilla contribution is an empty descriptor list")
context.battlers.player.hp = 0
context.battlers.player.types[1] = "MUTATED"
T.eq(context.field.sides, nil,
"the field view has the checkpoint shape and no live side graph")
T.eq(context.field.weather.callback, nil,
"the field view omits executable values")
T.eq(context.field.weather.handle, nil,
"the field view omits userdata")
T.eq(context.field.weather.worker, nil,
"the field view omits threads")
T.eq(context.field.weather.cyclic.label, "cycle-data",
"the field view retains scalar data around a cycle")
T.eq(context.field.weather.cyclic.self, nil,
"the field view omits cyclic edges")
T.eq(getmetatable(context.field.weather.metatableData), nil,
"the field view carries no metatable")
T.eq(context.field.weather.metatableData.label, "plain-data",
"the field view retains raw data from a metatable-bearing table")
T.eq(context.field.weather.metatableData.hidden, nil,
"the field view does not expose metatable-provided values")
context.field.weather.turns = 0
context.field.tokens[1].state.intensity = 99
return {
false,
{ side = "unknown", amount = 20, message = "invalid side" },
{ side = "player", amount = "3", message = "numeric string" },
{ side = "player", amount = 0, message = "zero" },
{ side = "player", amount = -2, message = "negative" },
{ side = "player", amount = 1.5, message = "fractional" },
{ side = "player", amount = "not a number", message = "bad amount" },
{ side = "player", amount = 0 / 0, message = "not finite" },
{ side = "player", amount = math.huge, message = "non-finite" },
{ side = "player", amount = 2, message = function() end },
{ side = "enemy", amount = 3 },
}
end, 0, "validation_probe")
battle:applyFieldResiduals()
T.eq(battle.player.mon.hp, playerHp,
"a descriptor with a non-string message fails closed")
T.eq(battle.enemy.mon.hp, enemyHp - 3,
"a valid descriptor may omit its message")
T.eq(battle.player.curTypes[1], playerType,
"mutating the detached type view cannot mutate the live battler")
T.check(battle.player.mon.hp ~= 0,
"mutating detached HP cannot replace engine damage authority")
T.eq(battle.field.weather.turns, 3,
"mutating the detached weather view cannot mutate live field state")
T.eq(battle.field.tokens[1].state.intensity, 4,
"mutating nested detached token state cannot mutate live field state")
local guarded = newBattle()
local fieldReads, runtimeCalls = 0, 0
guarded.field = setmetatable({}, { __index = function()
fieldReads = fieldReads + 1
return nil
end })
local realRuntimeCall = Runtime.call
Runtime.call = function(...)
runtimeCalls = runtimeCalls + 1
return realRuntimeCall(...)
end
Runtime.install(savedEvents, Hooks.new(), {})
guarded:applyFieldResiduals()
Runtime.call = realRuntimeCall
T.eq(runtimeCalls, 0,
"a disabled field hook never enters Runtime.call")
T.eq(fieldReads, 0,
"a disabled field hook does not construct its field context")
local nilBattle = newBattle()
local nilHp = nilBattle.player.mon.hp
local nilHooks = Hooks.new()
Runtime.install(savedEvents, nilHooks, {})
nilHooks:wrap("battle.field_residual", function() return nil end,
0, "nil_probe")
nilBattle:applyFieldResiduals()
T.eq(nilBattle.player.mon.hp, nilHp,
"a non-table hook result fails closed")
local settled = newBattle()
local settledCalls = 0
local settledHooks = Hooks.new()
Runtime.install(savedEvents, settledHooks, {})
settledHooks:wrap("battle.field_residual", function(next, context)
settledCalls = settledCalls + 1
return next(context)
end, 0, "settled_probe")
settled.result = "win"
settled:endOfTurn()
T.eq(settledCalls, 0,
"a settled battle never invokes field residual policy")
local function drainQueue(battle)
local rows, guard = {}, 0
while battle.queue[1] and guard < 1000 do
guard = guard + 1
local row = table.remove(battle.queue, 1)
rows[#rows + 1] = row
if row.fn then
battle.nextInsert = 0
row.fn()
end
end
T.check(guard < 1000, "the simultaneous-faint queue completes")
return rows
end
local function simultaneousTerminal(order)
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local double = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
double.phase, double.queue = "menu", {}
local originalEnemy, originalEnemyIndex = double.enemy, double.enemyIndex
local startingExp = double.player.mon.exp
local events, doubleHooks = Events.new(), Hooks.new()
local awardCalls, expEvents, switches = 0, 0, 0
events:on("battle.exp_gained", function() expEvents = expEvents + 1 end,
0, "double_probe")
events:on("battle.battler_switched", function() switches = switches + 1 end,
0, "double_probe")
Runtime.install(events, doubleHooks, {})
doubleHooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
for _, side in ipairs(order) do
rows[#rows + 1] = {
side = side,
amount = context.battlers[side].hp,
}
end
return rows
end, 0, "double_probe")
doubleHooks:wrap("battle.exp_award", function(next, context)
awardCalls = awardCalls + 1
return next(context)
end, 0, "double_probe")
double:endOfTurn()
local queued = drainQueue(double)
T.eq(double.player.mon.hp, 0,
"simultaneous residuals settle the player side")
T.eq(double.enemy.mon.hp, 0,
"simultaneous residuals settle the enemy side")
T.eq(double.result, "lose",
"a simultaneous terminal residual resolves as player blackout")
T.eq(double.afterQueue, "finish",
"the completed simultaneous-faint queue closes the battle")
T.eq(double.player.faintQueued, true,
"the terminal hook batch queues player faint authority")
T.eq(double.enemy.faintQueued, nil,
"the terminal hook batch suppresses only its enemy faint authority")
T.eq(double.player.mon.exp, startingExp,
"a blackout does not award contradictory enemy-faint EXP")
T.eq(awardCalls, 0,
"a blackout never enters the enemy EXP-award policy")
T.eq(expEvents, 0,
"a blackout emits no contradictory EXP event")
T.eq(switches, 0,
"a blackout does not send the trainer's reserve into battle")
T.eq(double.enemyIndex, originalEnemyIndex,
"a blackout leaves the enemy roster position unchanged")
T.check(double.enemy == originalEnemy,
"a blackout queues no contradictory enemy replacement")
for _, row in ipairs(queued) do
T.eq(row.ui, nil,
"a simultaneous terminal residual queues no replacement UI")
end
end
simultaneousTerminal({ "player", "enemy" })
simultaneousTerminal({ "enemy", "player" })
local timing = newBattle()
local timingOrder = {}
timing.ruleset = require("src.battle.rulesets.modern_clean")
timing.player.mon.status = "PSN"
timing.field.tokens[1] = { id = "expires", turns = 1,
onExpire = function() timingOrder[#timingOrder + 1] = "token_expired" end }
local timingEvents, timingHooks = Events.new(), Hooks.new()
timingEvents:on("battle.turn_ended", function()
timingOrder[#timingOrder + 1] = "turn_ended"
end, 0, "timing_probe")
Runtime.install(timingEvents, timingHooks, {})
local preStatusHp = timing.player.mon.hp
timingHooks:wrap("battle.field_residual", function(next, context)
timingOrder[#timingOrder + 1] = "field_residual"
T.check(context.battlers.player.hp < preStatusHp,
"the hook snapshot observes completed vanilla status residuals")
return next(context)
end, 0, "timing_probe")
timing:endOfTurn()
T.same(timingOrder,
{ "field_residual", "token_expired", "turn_ended" },
"the hook runs before token expiry and battle.turn_ended")
local oldGetState, oldSetState = love.math.getRandomState,
love.math.setRandomState
local checkpointRng = "field-residual-rng"
love.math.getRandomState = function() return checkpointRng end
love.math.setRandomState = function(state) checkpointRng = state end
local function checkpointBattle()
local save = SaveData.newGame()
save.meta.playthroughId = "field-residual-checkpoint"
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
SaveData.validate(save, data)
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {},
scriptMoves = {},
}
function overworld:captureSave(target)
target.player.map = self.map.id
target.player.x, target.player.y = self.player.cellX, self.player.cellY
target.player.facing = self.player.facing
target.player.surfing = self.player.surfing and true or false
end
function overworld:restoreBattleContinuation(restored, origin)
restored.onFinish = function() end
return origin.kind == "wild_encounter" and origin.map == self.map.id
end
local game = setmetatable({ data = data, save = save, stack = stack,
overworld = overworld }, { __index = GameMethods })
stack.states[1] = overworld
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
battle.musicKind = battle:computeMusicKind()
battle.onFinish = function() end
battle.field.weather = { id = "checkpoint-weather", turns = 5 }
stack.states[2] = battle
return game, battle
end
local checkpointGame = checkpointBattle()
local checkpointHooks, restoredCalls = Hooks.new(), 0
Runtime.install(Events.new(), checkpointHooks, {})
checkpointHooks:wrap("battle.field_residual", function(next, context)
restoredCalls = restoredCalls + 1
T.same(context.field.weather,
{ id = "checkpoint-weather", turns = 5 },
"the enabled hook observes checkpointed field state after restore")
return next(context)
end, 0, "checkpoint_probe")
local snapshot, captureCode = Checkpoint.capture(checkpointGame)
T.check(snapshot ~= nil,
"an enabled process-local hook does not enter the checkpoint: "
.. tostring(captureCode))
if snapshot then
checkpointGame.stack:top().field.weather.turns = 1
local restored, restoreCode, restoreMessage =
Checkpoint.restore(checkpointGame, snapshot)
T.check(restored == true,
"field state reconstructs while the hook remains enabled: "
.. tostring(restoreCode or restoreMessage))
if restored then checkpointGame.stack:top():applyFieldResiduals() end
if restored then
T.same(Checkpoint.capture(checkpointGame), snapshot,
"enabled-hook field state completes capture/restore/capture round-trip")
end
end
T.eq(restoredCalls, 1,
"the process-local hook still runs after checkpoint reconstruction")
love.math.getRandomState, love.math.setRandomState = oldGetState, oldSetState
Runtime.install(savedEvents, savedHooks, savedErrors)
T.finish("battle.field_residual validation")
@@ -0,0 +1,145 @@
-- A sandboxed mod can contribute data-only field residual damage while the
-- engine retains HP, queue, and faint authority. The case also proves that
-- no-mod battles allocate no hook context and remain byte-equivalent.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
local FIXTURE = {
["mods/field_residual_probe/manifest.json"] = [[{
"id": "field_residual_probe",
"name": "Field Residual Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/field_residual_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("battle.field_residual", function(next, context)
mod.exports.calls = (mod.exports.calls or 0) + 1
mod.exports.context = context
local callback = context.field.tokens[1]
and context.field.tokens[1].onExpire
mod.exports.callbackType = type(callback)
if callback then callback() end
local rows = next(context)
rows[#rows + 1] = {
side = "player", amount = 7,
message = context.battlers.player.name .. " is buffeted!",
}
rows[#rows + 1] = {
side = "enemy", amount = 999,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
]],
}
local function newBattle(data)
TypeChart.load(data)
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
battle.field.weather = { id = "sand", turns = 4, source = "probe" }
return battle
end
local vanilla = T.sdk.loadNone({})
local plain = newBattle(vanilla.data)
local plainPlayerHp, plainEnemyHp = plain.player.mon.hp, plain.enemy.mon.hp
plain:endOfTurn()
T.eq(plain.player.mon.hp, plainPlayerHp,
"no-mod end of turn preserves player HP")
T.eq(plain.enemy.mon.hp, plainEnemyHp,
"no-mod end of turn preserves enemy HP")
T.eq(plain.field.weather.turns, 4,
"no-mod path does not reinterpret an unknown data-only field extension")
vanilla.release()
local run = T.sdk.loadMods({ "mods/field_residual_probe" }, {
fs = T.sdk.memfs(FIXTURE),
})
T.eq(#run.errors, 0,
"the public field-residual probe loads cleanly")
local battle = newBattle(run.data)
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
local callbackInvocations = 0
battle.field.tokens[1] = {
id = "callback-bearing", turns = 2,
state = { intensity = 4 },
onExpire = function() callbackInvocations = callbackInvocations + 1 end,
}
battle.player.invulnerable = true
battle:endOfTurn()
local out = run.loader.exports.field_residual_probe or {}
T.eq(out.calls, 1, "the public hook runs exactly once at round end")
T.check(out.context.field ~= battle.field,
"the hook receives a detached checkpoint-shaped field view")
T.same(out.context.field.weather,
{ id = "sand", turns = 4, source = "probe" },
"the detached field view carries checkpointed weather state")
T.eq(out.context.field.sides, nil,
"the detached field view exposes no live battler aliases")
T.eq(out.callbackType, "nil",
"the sandboxed wrapper cannot obtain a live field callback")
T.eq(callbackInvocations, 0,
"the sandboxed wrapper cannot invoke the engine-owned callback")
T.same(out.context.field.tokens[1], {
id = "callback-bearing", turns = 2, state = { intensity = 4 },
}, "the public field view retains data while omitting the callback")
T.eq(out.context.turn, battle.turnCount or 0,
"the hook receives the current turn counter")
T.eq(out.context.battle, nil,
"the hook does not expose the live engine battle object")
T.eq(out.context.battlers.player.side, "player",
"the detached player snapshot identifies its side")
T.eq(out.context.battlers.enemy.side, "enemy",
"the detached enemy snapshot identifies its side")
T.eq(out.context.battlers.player.vanished, true,
"the detached view reports Gen1 semi-invulnerability")
T.eq(out.context.battlers.player.hp, playerHp,
"the player snapshot carries pre-residual HP")
T.eq(out.context.battlers.enemy.hp, enemyHp,
"the enemy snapshot carries pre-residual HP")
T.check(out.context.battlers.player ~= battle.player,
"the public battler view is detached from the engine wrapper")
T.check(out.context.battlers.player.types ~= battle.player.curTypes,
"the public type list is detached")
T.eq(battle.player.mon.hp, playerHp - 7,
"the engine applies the validated player residual amount")
T.eq(battle.enemy.mon.hp, 0,
"the engine clamps residual damage to current HP")
T.eq(battle.enemy.faintQueued, true,
"the engine, not the mod, owns residual faint orchestration")
local sawPlayerMessage, sawEnemyMessage, drains = false, false, 0
for _, row in ipairs(battle.queue) do
local text = row.text and tostring(row.text) or ""
if text:find("buffeted", 1, true) then
if text:find(battle.player.name, 1, true) then sawPlayerMessage = true end
if text:find(battle.enemy.name, 1, true) then sawEnemyMessage = true end
end
if row.drain then drains = drains + 1 end
end
T.check(sawPlayerMessage and sawEnemyMessage,
"validated public messages enter the normal battle queue")
T.check(drains >= 2,
"residual HP changes use normal engine drain rows")
run.release()
T.finish("battle.field_residual public seam")
+18
View File
@@ -22,6 +22,7 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local BattleState = require("src.battle.BattleState")
local Runtime = require("src.mods.Runtime")
local S = require("tests.harness").suite("parity double faint")
local check, eq = S.check, S.eq
@@ -107,4 +108,21 @@ do
check(not saidBlackout(b), "and does not black out")
end
-- enemyMonFainted is also a native authority path used by move effects. A
-- field-residual hook must not change what that path does when no hook is
-- installed, even if both active mons are already at zero HP.
do
Runtime.reset()
local b = battleWith({ 0 }, nil)
b.player = { mon = b.game.save.party[1] }
b.enemy = { mon = { hp = 0 } }
b.awards = 0
b.awardExp = function(self) self.awards = self.awards + 1 end
BattleState.enemyMonFainted(b)
eq(b.awards, 1,
"no-hook simultaneous faint still enters native enemy EXP authority")
eq(b.result, "win",
"no-hook simultaneous faint preserves native enemy-faint resolution")
end
S.finish()