mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
337 lines
13 KiB
Lua
337 lines
13 KiB
Lua
-- 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")
|