From 9cc72f951b2035e7f4bfe51be5dd3a2086fc5890 Mon Sep 17 00:00:00 2001 From: kikimanjaro Date: Thu, 6 Aug 2026 18:51:27 +0200 Subject: [PATCH] Add nickname editing to the save editor's mon inspector The mon inspector now has a NICKNAME section: a text field with Set / Clear, commit-on-Enter and discard-on-Escape. Input is gated at the field instead of refused at commit. Only glyphs the game font can actually draw AND that round-trip through a .sav are accepted, capped at the naming screen's 10 glyphs, so a bad keystroke or paste never appears and Set always succeeds. Chars like "@" (the Gen1 string terminator), "#" and the dakuten kana have codec entries but no font tile so they are blocked rather than silently mangled. --- tests/run_save_editor_tests.lua | 177 +++++++++++++++++++++++++ tools/save-editor/App.lua | 18 +++ tools/save-editor/Kit.lua | 10 +- tools/save-editor/Ops.lua | 153 +++++++++++++++++++++ tools/save-editor/State.lua | 2 + tools/save-editor/panels/MonEditor.lua | 43 +++++- 6 files changed, 401 insertions(+), 2 deletions(-) diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index 000d2a31..986e3dfc 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -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 diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 5d421f8e..7b5d3c21 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -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 diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index 4c40d920..2197d7a4 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -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 diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 853c2d6b..5da44907 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -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 ("", 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) diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index cdbf84ea..258e32d2 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -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 diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 0a2230ee..8ccdc13d 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -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