mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 06:55:29 +02:00
Merge pull request #1766 from MaxTomahawk/adaptive-trainers/field-residual-hook
feat(mod-api): expose field residual descriptors
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user