diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 716a2607..92cc0839 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -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 diff --git a/docs/modding.md b/docs/modding.md index 1a2251b0..05c11360 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 diff --git a/docs/rfcs/0016-battle-field-residual.md b/docs/rfcs/0016-battle-field-residual.md new file mode 100644 index 00000000..07eba812 --- /dev/null +++ b/docs/rfcs/0016-battle-field-residual.md @@ -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. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index bae8aaa7..c4e50160 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 diff --git a/tests/engine/battle_field_residual_validation.lua b/tests/engine/battle_field_residual_validation.lua new file mode 100644 index 00000000..a61c589b --- /dev/null +++ b/tests/engine/battle_field_residual_validation.lua @@ -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") diff --git a/tests/modkit/cases/battle_field_residual.lua b/tests/modkit/cases/battle_field_residual.lua new file mode 100644 index 00000000..72097f19 --- /dev/null +++ b/tests/modkit/cases/battle_field_residual.lua @@ -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") diff --git a/tests/parity_double_faint.lua b/tests/parity_double_faint.lua index cb136229..01844892 100644 --- a/tests/parity_double_faint.lua +++ b/tests/parity_double_faint.lua @@ -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()