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.
This commit is contained in:
kikimanjaro
2026-08-06 18:51:27 +02:00
parent aa6217e581
commit 9cc72f951b
6 changed files with 401 additions and 2 deletions
+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