mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user