This commit is contained in:
bryanthaboi
2026-08-10 14:59:06 -04:00
13 changed files with 841 additions and 18 deletions
+28
View File
@@ -242,6 +242,34 @@ effects; verifies a recapture; and rolls back runtime plus RNG in memory if
reconstruction fails. Callers that need crash recovery should durably capture
their own recovery checkpoint before restore.
Checkpoint ownership follows the persistence model rather than mod identity:
- canonical `game.save` progress, including every mod's `save.modData` /
`mod.save` bucket and data-only fields added to saved Pokémon, rewinds;
- global and per-mod options remain at their current values;
- independently written `mod.storage` records do not rewind; and
- mod-owned runtime objects, references, and caches are never serialized.
Successful restore emits `checkpoint.restored` only after reconstruction and
differential recapture have committed. Mods that cache rewound progress or hold
references to reconstructed runtime objects can re-read their own public state
and rebuild at that point:
```lua
mod.events:on("checkpoint.restored", function(ev)
-- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed.
cachedQuestStage = mod.save:get("quest_stage", 0)
rebuildRuntimeFor(ev.game, ev.kind)
end)
```
The event is not emitted for validation failure, failed reconstruction, or a
successful rollback. Its payload contains no checkpoint data or other mod's
private state. A mod that deliberately stores progress-coupled truth in
`mod.storage` must version and reconcile that relationship itself; the engine
cannot distinguish it safely from independent history, configuration, or cache
data.
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
## Developer console
+10 -4
View File
@@ -97,7 +97,9 @@ events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites
it does not emit normal `save.loading`/`save.loaded` lifecycle events. After
reconstruction, the engine recaptures and byte-compares normalized data. A failed
apply rolls back and returns `restore_failed`; failure of that rollback returns
`rollback_failed`.
`rollback_failed`. Only after a successful comparison does the engine emit
`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation
failure, failed apply, and successful rollback emit nothing.
Durable recovery remains a caller responsibility: in-memory rollback handles a
runtime exception, not process termination.
@@ -112,9 +114,11 @@ support implied here.
## Migration note for existing mods
**Nothing.** No existing hook, event, save, controller, or world action changes
**Nothing required.** No existing hook, save, controller, or world action changes
when `mod.checkpoints` is unused. The reconstruction path is called only by a
successful public restore after validation.
successful public restore after validation. Mods whose runtime caches derive from
rewound `game.save` or `mod.save` state may optionally subscribe to
`checkpoint.restored` and rebuild from their own public state.
## Parity tests
@@ -124,7 +128,9 @@ successful public restore after validation.
unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync,
`A -> mutate B -> restore A -> recapture A2` equality across representative
progress, settings preservation, compatibility rejection without mutation,
map-side-effect suppression, and injected reconstruction rollback.
map-side-effect suppression, injected reconstruction rollback, mod-owned
metadata and `mod.save` rewind, independent `mod.storage`/options preservation,
and success-only runtime-cache reconciliation through `checkpoint.restored`.
## Deprecation etiquette
+12 -5
View File
@@ -90,11 +90,14 @@ mutation. The engine then:
4. binds an engine-owned wild/trainer completion continuation;
5. installs the battle directly at the settled menu without replaying its intro;
6. restores the RNG after reconstruction has finished; and
7. recaptures and compares the complete checkpoint.
7. recaptures and compares the complete checkpoint; and
8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the
comparison succeeds.
The pre-operation checkpoint is the transaction rollback. A failed post-install
RNG restore is covered: both battle runtime and RNG are reconstructed back to
their original values.
their original values. Validation failure, failed reconstruction, and successful
rollback emit no checkpoint lifecycle event.
## Continuation decision
@@ -112,9 +115,10 @@ contract until a separate semantic ScriptRunner checkpoint RFC exists.
## Migration note
**Existing mods require no changes.** The facade and format number are unchanged;
the new kind and RNG field are additive. Overworld-only callers may continue to
filter `capability.kind`. No-mod behavior is unchanged when checkpoints are
unused.
the new kind, RNG field, and success-only lifecycle event are additive.
Overworld-only callers may continue to filter `capability.kind`. Mods with derived
runtime caches may rebuild them from restored public state when the event fires.
No-mod behavior is unchanged when checkpoints are unused.
## Verification
@@ -129,5 +133,8 @@ unused.
raw RNG result after reload;
- corrupt content/continuation rejection before mutation;
- injected post-install failure with full runtime and RNG rollback;
- mod-added Pokémon metadata and `mod.save` rewind while independent
`mod.storage` and options remain current;
- exactly one post-verification `checkpoint.restored` event and none on failure;
- legacy overworld checkpoint compatibility;
- complete ROM-free engine and public mod-API suites.
+37 -6
View File
@@ -357,13 +357,44 @@ build_linux() {
cp "$LOVE_FILE" "$appdir/game.love"
# The .desktop's Icon=love resolves against the AppDir root by basename,
# so drop the stock love.svg and provide our PNG under the same name;
# .DirIcon is what appimaged/thumbnailers show for the file itself.
# Replace LÖVE's own desktop entry rather than keeping it: it says
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
# managers displayed this image as. Same file as the arm64 build writes,
# so both architectures integrate under the game's name.
local stock_desktop
stock_desktop="$(find "$appdir" -maxdepth 1 -name '*.desktop' | wc -l | tr -d ' ')"
[ "$stock_desktop" = 1 ] \
|| fail "expected exactly one .desktop at the AppDir root, found $stock_desktop"
rm -f "$appdir"/*.desktop
# share/ carries a second, NoDisplay copy of the same entry plus the .love
# file-type icons and mime rule, all left over from LÖVE's `make install`
# (its Exec even points at the CI runner that built it). Nothing at runtime
# reads them -- only share/lua and share/luajit-* are on LUA_PATH -- but
# AppRun puts $APPDIR/share on XDG_DATA_DIRS, so anyone extracting the image
# gets a "LÖVE" entry back. The arm64 AppDir never had them.
rm -rf "$appdir/share/applications" "$appdir/share/pixmaps" \
"$appdir/share/mime" "$appdir/share/icons"
cat > "$appdir/$APP_NAME.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=gen1recomp
Comment=Pokémon Gen 1 recompilation
Exec=$APP_NAME
Icon=$APP_NAME
StartupWMClass=love
Categories=Game;
Terminal=false
EOF
# Icon= resolves against the AppDir root by basename, so the PNG has to be
# named after the desktop entry; .DirIcon is what appimaged and
# file-manager thumbnailers show for the file itself.
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
rm -f "$appdir/love.svg" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/love.png" >/dev/null
cp "$appdir/love.png" "$appdir/.DirIcon"
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
+1
View File
@@ -369,6 +369,7 @@ Name=gen1recomp
Comment=Pokémon Gen 1 recompilation
Exec=$APP_NAME
Icon=$APP_NAME
StartupWMClass=love
Categories=Game;
Terminal=false
EOF
+10 -1
View File
@@ -6,6 +6,7 @@ local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local BattleState = require("src.battle.BattleState")
local BattleCheckpoint = require("src.core.BattleCheckpoint")
local ModRuntime = require("src.mods.Runtime")
local Checkpoint = {}
@@ -398,7 +399,15 @@ function Checkpoint.restore(game, checkpoint)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
if restored and equalData(restored, validated) then return true end
if restored and equalData(restored, validated) then
if ModRuntime.wants("checkpoint.restored") then
ModRuntime.emit("checkpoint.restored", {
game = game,
kind = validated.kind,
})
end
return true
end
err = restored and ("restored state differed at "
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
or ("restored state could not be captured: " .. tostring(verifyCode))
+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
+18
View File
@@ -842,6 +842,24 @@ function App.keypressed(key)
return
end
end
-- The inspector's nickname field is a commit-on-Enter field, unlike the
-- search fields, which are live view state. Enter commits the draft through
-- Ops and blurs; Escape discards it and blurs. Both must run before
-- Kit.keypressed, which maps return/escape to the same "\r" edit and cannot
-- tell "commit" from "cancel".
if Kit.focus == "mon-nickname" then
if key == "return" or key == "kpenter" then
if S.editingMon and Ops.setNickname(S, S.editingMon, S.nicknameDraft) then
S.nicknameDraft = S.editingMon.nickname or ""
end
Kit.blur()
return
elseif key == "escape" then
Kit.blur()
if S.editingMon then S.nicknameDraft = S.editingMon.nickname or "" end
return
end
end
-- A focused text field eats the keys it cares about (typing "s" into the
-- map filter must not trigger Save).
if Kit.keypressed(key) then return end
+9 -1
View File
@@ -426,7 +426,12 @@ end
-- state because Kit had no input widget; this replaces that hack, and App
-- routes love.textinput / love.keypressed in through Kit.textinput /
-- Kit.keypressed. Returns the (possibly edited) value; the caller stores it.
function Kit.textfield(id, x, y, w, h, value, placeholder)
--
-- `opts.sanitize(value)` (optional) is a post-filter run on the merged text
-- right after this frame's edits and BEFORE it draws, so a keystroke or paste
-- the filter rejects never even flashes on screen. It gets the whole value
-- because a paste arrives as one textinput chunk alongside existing text.
function Kit.textfield(id, x, y, w, h, value, placeholder, opts)
audit("control", x, y, w, h, id)
value = tostring(value or "")
if Kit.press(x, y, w, h) then Kit.focus = id end
@@ -444,6 +449,9 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
value = value .. e
end
end
if opts and opts.sanitize then
value = opts.sanitize(value)
end
end
if G then
local r = 8 * Kit.scale
+153
View File
@@ -16,12 +16,17 @@ local PartyMod = require("src.pokemon.Party")
local BoxesMod = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local MonOps = require("MonOps")
local Charmap = require("src.save_convert.data.charmap")
local Ops = {}
Ops.MONEY_MAX = 999999
Ops.STACK_MAX = 99
Ops.ARM_SECONDS = 2.5
-- The in-game naming screen caps a nickname at 10 glyphs
-- (BattleState:askNicknameUI / src/ui/NamingScreen.lua maxLen = 10); the
-- editor mirrors that cap instead of inventing its own.
Ops.NICKNAME_MAX = 10
local function clamp(n, lo, hi)
if n < lo then return lo end
@@ -400,6 +405,154 @@ function Ops.healMon(S, mon)
return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp))
end
-- ----------------------------------------------------------------- nicknames
-- Gen1 has no "is nicknamed" bit: an un-nicknamed mon is mon.nickname == nil,
-- and every display site reads `mon.nickname or def.name`
-- (src/save_convert/GenSave.lua). The editor edits that field directly.
-- The byte length of the UTF-8 glyph starting at lead byte `b`. Self-contained
-- so this (and eachGlyph) also runs headless under luajit, which has no `utf8`
-- standard library.
local function glyphByteLen(b)
if b < 0x80 then return 1 end
if b < 0xE0 then return 2 end
if b < 0xF0 then return 3 end
return 4
end
-- Walk `name` one UTF-8 glyph at a time; fn(glyph) returning false stops the
-- walk early and eachGlyph returns false. Returns true when every glyph was
-- visited. The single place that walks a name, so the count / validate /
-- sanitize paths cannot drift apart (a glyph is "é" or "♂", not one of its
-- bytes, exactly as the naming screen counts its grid cells).
local function eachGlyph(name, fn)
local i, n = 1, #name
while i <= n do
local b = name:byte(i)
local ch = name:sub(i, i + glyphByteLen(b) - 1)
if fn(ch) == false then return false end
i = i + #ch
end
return true
end
-- Glyph count, not byte count: "é" or "♂" is ONE game character, exactly as
-- the naming screen counts its grid cells and GenSave.encodeName counts a
-- charmap sequence.
function Ops.nicknameLength(name)
local n = 0
eachGlyph(tostring(name or ""), function() n = n + 1 end)
return n
end
-- The set of glyphs a nickname may hold: present in BOTH the Gen1 text codec
-- charmap (so the name round-trips through a .sav) and the game's font
-- charmap (so it actually draws). The codec alone is not enough: "@" is the
-- string-terminator byte, and "#" plus the dakuten kana have codec entries
-- but no font tile, so Font.encode (src/render/Font.lua) draws them as a
-- space -- an invisible nickname. Only single-codepoint entries qualify:
-- multi-character macros ("<PK>", the 'd ligature) cannot be typed one
-- character at a time, so they have no place in the input gate.
-- Built once per loaded font table (a mod replacing the font rebuilds it);
-- falls back to the codec-only set when no font data is loaded (headless
-- suites that never call Data:load).
local glyphCache, glyphCacheFont
local function nameGlyphSet(S)
local font = S and S.data and S.data.font
if not (font and font.charmap) then return Charmap.byToken end
if glyphCache and glyphCacheFont == font then return glyphCache end
local set = {}
for _, e in ipairs(font.charmap) do
local s = e.seq
if type(s) == "string" and s ~= "" and Charmap.byToken[s]
and #s == glyphByteLen(s:byte(1)) then
set[s] = true
end
end
glyphCache, glyphCacheFont = set, font
return set
end
-- True when every glyph is a legal nickname glyph (see nameGlyphSet): the
-- name can be stored in a .sav AND draws in the game. Anything else either
-- encodes as "?" (GenSave.encodeName) or renders as a space (Font.encode),
-- which the user did not ask for, so it is refused rather than mangled.
function Ops.nicknameUsable(S, name)
local set = nameGlyphSet(S)
return eachGlyph(tostring(name or ""), function(ch)
return set[ch] ~= nil
end)
end
-- The species' display name, what an un-nicknamed mon reads as.
local function speciesName(S, species)
local def = species and S.data.pokemon[species]
return (def and def.name) or tostring(species or "")
end
-- The input gate for the inspector's nickname field. Given the whole draft
-- (existing text plus this frame's keystrokes and any paste), return the
-- version the game can actually hold: every glyph kept draws in the game
-- (see nameGlyphSet) and the result never exceeds the naming screen's
-- 10-glyph cap. Unrenderable glyphs are skipped, not used to abort the rest
-- of the string, so a paste of "PIKA€CHU" lands as "PIKACHU". The field runs
-- this through Kit.textfield's opts.sanitize, so a blocked character never
-- appears at all.
function Ops.nicknameSanitize(S, name)
local set = nameGlyphSet(S)
local out, count = {}, 0
eachGlyph(tostring(name or ""), function(ch)
if count < Ops.NICKNAME_MAX and set[ch] then
out[#out + 1] = ch
count = count + 1
end
end)
return table.concat(out)
end
-- One verb for both writing and clearing. An empty field means "no nickname",
-- exactly like an empty confirm on the in-game naming screen (which falls
-- through to the species' standard name). A name that equals the species'
-- standard name is the un-nicknamed state in this save format
-- (importedNickname in GenSave.lua maps exactly that to nil), so it is
-- normalized to nil rather than stored as a literal copy of the default.
function Ops.setNickname(S, mon, name)
if not mon then return Ops.say(S, "Pick a slot first") end
name = tostring(name or "")
if name == "" then
return Ops.clearNickname(S, mon)
end
if name == mon.nickname then
return Ops.say(S, ("Already nicknamed %s"):format(name))
end
if name == speciesName(S, mon.species) then
if mon.nickname == nil then
return Ops.say(S, ("%s is already un-nicknamed"):format(mon.species))
end
mon.nickname = nil
return Ops.mark(S, ("%s matches its standard name; nickname cleared")
:format(name))
end
if Ops.nicknameLength(name) > Ops.NICKNAME_MAX then
return Ops.say(S, ("Nicknames are capped at %d characters"):format(Ops.NICKNAME_MAX))
end
if not Ops.nicknameUsable(S, name) then
return Ops.say(S,
"That name has characters the game cannot render or export cleanly")
end
mon.nickname = name
return Ops.mark(S, ("Nicknamed %s \"%s\""):format(mon.species, name))
end
function Ops.clearNickname(S, mon)
if not mon then return Ops.say(S, "Pick a slot first") end
if mon.nickname == nil then
return Ops.say(S, ("%s has no nickname to clear"):format(mon.species))
end
mon.nickname = nil
return Ops.mark(S, ("Cleared %s's nickname"):format(mon.species))
end
-- ------------------------------------------------------------------ boxes
function Ops.boxes(S)
return BoxesMod.ensure(S.save)
+2
View File
@@ -43,6 +43,8 @@ function State.new()
partyOffset = 0, -- roster scroll position (#715)
inspectorScroll = 0, -- MonEditor body pixel scroll (#715)
editingMon = nil, -- reference into party or a box
nicknameDraft = nil, -- text being typed in the inspector's nickname field
nicknameMon = nil, -- the mon the draft belongs to (nil for none)
-- species picker overlay: nil when closed, otherwise { query, offset }
-- plus mode = "box-add" when it is adding to a box instead of changing a
-- species (Ops.openBoxAddPicker). Modal in the literal sense -- App
+42 -1
View File
@@ -218,7 +218,11 @@ function MonEditor.draw(S, Kit, x, y, w, h)
else
colsH = capH + 10 * s + colRowsH + 12 * s + actH
end
-- the nickname section: a caption line (with the Clear button on it) plus
-- the field + Set row
local nickFieldH = 30 * s
local contentH = pad + headerH + 18 * s
+ capH + 10 * s + nickFieldH + 18 * s
+ capH + 10 * s + cellH + 18 * s
+ colsH + pad
@@ -266,8 +270,45 @@ function MonEditor.draw(S, Kit, x, y, w, h)
drawLevelRow(S, Kit, mon, cx, cy + math.max(sprite, titleH) + 12 * s)
end
-- ---------------------------------------------------------- nickname
-- Editing the field is a draft (S.nicknameDraft) held on the mon it belongs
-- to; Set / Enter commit it through Ops.setNickname, which clears on an
-- empty value, and Clear goes through Ops.clearNickname. The draft resets
-- when the selection moves so one mon's typing can never leak onto another.
local nickY = cy + headerH + 18 * s
Kit.caption(cx, nickY, "NICKNAME")
local clearW = 74 * s
local clearH = 24 * s
if Kit.button(cx + inner - clearW, nickY + (capH - clearH) / 2, clearW, clearH,
"Clear", { kind = "danger", font = "micro", radius = 6 * s }) then
Ops.clearNickname(S, mon)
S.nicknameDraft = ""
end
local fieldY = nickY + capH + 10 * s
local setW = 64 * s
local fieldW = inner - setW - 10 * s
if S.nicknameMon ~= mon then
local switching = S.nicknameMon ~= nil
S.nicknameMon = mon
S.nicknameDraft = mon.nickname or ""
-- a still-focused field would keep appending keystrokes to the newly
-- selected mon; the selection move counts as leaving the field. The
-- first sync (nicknameMon starts nil) never blurs: the species picker
-- owns focus when it opens, and blurring there drops the player's typing.
if switching and Kit.focus == "mon-nickname" then Kit.blur() end
end
S.nicknameDraft = Kit.textfield("mon-nickname", cx, fieldY, fieldW, nickFieldH,
S.nicknameDraft or "", "no nickname",
{ sanitize = function(value) return Ops.nicknameSanitize(S, value) end })
if Kit.button(cx + fieldW + 10 * s, fieldY, setW, nickFieldH, "Set",
{ kind = "accent", font = "small", radius = 8 * s }) then
if Ops.setNickname(S, mon, S.nicknameDraft) then
S.nicknameDraft = mon.nickname or ""
end
end
-- ------------------------------------------------------- derived stats
local statsY = cy + headerH + 18 * s
local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
statsY = statsY + capH + 10 * s
local gap = 12 * s