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
@@ -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()