This commit is contained in:
bryanthaboi
2026-08-10 14:59:06 -04:00
13 changed files with 841 additions and 18 deletions
+342
View File
@@ -0,0 +1,342 @@
-- 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()
+177
View File
@@ -291,6 +291,106 @@ do
eq(mon.level, 1, "stepSpecies keeps the level")
end
do
-- Nicknames: the editor edits mon.nickname, which is nil when un-nicknamed
-- (every display site reads `mon.nickname or def.name`, GenSave.lua). The
-- game's naming screen caps at 10 glyphs and treats an empty confirm as "no
-- nickname", so the verbs below mirror that: "" clears, a name matching the
-- species' standard name normalizes back to nil, too-long or unrenderable
-- names refuse with a status line, and nothing silently no-ops.
local S = State.new()
S.data = Data
S.cat = Catalog.build(Data)
S.save = SaveData.newGame()
local mon = MonOps.create(Data, "CHARIZARD", 50)
S.save.party = { mon }
S.editingMon = mon
eq(Ops.nicknameLength("POKEMON"), 7, "nicknameLength counts ASCII glyphs")
eq(Ops.nicknameLength("ééé"), 3, "nicknameLength counts a multi-byte char as one glyph")
eq(Ops.nicknameLength("♂♀!"), 3, "nicknameLength counts symbol glyphs")
check(Ops.nicknameUsable(S, "CHARIZARD"), "ASCII letters are renderable")
check(Ops.nicknameUsable(S, "Nidoking"), "lower case is renderable")
check(Ops.nicknameUsable(S, "é") == true, "a charmap glyph is renderable")
check(Ops.nicknameUsable(S, "PIKA€") == false, "a non-charmap glyph is not renderable")
check(Ops.nicknameUsable(S, "🤖") == false, "an emoji is not renderable")
-- "@" is the Gen1 string terminator: the codec has an entry for it but the
-- game font has no tile, so Font.encode draws it as a space in-game
check(Ops.nicknameUsable(S, "POKE@MON") == false,
"the terminator @ is not a renderable nickname glyph")
check(Ops.nicknameUsable(S, "POKE#MON") == false,
"the # marker is not a renderable nickname glyph")
-- the field gate: sanitize skips unrenderable glyphs and clamps at 10, so
-- what reaches the mon can only ever be a legal Gen 1 nickname
eq(Ops.nicknameSanitize(S, "PIKA\226\130\172"), "PIKA",
"sanitize drops an unrenderable glyph")
eq(Ops.nicknameSanitize(S, "PIKA\226\130\172CHU"), "PIKACHU",
"sanitize skips a bad glyph mid-name instead of aborting the rest")
eq(Ops.nicknameSanitize(S, "POKE@MON"), "POKEMON",
"sanitize strips the invisible @ terminator")
eq(Ops.nicknameSanitize(S, "1234567890123"), "1234567890",
"sanitize clamps the draft at 10 glyphs")
eq(Ops.nicknameSanitize(S, "\195\169"), "\195\169",
"sanitize keeps a charmap glyph")
eq(Ops.nicknameSanitize(S, ""), "", "sanitize of empty is empty")
Ops.setNickname(S, mon, "SPARKY")
eq(mon.nickname, "SPARKY", "setNickname stores the name")
check(S.dirty == true, "setNickname marks the save dirty")
eq(S.status:match("SPARKY") ~= nil, true, "setNickname narrates the new name")
S.dirty = false
check(Ops.setNickname(S, mon, "SPARKY") == false,
"setting the same nickname again is a no-op")
check(S.dirty == false, "the no-op did not dirty the save")
check(S.status:match("Already nicknamed") ~= nil, "the no-op explains itself")
-- a name matching the species' standard name is the un-nicknamed state
Ops.setNickname(S, mon, "CHARIZARD")
eq(mon.nickname, nil, "a name equal to the standard name normalizes to nil")
eq(S.status:match("standard name") ~= nil, true, "the normalization explains itself")
check(Ops.clearNickname(S, mon) == false,
"clearing an already-un-nicknamed mon is a no-op")
check(S.status:match("no nickname") ~= nil, "the no-op explains itself")
-- empty input means clear, like an empty naming-screen confirm
Ops.setNickname(S, mon, "SPARKY")
eq(mon.nickname, "SPARKY", "re-nicknamed for the empty-clear check")
check(Ops.setNickname(S, mon, "") == true, "an empty name is a valid clear")
eq(mon.nickname, nil, "an empty name clears the nickname")
check(S.status:match("Cleared") ~= nil, "the clear narrates")
Ops.setNickname(S, mon, "1234567890")
eq(mon.nickname, "1234567890", "a 10-glyph name is accepted")
S.dirty = false
check(Ops.setNickname(S, mon, "12345678901") == false,
"an 11-glyph name is refused")
eq(mon.nickname, "1234567890", "a refused name leaves the mon alone")
check(S.dirty == false, "a refused name does not dirty the save")
check(S.status:match("capped at 10") ~= nil, "the length refusal explains itself")
check(Ops.setNickname(S, mon, "PIKA€") == false,
"a name with an unrenderable glyph is refused")
eq(mon.nickname, "1234567890", "a refused glyph leaves the mon alone")
check(S.status:match("cannot render") ~= nil, "the glyph refusal explains itself")
check(Ops.setNickname(S, mon, "POKE@MON") == false,
"a name with the invisible @ terminator is refused")
eq(mon.nickname, "1234567890", "a refused @ name leaves the mon alone")
check(S.status:match("cannot render") ~= nil, "the @ refusal explains itself")
check(Ops.setNickname(S, nil, "X") == false, "setNickname without a mon refuses")
check(S.status:match("Pick a slot") ~= nil, "and explains itself")
check(Ops.clearNickname(S, nil) == false, "clearNickname without a mon refuses")
-- the canonical round trip: what the game reads back is the same either way
mon.nickname = "SPARKY"
local encoded = SaveData.encode(S.save)
local back = SaveData.decode(encoded)
eq(back.party[1].nickname, "SPARKY", "a nickname survives a save round trip")
end
-- App.load corrupt-save vs missing-save (Important fix #2): App.load takes
-- an optional path override precisely so tests can drive this without
-- touching the real default save file.
@@ -799,6 +899,83 @@ do
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- The inspector's nickname field is commit-on-Enter: the draft lives in
-- S.nicknameDraft while typing, Enter commits it through Ops.setNickname,
-- and Escape discards it. Drive it through App the way a player would:
-- focus the field (a click is just a Kit.focus assignment here), type,
-- drain the edits with a draw, then press Enter / Escape.
local Kit = require("Kit")
local tmpPath = os.tmpname() .. "-nickname-save.lua"
local data = SaveData.newGame()
data.party = { MonOps.create(Data, "CHARIZARD", 50) }
local f = io.open(tmpPath, "wb")
f:write(SaveData.encode(data))
f:close()
App.load(tmpPath, { version = "red" })
local S = App.getState()
S.tab = "party"
Ops.selectParty(S, 1)
local mon = S.editingMon
eq(mon.nickname, nil, "the save starts un-nicknamed")
-- type "SPARKY" and commit with Enter
Kit.focus = "mon-nickname"
App.textinput("SPARKY")
App.draw()
eq(S.nicknameDraft, "SPARKY", "typed text lands in the draft")
App.keypressed("return")
eq(mon.nickname, "SPARKY", "Enter commits the draft to the mon")
check(S.dirty == true, "the commit marks the save dirty")
check(Kit.focus == nil, "Enter blurs the field")
S.dirty = false
-- The field has no select-all, so a rename is backspace-then-type (the
-- caret parks at the end, exactly like the editor's other fields).
local function clearField(n)
Kit.focus = "mon-nickname"
for _ = 1, n do App.keypressed("backspace") end
App.draw()
end
-- type junk, then Escape: nothing is committed and the draft is discarded
clearField(#mon.nickname)
App.textinput("ZEPTO")
App.draw()
eq(S.nicknameDraft, "ZEPTO", "the draft holds the new typing")
App.keypressed("escape")
eq(mon.nickname, "SPARKY", "Escape does not commit")
eq(S.nicknameDraft, "SPARKY", "Escape resets the draft to the committed name")
check(Kit.focus == nil, "Escape blurs the field")
-- an unrenderable glyph is blocked AT INPUT: the euro sign never reaches
-- the draft, so the field can only ever hold what the game can render
clearField(#mon.nickname)
App.textinput("PIKA\226\130\172") -- PIKA + euro sign, not a charmap glyph
App.draw()
eq(S.nicknameDraft, "PIKA", "an unrenderable glyph is dropped at input")
App.keypressed("return")
eq(mon.nickname, "PIKA", "the clean draft commits on Enter")
-- and the 10-glyph cap blocks extra input the same way
clearField(#mon.nickname)
App.textinput("123456789012345")
App.draw()
eq(S.nicknameDraft, "1234567890", "typing past 10 glyphs clamps at 10")
-- the @ terminator never reaches the draft either: it draws as a space
-- in-game, so the field strips it like any other unrenderable glyph. The
-- clamp test above left an uncommitted draft, so clear the whole draft.
clearField(#S.nicknameDraft)
App.textinput("POKE@MON")
App.draw()
eq(S.nicknameDraft, "POKEMON", "the @ terminator is stripped at input")
os.remove(tmpPath)
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
end
do
-- #541 modal shield. Kit hit-tests without a z-order, so the picker cannot
-- simply be drawn last: the chrome and the panel underneath would take the