From 89f023c7dda4cda84759a5b6f0148cd8f8646994 Mon Sep 17 00:00:00 2001 From: kikimanjaro Date: Wed, 5 Aug 2026 00:10:10 +0200 Subject: [PATCH] Order the save editor DEX grid by Pokedex number or name The DEX grid now defaults to Pokedex-number order with an A-Z view, set from two chips in the header. Sorting is view-only: it never dirties the save, resets the scroll, and no-ops on a re-click. Ops.dexList builds the order deterministically and sorts partial mod records last. Tests pin the orderings against the real generated data. --- tests/save_editor_task7_tests.lua | 114 ++++++++++++++++++++++++++++++ tools/save-editor/Ops.lua | 52 ++++++++++++++ tools/save-editor/State.lua | 1 + tools/save-editor/panels/Dex.lua | 10 ++- 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/tests/save_editor_task7_tests.lua b/tests/save_editor_task7_tests.lua index b45179fc..b6c8e4c7 100644 --- a/tests/save_editor_task7_tests.lua +++ b/tests/save_editor_task7_tests.lua @@ -39,6 +39,7 @@ print("== save editor task 7 tests (Events + Dex) ==") local Ops = require("Ops") local State = require("State") +local Catalog = require("Catalog") local function newState() local S = State.new() @@ -209,5 +210,118 @@ do check(owned > 0, "the dex was not wiped by the unrelated click") end +-- Dex sort ----------------------------------------------------------- + +do + -- Ops.dexList orders the grid by the active mode. This block drives the + -- real generated data (State.new alone carries no Data), so the vanilla + -- 1-151 numbering is what the "dex" mode asserts against. + local Data = require("src.core.Data") + Data:load() + local S = State.new() + S.data = Data + S.cat = Catalog.build(Data) + S.save = require("src.core.SaveData").newGame() + + -- default mode is "dex": number order + eq(S.dexSort, "dex", "a fresh state sorts the dex by number by default") + local byDex = Ops.dexList(S) + eq(#byDex, #S.cat.species, "dexList covers every species") + eq(byDex[1], "BULBASAUR", "dex order starts at #1") + eq(byDex[4], "CHARMANDER", "dex order puts Charmander fourth") + eq(byDex[25], "PIKACHU", "dex order puts Pikachu at #25") + eq(byDex[151], "MEW", "dex order ends at #151") + + -- "name" mode: alphabetical by display name + Ops.dexSort(S, "name") + eq(S.dexSort, "name", "dexSort switches the mode") + local byName = Ops.dexList(S) + eq(#byName, #S.cat.species, "the name sort covers every species too") + eq(byName[1], "ABRA", "the name sort leads with ABRA") + local sorted = true + for i = 2, #byName do + local a = S.data.pokemon[byName[i - 1]] + local b = S.data.pokemon[byName[i]] + local an = (a and a.name or byName[i - 1]):lower() + local bn = (b and b.name or byName[i]):lower() + if an > bn then sorted = false break end + end + check(sorted, "the name sort is alphabetical over display names") + local mi = nil + for i, id in ipairs(byName) do + if id == "MEW" then mi = i + elseif id == "MR_MIME" and mi then + check(i > mi, "MEW sorts before MR.MIME in the name sort") + end + end + local fIdx, mIdx = nil, nil + for i, id in ipairs(byName) do + if id == "NIDORAN_F" then fIdx = i elseif id == "NIDORAN_M" then mIdx = i end + end + check(fIdx and mIdx and fIdx < mIdx, "NIDORAN_F sorts before NIDORAN_M") + + -- switching back to the number order restores the original sequence + Ops.dexSort(S, "dex") + local back = Ops.dexList(S) + eq(back[1], "BULBASAUR", "switching back restores number order") +end + +do + -- the switch is view-only: it resets the grid scroll but never dirties the + -- save, and a re-click on the active mode is a narrated no-op + local S = newState() + S.data = { pokemon = { BULBASAUR = { dex = 1, name = "BULBASAUR" }, + CHARMANDER = { dex = 4, name = "CHARMANDER" }, + PIKACHU = { dex = 25, name = "PIKACHU" }, + SQUIRTLE = { dex = 7, name = "SQUIRTLE" } } } + S.cat = { species = { "BULBASAUR", "CHARMANDER", "SQUIRTLE", "PIKACHU" }, + items = {}, moves = {} } + S.dexOffset = 9 + S.dirty = false + + check(Ops.dexSort(S, "name") == true, "dexSort switches the mode without dirtying") + eq(S.dirty, false, "a sort never dirties the save") + eq(S.dexOffset, 0, "changing the sort resets the grid scroll") + eq(S.status, "", "a sort leaves the status bar alone") + + S.dexOffset = 4 + check(Ops.dexSort(S, "name") == false, "re-clicking the active mode is a no-op") + eq(S.dexOffset, 4, "a no-op sort leaves the scroll alone") + eq(S.dirty, false, "a no-op sort does not dirty either") + eq(S.status, "", "a no-op sort does not narrate either") + + check(Ops.dexSort(S, "bogus") == false, "an unknown mode is refused") + eq(S.dexSort, "name", "a refused mode leaves the sort unchanged") + + -- the keyed list sorts against this mini dataset too + local byDex = Ops.dexList(S) + eq(byDex[1], "BULBASAUR", "mini-catalog dex order is #1 first") + eq(byDex[2], "CHARMANDER", "mini-catalog dex order is #4 second") + Ops.dexSort(S, "name") + eq(Ops.dexList(S)[1], "BULBASAUR", "mini-catalog name order leads with BULBASAUR") +end + +do + -- robustness: a mod-shaped partial record (no name, no dex) must not crash + -- the sort or disappear from the grid -- it just sorts last + local S = newState() + S.data = { pokemon = { BULBASAUR = { dex = 1, name = "BULBASAUR" }, + PARTIAL = { baseStats = { hp = 40 } } } } + S.cat = { species = { "BULBASAUR", "PARTIAL" }, items = {}, moves = {} } + + local byDex = Ops.dexList(S) + eq(#byDex, 2, "a partial record still appears in the dex order") + eq(byDex[2], "PARTIAL", "a record without a dex number sorts last") + + Ops.dexSort(S, "name") + local byName = Ops.dexList(S) + eq(#byName, 2, "a partial record still appears in the name order") + eq(byName[2], "PARTIAL", "a record without a name sorts last, by its id") + + -- and with no data/catalog at all, the list degrades to empty, not nil + local bare = State.new() + eq(#Ops.dexList(bare), 0, "a state with no catalog yields an empty list") +end + print(string.format("save editor task 7 tests: %d passed, %d failed", passed, failed)) if failed > 0 then os.exit(1) end diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 78d72092..853c2d6b 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -717,6 +717,58 @@ function Ops.dexClear(S) return Ops.mark(S, "Pokedex wiped") end +-- ------------------------------------------------------------------ dex sort +-- The DEX grid's row order. Sorting is view-only: it never touches the save, +-- so the list itself is computed here (pure, testable) and the switch is +-- narrated through Ops.say, never Ops.mark. +-- +-- "dex" -- by Pokedex number (1-151), the panel default +-- "name" -- by display name, alphabetical (case-insensitive) +-- +-- A species whose record lacks the sort key (a partial mod record) sorts +-- last, ordered by its id, so the grid can never drop a row or crash. +-- table.sort is not stable, so every sort carries the id as a tiebreak and +-- the order is fully deterministic. +local SORT_KEYS = { + dex = function(def, id) + return def and def.dex or math.huge + end, + name = function(def, id) + local name = def and def.name + return (name and tostring(name):lower()) or tostring(id):lower() + end, +} + +function Ops.dexList(S) + local list = S and S.cat and S.cat.species + if not list then return {} end + local make = SORT_KEYS[S.dexSort == "name" and "name" or "dex"] + local data = S.data + local rows = {} + for _, id in ipairs(list) do + rows[#rows + 1] = { key = make(data and data.pokemon and data.pokemon[id], id), + id = id } + end + table.sort(rows, function(a, b) + if a.key ~= b.key then return a.key < b.key end + return a.id < b.id + end) + local out = {} + for i, r in ipairs(rows) do out[i] = r.id end + return out +end + +-- View-only verb: switching the DEX grid's order resets its scroll but never +-- dirties the save or narrates in the status bar (the active chip carries +-- the mode). Returns true when the mode changed, false on a no-op. +function Ops.dexSort(S, mode) + if mode ~= "name" and mode ~= "dex" then return false end + if S.dexSort == mode then return false end + S.dexSort = mode + S.dexOffset = 0 + return true +end + -- -------------------------------------------------------------------- map -- Outdoor is detected the way the game treats LAST_MAP sources: -- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 47742c78..cdbf84ea 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -77,6 +77,7 @@ function State.new() eventsOffset = 0, -- dex + dexSort = "dex", -- how the DEX grid is ordered: "dex" (by number) | "name" (A-Z) dexOffset = 0, -- map diff --git a/tools/save-editor/panels/Dex.lua b/tools/save-editor/panels/Dex.lua index f8b1629c..05116383 100644 --- a/tools/save-editor/panels/Dex.lua +++ b/tools/save-editor/panels/Dex.lua @@ -24,7 +24,7 @@ function M.draw(S, Kit, x, y, w, h) local s = Kit.scale local pad = 20 * s local dex = Ops.dex(S) - local species = S.cat.species + local species = Ops.dexList(S) local seen, owned, total = Ops.dexCounts(S) Kit.card(x, y, w, h) @@ -44,12 +44,20 @@ function M.draw(S, Kit, x, y, w, h) -- and FLOW, wrapping to further rows when even one is too narrow, so the -- cluster can never paint over the headline or over itself. local actH = 34 * s + -- The two sort chips are view-only (Ops.dexSort never dirties the save); + -- the active mode reads as the accent chip, the other as ghost. They ride + -- the same wrap-aware cluster as the bulk actions so a narrow window flows + -- them to their own rows instead of painting over the headline (#715). local buttons = { { label = "Own party + boxes", kind = "ghost", fn = Ops.dexStamp }, { label = "See all", kind = "accent", fn = Ops.dexSeeAll }, { label = "Own all", kind = "good", fn = Ops.dexOwnAll }, { label = Ops.armLabel(S, "dex-clear", "Wipe dex"), kind = "danger", fn = Ops.dexClear }, + { label = "Dex #", kind = (S.dexSort ~= "name") and "accent" or "ghost", + fn = function(s) Ops.dexSort(s, "dex") end }, + { label = "A-Z", kind = (S.dexSort == "name") and "accent" or "ghost", + fn = function(s) Ops.dexSort(s, "name") end }, } local clusterW = -10 * s for _, b in ipairs(buttons) do