mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
343 lines
14 KiB
Lua
343 lines
14 KiB
Lua
-- Cross-mod checkpoint ownership and lifecycle contract through public API only.
|
|
-- The Pokemon metadata case models masterwebx/SHINY_POKEMON 1.0.8 at 2141b2e:
|
|
-- shiny identity is data on the plain Pokemon record (`dvs` plus `shiny`).
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
love = love or require("tests.love_stub")
|
|
|
|
local T = require("tests.harness").suite("checkpoint cross-mod compatibility")
|
|
local BattleState = require("src.battle.BattleState")
|
|
local Fixtures = require("tests.modkit").fixtures
|
|
local GameMethods = require("src.core.Game")
|
|
local Loader = require("src.mods.Loader")
|
|
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 Stats = require("src.pokemon.Stats")
|
|
|
|
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
|
local oldGetRandomState = love.math.getRandomState
|
|
local oldSetRandomState = love.math.setRandomState
|
|
local rngState = "cross-mod-rng-A"
|
|
love.math.getRandomState = function() return rngState end
|
|
love.math.setRandomState = function(state) rngState = state end
|
|
|
|
local function memfs(files)
|
|
return {
|
|
read = function(path) return files[path] end,
|
|
write = function(path, body) files[path] = body return true end,
|
|
remove = function(path) files[path] = nil return true end,
|
|
createDirectory = function() return true end,
|
|
getInfo = function(path)
|
|
if files[path] then return { type = "file" } end
|
|
local prefix = path .. "/"
|
|
for key in pairs(files) do
|
|
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
|
end
|
|
return nil
|
|
end,
|
|
load = function(path)
|
|
if not files[path] then return nil, "no file: " .. path end
|
|
return load(files[path], path)
|
|
end,
|
|
getDirectoryItems = function(path)
|
|
local prefix, seen, out = path .. "/", {}, {}
|
|
for key in pairs(files) do
|
|
if key:sub(1, #prefix) == prefix then
|
|
local child = key:sub(#prefix + 1):match("^[^/]+")
|
|
if child and not seen[child] then
|
|
seen[child] = true
|
|
out[#out + 1] = child
|
|
end
|
|
end
|
|
end
|
|
table.sort(out)
|
|
return out
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function shinyDvs()
|
|
return { attack = 2, defense = 10, speed = 10, special = 10, hp = 0 }
|
|
end
|
|
|
|
local function ordinaryDvs()
|
|
return { attack = 1, defense = 1, speed = 1, special = 1, hp = 15 }
|
|
end
|
|
|
|
local function setPokemonIdentity(data, mon, shiny)
|
|
mon.dvs = shiny and shinyDvs() or ordinaryDvs()
|
|
mon.shiny = shiny and true or false
|
|
mon.stats = Stats.calc(data.pokemon[mon.species], mon.level, mon.dvs, mon.statExp)
|
|
mon.hp = math.min(mon.hp or mon.stats.hp, mon.stats.hp)
|
|
end
|
|
|
|
local function setBattlerIdentity(data, battler, shiny)
|
|
setPokemonIdentity(data, battler.mon, shiny)
|
|
battler.curStats = battler.mon.stats
|
|
battler.shownHP = battler.mon.hp
|
|
battler.shownStatus = battler.mon.status
|
|
end
|
|
|
|
local function makeGame()
|
|
local data = Fixtures.fresh()
|
|
local save = SaveData.newGame()
|
|
save.meta.playthroughId = "cross-mod-playthrough"
|
|
save.party = { Pokemon.new(data, "FIXMON_A", 20) }
|
|
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
|
|
save.options.modOptions = {}
|
|
|
|
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
|
local game
|
|
local ow = {
|
|
map = { id = "FIX_TOWN" },
|
|
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
|
|
runner = { isRunning = function() return false end },
|
|
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
|
}
|
|
function ow: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 ow:enter(mapId, x, y, facing, opts)
|
|
if game.failNextEnter then
|
|
game.failNextEnter = false
|
|
error("injected cross-mod reconstruction failure")
|
|
end
|
|
self.map = { id = mapId }
|
|
self.player = {
|
|
cellX = x, cellY = y, facing = facing,
|
|
surfing = game.save.player.surfing and true or false,
|
|
}
|
|
self.runner = { isRunning = function() return false end }
|
|
self.parallelRunners, self.pendingScripts = {}, {}
|
|
self.parallelQueue, self.scriptMoves = {}, {}
|
|
game.lastCheckpointEnter = opts
|
|
end
|
|
function ow:restoreBattleContinuation(battle, origin)
|
|
if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then
|
|
return false
|
|
end
|
|
battle.onFinish = function() end
|
|
return true
|
|
end
|
|
|
|
game = setmetatable({
|
|
data = data, save = save, stack = stack, overworld = ow,
|
|
}, { __index = GameMethods })
|
|
stack.states[1] = ow
|
|
return game, ow
|
|
end
|
|
|
|
local function manifest(id)
|
|
return ('{"id":"%s","name":"%s","version":"1.0.0",')
|
|
:format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}'
|
|
end
|
|
|
|
local files = {
|
|
["mods/cooperator/manifest.json"] = manifest("cooperator"),
|
|
["mods/cooperator/main.lua"] = [[
|
|
return function(mod)
|
|
local cachedStage = mod.save:get("stage", "unset")
|
|
local restoreCount = 0
|
|
mod.options:define({
|
|
{ key = "mode", type = "choice", default = "default",
|
|
choices = { { "A", "A" }, { "B", "B" }, { "C", "C" } } },
|
|
})
|
|
mod.exports.checkpoints = mod.checkpoints
|
|
mod.exports.storage = mod.storage
|
|
mod.exports.setStage = function(stage)
|
|
mod.save:set("stage", stage)
|
|
cachedStage = stage
|
|
end
|
|
mod.exports.stage = function() return mod.save:get("stage", "unset") end
|
|
mod.exports.cachedStage = function() return cachedStage end
|
|
mod.exports.restoreCount = function() return restoreCount end
|
|
mod.events:on("checkpoint.restored", function(ev)
|
|
restoreCount = restoreCount + 1
|
|
cachedStage = mod.save:get("stage", "unset")
|
|
mod.exports.lastRestore = {
|
|
game = ev.game,
|
|
kind = ev.kind,
|
|
top = ev.game.stack:top(),
|
|
}
|
|
end)
|
|
end
|
|
]],
|
|
["mods/passive/manifest.json"] = manifest("passive"),
|
|
["mods/passive/main.lua"] = [[
|
|
return function(mod)
|
|
local cachedStage = mod.save:get("stage", "unset")
|
|
mod.exports.setStage = function(stage)
|
|
mod.save:set("stage", stage)
|
|
cachedStage = stage
|
|
end
|
|
mod.exports.stage = function() return mod.save:get("stage", "unset") end
|
|
mod.exports.cachedStage = function() return cachedStage end
|
|
end
|
|
]],
|
|
}
|
|
|
|
local game, ow = makeGame()
|
|
local loader = Loader.new({ fs = memfs(files) })
|
|
loader.game, game.mods = game, loader
|
|
T.check(loader:load({}) == true, "cooperating fixture mods load")
|
|
local cooperator = loader.exports.cooperator
|
|
local passive = loader.exports.passive
|
|
T.check(type(cooperator) == "table" and type(passive) == "table",
|
|
"fixture exposes only public mod exports")
|
|
if type(cooperator) ~= "table" or type(passive) ~= "table" then
|
|
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
|
love.math.getRandomState = oldGetRandomState
|
|
love.math.setRandomState = oldSetRandomState
|
|
T.finish()
|
|
end
|
|
|
|
cooperator.setStage("A")
|
|
passive.setStage("A")
|
|
game:adoptSave(game.save, true)
|
|
local optionBucket = { mode = "A" }
|
|
game.save.options.modOptions.cooperator = optionBucket
|
|
loader.modOptions.cooperator = optionBucket
|
|
setPokemonIdentity(game.data, game.save.party[1], true)
|
|
T.check(Stats.isShiny(game.save.party[1].dvs),
|
|
"condition A uses the real Gen 2 DV shiny predicate")
|
|
T.check(cooperator.storage:write(game, "history", { generation = "A" }),
|
|
"independent history condition A writes through mod.storage")
|
|
|
|
local overworldA, captureCode = cooperator.checkpoints:capture(game)
|
|
T.check(overworldA ~= nil, "condition A overworld captures: " .. tostring(captureCode))
|
|
|
|
-- Mutate canonical progress, both mod.save buckets, independent storage,
|
|
-- options, and runtime caches to condition B.
|
|
game.save.money = 999999
|
|
setPokemonIdentity(game.data, game.save.party[1], false)
|
|
cooperator.setStage("B")
|
|
passive.setStage("B")
|
|
optionBucket.mode = "B"
|
|
rngState = "cross-mod-rng-B"
|
|
T.check(cooperator.storage:write(game, "history", { generation = "B" }),
|
|
"independent history advances to condition B")
|
|
|
|
local bad = cooperator.checkpoints:capture(game)
|
|
bad.format = 99
|
|
local rejected, rejectCode = cooperator.checkpoints:restore(game, bad)
|
|
T.check(rejected == false and rejectCode == "unsupported_format",
|
|
"failed checkpoint validation is reported")
|
|
T.eq(cooperator.restoreCount(), 0, "failed restore emits no lifecycle event")
|
|
T.eq(cooperator.cachedStage(), "B", "failed restore leaves runtime cache at B")
|
|
T.eq(cooperator.stage(), "B", "failed restore leaves mod.save at B")
|
|
|
|
local failedTarget = cooperator.checkpoints:capture(game)
|
|
game.failNextEnter = true
|
|
local failed, failedCode = cooperator.checkpoints:restore(game, failedTarget)
|
|
T.check(failed == false and failedCode == "restore_failed",
|
|
"failed reconstruction rolls back without committing")
|
|
T.eq(cooperator.restoreCount(), 0,
|
|
"failed reconstruction and successful rollback emit no lifecycle event")
|
|
T.eq(cooperator.cachedStage(), "B",
|
|
"failed reconstruction leaves cooperating runtime cache at B")
|
|
T.eq(cooperator.stage(), "B",
|
|
"failed reconstruction rollback leaves mod.save at B")
|
|
|
|
local restored, restoreCode, restoreMessage =
|
|
cooperator.checkpoints:restore(game, overworldA)
|
|
T.check(restored == true,
|
|
"condition A overworld restores: " .. tostring(restoreCode or restoreMessage))
|
|
T.eq(game.save.money, overworldA.save.money, "core game progress rewinds to A")
|
|
T.eq(game.save.party[1].shiny, true,
|
|
"shiny marker rewinds with its Pokemon record")
|
|
T.check(Stats.isShiny(game.save.party[1].dvs),
|
|
"authoritative shiny DVs rewind with the Pokemon record")
|
|
T.eq(cooperator.stage(), "A", "cooperating mod.save progress rewinds to A")
|
|
T.eq(passive.stage(), "A", "all mods' mod.save progress rewinds generically")
|
|
T.eq(passive.cachedStage(), "B",
|
|
"runtime-only state is not serialized for a non-cooperating mod")
|
|
T.eq(cooperator.cachedStage(), "A",
|
|
"checkpoint lifecycle lets a cooperating mod rebuild its runtime cache")
|
|
T.same(cooperator.storage:read(game, "history"), { generation = "B" },
|
|
"independent mod.storage history does not rewind")
|
|
T.eq(cooperator.restoreCount(), 1, "successful overworld restore emits once")
|
|
local overworldEvent = cooperator.lastRestore or {}
|
|
T.eq(overworldEvent.game, game, "restore event carries the final live game")
|
|
T.eq(overworldEvent.kind, "overworld", "restore event identifies overworld")
|
|
T.eq(overworldEvent.top, ow,
|
|
"restore event runs after the reconstructed overworld is installed")
|
|
T.eq(loader.modOptions.cooperator.mode, "B",
|
|
"per-mod global options stay at condition B")
|
|
T.eq(game.save.options.modOptions.cooperator.mode, "B",
|
|
"checkpoint reattaches the current global options table")
|
|
|
|
-- Repeat the same ownership rules at a supported ordinary wild battle safe point.
|
|
cooperator.setStage("battle-A")
|
|
passive.setStage("battle-A")
|
|
setPokemonIdentity(game.data, game.save.party[1], true)
|
|
local battle = BattleState.newWild(game, "FIXMON_B", 12)
|
|
battle.phase, battle.queue = "menu", {}
|
|
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
|
battle.musicKind = battle:computeMusicKind()
|
|
battle.onFinish = function() end
|
|
setBattlerIdentity(game.data, battle.enemy, true)
|
|
game.stack.states[2] = battle
|
|
rngState = "cross-mod-battle-rng-A"
|
|
|
|
local battleA, battleCaptureCode = cooperator.checkpoints:capture(game)
|
|
T.check(battleA and battleA.kind == "battle",
|
|
"condition A battle captures: " .. tostring(battleCaptureCode))
|
|
if battleA then
|
|
setBattlerIdentity(game.data, battle.player, false)
|
|
setBattlerIdentity(game.data, battle.enemy, false)
|
|
cooperator.setStage("battle-B")
|
|
passive.setStage("battle-B")
|
|
optionBucket.mode = "C"
|
|
rngState = "cross-mod-battle-rng-B"
|
|
T.check(cooperator.storage:write(game, "history", { generation = "battle-B" }),
|
|
"independent history advances during battle")
|
|
|
|
local battleRestored, battleRestoreCode, battleRestoreMessage =
|
|
cooperator.checkpoints:restore(game, battleA)
|
|
T.check(battleRestored == true,
|
|
"condition A battle restores: "
|
|
.. tostring(battleRestoreCode or battleRestoreMessage))
|
|
local restoredBattle = game.stack:top()
|
|
T.eq(restoredBattle.player.mon, game.save.party[1],
|
|
"restored player battler rebinds to canonical party Pokemon")
|
|
T.eq(restoredBattle.player.mon.shiny, true,
|
|
"player shiny metadata rewinds through battle reconstruction")
|
|
T.check(Stats.isShiny(restoredBattle.player.mon.dvs),
|
|
"player shiny DVs rewind through battle reconstruction")
|
|
T.eq(restoredBattle.enemy.mon.shiny, true,
|
|
"enemy shiny metadata rewinds with copied battle Pokemon")
|
|
T.check(Stats.isShiny(restoredBattle.enemy.mon.dvs),
|
|
"enemy shiny DVs rewind through battle reconstruction")
|
|
T.eq(cooperator.stage(), "battle-A", "battle restore rewinds mod.save progress")
|
|
T.eq(cooperator.cachedStage(), "battle-A",
|
|
"battle restore event rebuilds cooperating runtime cache")
|
|
T.eq(passive.cachedStage(), "battle-B",
|
|
"battle restore still does not serialize arbitrary mod runtime")
|
|
T.same(cooperator.storage:read(game, "history"), { generation = "battle-B" },
|
|
"battle restore leaves independent history current")
|
|
T.eq(loader.modOptions.cooperator.mode, "C",
|
|
"battle restore leaves per-mod global options current")
|
|
T.eq(cooperator.restoreCount(), 2, "successful battle restore emits once")
|
|
local battleEvent = cooperator.lastRestore or {}
|
|
T.eq(battleEvent.kind, "battle", "restore event identifies battle")
|
|
T.eq(battleEvent.top, restoredBattle,
|
|
"battle restore event runs after final battle installation")
|
|
T.same(cooperator.checkpoints:capture(game), battleA,
|
|
"combined battle and mod progress is a differential roundtrip")
|
|
end
|
|
|
|
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
|
Runtime.currentMod = nil
|
|
love.math.getRandomState = oldGetRandomState
|
|
love.math.setRandomState = oldSetRandomState
|
|
_G.CROSS_MOD_CHECKPOINT = nil
|
|
|
|
T.finish()
|