mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
89f023c7dd
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.
811 lines
29 KiB
Lua
811 lines
29 KiB
Lua
-- Every mutation the save editor can make to a loaded save, behind one
|
|
-- funnel: Ops.mark() is the ONLY thing that sets S.dirty, and it always
|
|
-- writes the status line at the same time. That is rule 2 of the design
|
|
-- spec (SaveEditor.dc.html) -- dirty state has to be visible from any tab,
|
|
-- and no branch may silently no-op. Panels above this file only lay out
|
|
-- pixels and dispatch; the rules live here, which is also what makes them
|
|
-- testable without a window (tests/save_editor_*).
|
|
--
|
|
-- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6
|
|
-- (src/pokemon/Party), box 20 x 12 (src/pokemon/Boxes), money 0-999999,
|
|
-- item stack 99 and the configured bag capacity (20 by default;
|
|
-- src/inventory/Bag).
|
|
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local PartyMod = require("src.pokemon.Party")
|
|
local BoxesMod = require("src.pokemon.Boxes")
|
|
local Bag = require("src.inventory.Bag")
|
|
local MonOps = require("MonOps")
|
|
|
|
local Ops = {}
|
|
|
|
Ops.MONEY_MAX = 999999
|
|
Ops.STACK_MAX = 99
|
|
Ops.ARM_SECONDS = 2.5
|
|
|
|
local function clamp(n, lo, hi)
|
|
if n < lo then return lo end
|
|
if n > hi then return hi end
|
|
return n
|
|
end
|
|
Ops.clamp = clamp
|
|
|
|
local function now()
|
|
if love and love.timer and love.timer.getTime then
|
|
return love.timer.getTime()
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- ------------------------------------------------------------ the funnel
|
|
-- Mark the save dirty and say what changed. Also disarms any pending
|
|
-- destructive confirmation: doing something else is an implicit "no".
|
|
function Ops.mark(S, msg)
|
|
S.dirty = true
|
|
S.status = msg or S.status
|
|
S.armed = nil
|
|
-- A fresh edit invalidates any prior "leave anyway" arming: quitting,
|
|
-- closing or opening another file has to be confirmed again, so a stale
|
|
-- confirmation from an earlier round of edits cannot discard these.
|
|
S._quitArmed = false
|
|
S._openArmed = false
|
|
return true
|
|
end
|
|
|
|
-- Status-only: used by the branches that refuse (party full, box full, no
|
|
-- cell selected). A refusal must still speak, it must just not dirty.
|
|
function Ops.say(S, msg)
|
|
S.status = msg
|
|
return false
|
|
end
|
|
|
|
-- Two-click confirm for destructive verbs. The first call arms `id` and
|
|
-- returns false; a second call with the same id inside ARM_SECONDS returns
|
|
-- true and disarms. Panels label the button through Ops.armLabel.
|
|
function Ops.arm(S, id, msg)
|
|
local t = now()
|
|
if S.armed == id then
|
|
local at = S.armedAt
|
|
if not (t and at and (t - at) > Ops.ARM_SECONDS) then
|
|
S.armed, S.armedAt = nil, nil
|
|
return true
|
|
end
|
|
end
|
|
S.armed, S.armedAt = id, t
|
|
S.status = msg
|
|
return false
|
|
end
|
|
|
|
-- The label a destructive button should carry right now.
|
|
function Ops.armLabel(S, id, label)
|
|
if S.armed ~= id then return label end
|
|
local t, at = now(), S.armedAt
|
|
if t and at and (t - at) > Ops.ARM_SECONDS then
|
|
S.armed, S.armedAt = nil, nil
|
|
return label
|
|
end
|
|
return "Confirm?"
|
|
end
|
|
|
|
function Ops.disarm(S)
|
|
S.armed, S.armedAt = nil, nil
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ party
|
|
function Ops.selectParty(S, index)
|
|
local mon = S.save.party[index]
|
|
if not mon then return false end
|
|
S.selectedParty = index
|
|
S.editingMon = mon
|
|
S.status = ("Selected party slot %d (%s)"):format(index, mon.species)
|
|
return true
|
|
end
|
|
|
|
function Ops.partyAdd(S)
|
|
if #S.save.party >= PartyMod.MAX then
|
|
return Ops.say(S, ("Party is full (%d/%d)"):format(#S.save.party, PartyMod.MAX))
|
|
end
|
|
local species = S.cat.species[1]
|
|
local mon = MonOps.create(S.data, species, 5)
|
|
mon.ot = S.save.player.name
|
|
mon.otId = S.save.player.id
|
|
table.insert(S.save.party, mon)
|
|
S.selectedParty = #S.save.party
|
|
S.editingMon = mon
|
|
return Ops.mark(S, ("Added %s Lv5 to party slot %d"):format(species, #S.save.party))
|
|
end
|
|
|
|
function Ops.partyRemove(S)
|
|
local index = S.selectedParty
|
|
local mon = S.save.party[index]
|
|
if not mon then return Ops.say(S, "No party slot selected") end
|
|
if not Ops.arm(S, "party-remove",
|
|
("Remove %s from slot %d? Click again to confirm"):format(mon.species, index)) then
|
|
return false
|
|
end
|
|
table.remove(S.save.party, index)
|
|
if S.editingMon == mon then S.editingMon = nil end
|
|
S.selectedParty = clamp(index, 1, math.max(#S.save.party, 1))
|
|
S.editingMon = S.save.party[S.selectedParty]
|
|
return Ops.mark(S, ("Removed %s from the party"):format(mon.species))
|
|
end
|
|
|
|
-- delta is -1 (up) or +1 (down); the selection follows the mon.
|
|
function Ops.partyMove(S, delta)
|
|
local i = S.selectedParty
|
|
local j = i + delta
|
|
local party = S.save.party
|
|
if not (party[i] and party[j]) then
|
|
return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon")
|
|
end
|
|
party[i], party[j] = party[j], party[i]
|
|
S.selectedParty = j
|
|
return Ops.mark(S, ("Moved %s to slot %d"):format(party[j].species, j))
|
|
end
|
|
|
|
-- ------------------------------------------------------- selected mon edits
|
|
-- All four of these round-trip through MonOps, which recomputes stats from
|
|
-- the Gen1 formula, so the inspector can never show illegal HP.
|
|
function Ops.setLevel(S, mon, level)
|
|
if not mon then return false end
|
|
local want = clamp(math.floor(level), 1, 100)
|
|
if want == mon.level then
|
|
return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100")
|
|
end
|
|
MonOps.setLevel(S.data, mon, want)
|
|
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
|
|
end
|
|
|
|
-- A catalog id is only usable as a real mon when its record carries what the
|
|
-- Gen1 formulas read: Stats.calc indexes baseStats.<stat> unconditionally
|
|
-- (src/pokemon/Stats.lua, home/move_mon.asm CalcStat), because the asm's
|
|
-- BaseStats is a fixed 151-entry table and every row is complete. The
|
|
-- editor's list is NOT that table -- it is every key in Data.pokemon after
|
|
-- the mod merge -- and a mod loaded at api 1 can leave a partial record in
|
|
-- there, since the schema violation downgrades to a warning rather than a
|
|
-- rejection (src/mods/Schemas.lua R.pokemon). So the editor tests the record
|
|
-- instead of trusting the list: without this, picking such a species walked
|
|
-- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window
|
|
-- down (#541).
|
|
local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
|
|
|
|
function Ops.speciesUsable(S, id)
|
|
local def = id and S.data.pokemon[id]
|
|
if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end
|
|
for _, key in ipairs(BASE_STAT_KEYS) do
|
|
if type(def.baseStats[key]) ~= "number" then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- The one funnel every species change goes through (the picker, the stepper,
|
|
-- anything later). MonOps asserts and recalculates, so an unusable record is
|
|
-- refused before it runs, and the round trip itself is fenced: a record that
|
|
-- passes the check above but still trips a formula has to leave the mon
|
|
-- exactly as it was and speak in the status bar, not take the editor with it.
|
|
function Ops.setSpecies(S, mon, id)
|
|
if not mon then return false end
|
|
if id == mon.species then
|
|
return Ops.say(S, ("Already a %s"):format(tostring(id)))
|
|
end
|
|
if not Ops.speciesUsable(S, id) then
|
|
return Ops.say(S, ("%s has no usable base stats, cannot assign it")
|
|
:format(tostring(id)))
|
|
end
|
|
-- MonOps.recalc replaces mon.stats with a fresh table rather than editing
|
|
-- it in place, so holding the old reference is a real rollback.
|
|
local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp
|
|
local wasStats, wasHp = mon.stats, mon.hp
|
|
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id)
|
|
if not ok then
|
|
mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp
|
|
mon.stats, mon.hp = wasStats, wasHp
|
|
return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err)))
|
|
end
|
|
return Ops.mark(S, ("Species set to %s"):format(id))
|
|
end
|
|
|
|
-- Kept for the keyboard and test path; the inspector opens the searchable
|
|
-- picker instead of walking the catalog one arrow at a time (#541). Skips
|
|
-- ids Ops.setSpecies would refuse, so one bad record cannot park the walk.
|
|
function Ops.stepSpecies(S, mon, delta)
|
|
if not mon then return false end
|
|
local list = S.cat.species
|
|
local n = #list
|
|
if n == 0 then return Ops.say(S, "No species in the catalog") end
|
|
local idx = 1
|
|
for i, id in ipairs(list) do
|
|
if id == mon.species then idx = i break end
|
|
end
|
|
for step = 1, n do
|
|
local nextId = list[((idx - 1 + delta * step) % n) + 1]
|
|
if nextId ~= mon.species and Ops.speciesUsable(S, nextId) then
|
|
return Ops.setSpecies(S, mon, nextId)
|
|
end
|
|
end
|
|
return Ops.say(S, "No other species in the catalog can be assigned")
|
|
end
|
|
|
|
-- Search predicate behind the picker's field: the id, the display name, and a
|
|
-- bare dex number ("25" finds PIKACHU), all case-insensitive and plain (no
|
|
-- pattern magic, so a "." typed by accident matches a literal dot).
|
|
function Ops.speciesMatches(S, id, query)
|
|
if not query or query == "" then return true end
|
|
local q = tostring(query):lower()
|
|
if id:lower():find(q, 1, true) then return true end
|
|
local def = S.data.pokemon[id]
|
|
local name = def and def.name
|
|
if name and tostring(name):lower():find(q, 1, true) then return true end
|
|
local dex = tonumber(def and def.dex)
|
|
-- dex matches exactly, in either the bare or the padded form the inspector
|
|
-- prints ("25" and "025" both find PIKACHU). A substring match here would
|
|
-- pull in ELECTABUZZ (#125) on a search for 25, which reads as a bug.
|
|
return dex ~= nil and (q == tostring(dex) or q == ("%03d"):format(dex))
|
|
end
|
|
|
|
function Ops.speciesSearch(S, query)
|
|
local out = {}
|
|
for _, id in ipairs(S.cat.species) do
|
|
if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- The picker is modal editor chrome, not a save mutation, so its flag lives
|
|
-- with the other view state on S (State.new). Ops owns the door only because
|
|
-- both the inspector and App need one and neither should require the other.
|
|
-- `opened` marks the frame the picker went up: the click that opened it is
|
|
-- still live when the overlay draws later in that same frame (#541).
|
|
function Ops.openSpeciesPicker(S, Kit)
|
|
if not S.editingMon then
|
|
return Ops.say(S, "Pick a slot first, then choose its species")
|
|
end
|
|
S.speciesPicker = { query = "", offset = 0, opened = true }
|
|
-- focus the field on open so the mobile soft keyboard rises with it (#529)
|
|
if Kit then Kit.focus = "species-picker" end
|
|
return true
|
|
end
|
|
|
|
-- The item catalog minus the badges, which are toggles on their own row and
|
|
-- would otherwise be "addable" into the bag as ordinary items.
|
|
function Ops.itemSearch(S, query)
|
|
query = tostring(query or ""):lower():gsub("^%s+", ""):gsub("%s+$", "")
|
|
local out = {}
|
|
for _, id in ipairs(S.cat.items) do
|
|
if not Ops.isBadgeId(id)
|
|
and (query == "" or id:lower():find(query, 1, true)) then
|
|
out[#out + 1] = id
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- `dest` is "bag" or "pc"; the picker can flip it while open. `opened`
|
|
-- marks the frame it went up, so the click that opened it is not also read
|
|
-- as a tap outside (the same rule the species picker follows).
|
|
function Ops.openItemPicker(S, Kit, dest)
|
|
S.itemPicker = { query = "", offset = 0, opened = true,
|
|
dest = dest or "bag" }
|
|
-- focus the field on open so the mobile soft keyboard rises with it (#529)
|
|
if Kit then Kit.focus = "item-picker" end
|
|
return true
|
|
end
|
|
|
|
function Ops.closeItemPicker(S, Kit)
|
|
S.itemPicker = nil
|
|
if Kit and Kit.blur then Kit.blur() end
|
|
end
|
|
|
|
function Ops.closeSpeciesPicker(S, Kit)
|
|
S.speciesPicker = nil
|
|
if Kit and Kit.blur then Kit.blur() end
|
|
end
|
|
|
|
-- The Boxes panel's add flow rides the same picker (#715): instead of
|
|
-- silently dropping catalog entry #1 into the box, "+ Add mon here" and the
|
|
-- dashed empty cells open the picker in box-add mode, and the committed
|
|
-- species goes through Ops.boxAddSpecies below. No selection is required:
|
|
-- the target is the box, not a mon.
|
|
function Ops.openBoxAddPicker(S, Kit)
|
|
local box = Ops.boxes(S)[S.selectedBox]
|
|
if #box >= BoxesMod.CAPACITY then
|
|
return Ops.say(S, ("Box %d is full (%d/%d)")
|
|
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
|
end
|
|
S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" }
|
|
if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529)
|
|
return true
|
|
end
|
|
|
|
-- Commit half of the box-add picker. Builds the mon exactly the way
|
|
-- Ops.partyAdd does (MonOps.create at Lv5, owned by the save's player), so a
|
|
-- box mon and a party mon born in the editor are indistinguishable.
|
|
function Ops.boxAddSpecies(S, id)
|
|
local box = Ops.boxes(S)[S.selectedBox]
|
|
if #box >= BoxesMod.CAPACITY then
|
|
return Ops.say(S, ("Box %d is full (%d/%d)")
|
|
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
|
end
|
|
if not Ops.speciesUsable(S, id) then
|
|
return Ops.say(S, ("%s has no usable base stats, cannot add it")
|
|
:format(tostring(id)))
|
|
end
|
|
local mon = MonOps.create(S.data, id, 5)
|
|
mon.ot = S.save.player.name
|
|
mon.otId = S.save.player.id
|
|
table.insert(box, mon)
|
|
S.selectedBoxSlot = #box
|
|
S.editingMon = mon
|
|
return Ops.mark(S, ("Added %s Lv5 to box %d slot %d")
|
|
:format(id, S.selectedBox, #box))
|
|
end
|
|
|
|
function Ops.setDv(S, mon, key, value)
|
|
if not mon then return false end
|
|
local want = clamp(math.floor(value), 0, 15)
|
|
if want == mon.dvs[key] then
|
|
return Ops.say(S, ("%s DV is already %d"):format(key, want))
|
|
end
|
|
MonOps.setDv(S.data, mon, key, want)
|
|
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
|
|
end
|
|
|
|
function Ops.cycleMove(S, mon, slot)
|
|
if not mon then return false end
|
|
local moves = S.cat.moves
|
|
local current = mon.moves and mon.moves[slot] and mon.moves[slot].id
|
|
local idx = 0
|
|
if current then
|
|
for i, id in ipairs(moves) do
|
|
if id == current then idx = i break end
|
|
end
|
|
end
|
|
local nextId = moves[(idx % #moves) + 1]
|
|
MonOps.setMove(S.data, mon, slot, nextId)
|
|
return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
|
|
end
|
|
|
|
function Ops.clearMove(S, mon, slot)
|
|
if not (mon and mon.moves and mon.moves[slot]) then
|
|
return Ops.say(S, ("Move slot %d is already empty"):format(slot))
|
|
end
|
|
local id = mon.moves[slot].id
|
|
mon.moves[slot] = nil
|
|
return Ops.mark(S, ("Cleared move slot %d (%s)"):format(slot, id))
|
|
end
|
|
|
|
function Ops.resetMoves(S, mon)
|
|
if not mon then return false end
|
|
local def = S.data.pokemon[mon.species]
|
|
local learned = Pokemon.movesAtLevel(def, mon.level)
|
|
mon.moves = {}
|
|
for slot, id in ipairs(learned) do
|
|
MonOps.setMove(S.data, mon, slot, id)
|
|
end
|
|
return Ops.mark(S, ("Reset %s to its Lv%d learnset (%d moves)")
|
|
:format(mon.species, mon.level, #learned))
|
|
end
|
|
|
|
function Ops.healMon(S, mon)
|
|
if not mon then return false end
|
|
if mon.hp == mon.stats.hp and not mon.status then
|
|
return Ops.say(S, ("%s is already at full HP"):format(mon.species))
|
|
end
|
|
mon.hp = mon.stats.hp
|
|
mon.status = nil
|
|
for _, mv in ipairs(mon.moves or {}) do
|
|
local def = S.data.moves[mv.id]
|
|
if def then mv.pp = def.pp + ((mv.ppUps or 0) * math.floor(def.pp / 5)) end
|
|
end
|
|
return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp))
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ boxes
|
|
function Ops.boxes(S)
|
|
return BoxesMod.ensure(S.save)
|
|
end
|
|
|
|
function Ops.selectBox(S, index)
|
|
S.selectedBox = clamp(index, 1, BoxesMod.COUNT)
|
|
S.selectedBoxSlot = 1
|
|
S.save.currentBox = S.selectedBox
|
|
local box = Ops.boxes(S)[S.selectedBox]
|
|
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, BoxesMod.CAPACITY)
|
|
return true
|
|
end
|
|
|
|
function Ops.stepBox(S, delta)
|
|
local n = BoxesMod.COUNT
|
|
return Ops.selectBox(S, ((S.selectedBox - 1 + delta) % n) + 1)
|
|
end
|
|
|
|
function Ops.selectBoxSlot(S, index)
|
|
local box = Ops.boxes(S)[S.selectedBox]
|
|
S.selectedBoxSlot = clamp(index, 1, BoxesMod.CAPACITY)
|
|
local mon = box[S.selectedBoxSlot]
|
|
S.editingMon = mon
|
|
S.status = mon
|
|
and ("Selected %s Lv%d in box %d slot %d")
|
|
:format(mon.species, mon.level, S.selectedBox, S.selectedBoxSlot)
|
|
or ("Box %d slot %d is empty"):format(S.selectedBox, S.selectedBoxSlot)
|
|
return true
|
|
end
|
|
|
|
-- Kept for the keyboard/test path; the Boxes panel itself goes through the
|
|
-- species picker (Ops.openBoxAddPicker -> Ops.boxAddSpecies) so the user
|
|
-- chooses what lands in the box instead of always getting catalog entry #1.
|
|
function Ops.boxAdd(S)
|
|
local box = Ops.boxes(S)[S.selectedBox]
|
|
if #box >= BoxesMod.CAPACITY then
|
|
return Ops.say(S, ("Box %d is full (%d/%d)")
|
|
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
|
|
end
|
|
local species = S.cat.species[1]
|
|
local mon = MonOps.create(S.data, species, 5)
|
|
mon.ot = S.save.player.name
|
|
mon.otId = S.save.player.id
|
|
table.insert(box, mon)
|
|
S.selectedBoxSlot = #box
|
|
S.editingMon = mon
|
|
return Ops.mark(S, ("Added %s Lv5 to box %d slot %d")
|
|
:format(species, S.selectedBox, #box))
|
|
end
|
|
|
|
function Ops.withdraw(S)
|
|
local box = Ops.boxes(S)[S.selectedBox]
|
|
local mon = box[S.selectedBoxSlot]
|
|
if not mon then return Ops.say(S, "No box slot selected") end
|
|
if #S.save.party >= PartyMod.MAX then
|
|
return Ops.say(S, ("Party is full (%d/%d), deposit one first")
|
|
:format(#S.save.party, PartyMod.MAX))
|
|
end
|
|
table.remove(box, S.selectedBoxSlot)
|
|
table.insert(S.save.party, mon)
|
|
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
|
|
S.selectedParty = #S.save.party
|
|
return Ops.mark(S, ("Withdrew %s to party slot %d"):format(mon.species, #S.save.party))
|
|
end
|
|
|
|
function Ops.release(S)
|
|
local box = Ops.boxes(S)[S.selectedBox]
|
|
local mon = box[S.selectedBoxSlot]
|
|
if not mon then return Ops.say(S, "No box slot selected") end
|
|
if not Ops.arm(S, "box-release",
|
|
("Release %s permanently? Click again to confirm"):format(mon.species)) then
|
|
return false
|
|
end
|
|
table.remove(box, S.selectedBoxSlot)
|
|
if S.editingMon == mon then S.editingMon = nil end
|
|
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
|
|
return Ops.mark(S, ("Released %s"):format(mon.species))
|
|
end
|
|
|
|
-- Follows BoxesMod.deposit: fills the current box first, then the next box
|
|
-- with room, and says where the mon actually landed.
|
|
function Ops.deposit(S)
|
|
local i = S.selectedParty
|
|
local mon = S.save.party[i]
|
|
if not mon then return Ops.say(S, "No party slot selected") end
|
|
local boxNum = BoxesMod.deposit(S.save, mon)
|
|
if not boxNum then
|
|
return Ops.say(S, "Every box is full, release something first")
|
|
end
|
|
table.remove(S.save.party, i)
|
|
S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1))
|
|
S.selectedBox = boxNum
|
|
if S.editingMon == mon then S.editingMon = nil end
|
|
return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxNum))
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ items
|
|
function Ops.addMoney(S, delta)
|
|
local want = clamp((S.save.money or 0) + delta, 0, Ops.MONEY_MAX)
|
|
if want == S.save.money then
|
|
return Ops.say(S, delta < 0 and "Money is already $0"
|
|
or ("Money is already capped at $%d"):format(Ops.MONEY_MAX))
|
|
end
|
|
S.save.money = want
|
|
return Ops.mark(S, ("Money set to $%d"):format(want))
|
|
end
|
|
|
|
function Ops.maxMoney(S)
|
|
return Ops.addMoney(S, Ops.MONEY_MAX)
|
|
end
|
|
|
|
function Ops.addToBag(S, id)
|
|
if not id then return Ops.say(S, "Pick an item first") end
|
|
local capacity = Bag.capacity(S.data)
|
|
if Bag.add(S.save, id, 1, S.data) then
|
|
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
|
|
:format(id, Bag.slots(S.save), capacity))
|
|
end
|
|
return Ops.say(S, ("Bag is full (%d/%d slots)")
|
|
:format(Bag.slots(S.save), capacity))
|
|
end
|
|
|
|
function Ops.bagAdjust(S, id, delta)
|
|
if not id then return Ops.say(S, "No bag row selected") end
|
|
if delta > 0 then
|
|
local have = S.save.inventory[id] or 0
|
|
if have >= Ops.STACK_MAX then
|
|
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
|
|
end
|
|
Bag.add(S.save, id, delta, S.data)
|
|
else
|
|
Bag.remove(S.save, id, -delta)
|
|
if not S.save.inventory[id] then
|
|
return Ops.mark(S, ("Removed the last %s from the bag"):format(id))
|
|
end
|
|
end
|
|
return Ops.mark(S, ("%s x%d"):format(id, S.save.inventory[id] or 0))
|
|
end
|
|
|
|
function Ops.bagDrop(S, id)
|
|
if not id then return Ops.say(S, "No bag row selected") end
|
|
local qty = S.save.inventory[id] or 0
|
|
Bag.remove(S.save, id, qty)
|
|
return Ops.mark(S, ("Dropped all %d %s"):format(qty, id))
|
|
end
|
|
|
|
function Ops.pcItems(S)
|
|
S.save.pcItems = S.save.pcItems or {}
|
|
return S.save.pcItems
|
|
end
|
|
|
|
function Ops.pcOrder(S)
|
|
local ids = {}
|
|
for id in pairs(Ops.pcItems(S)) do ids[#ids + 1] = id end
|
|
table.sort(ids)
|
|
return ids
|
|
end
|
|
|
|
function Ops.addToPc(S, id)
|
|
if not id then return Ops.say(S, "Pick an item first") end
|
|
local pc = Ops.pcItems(S)
|
|
pc[id] = math.min(Ops.STACK_MAX, (pc[id] or 0) + 1)
|
|
return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id]))
|
|
end
|
|
|
|
function Ops.pcAdjust(S, id, delta)
|
|
if not id then return Ops.say(S, "No PC row selected") end
|
|
local pc = Ops.pcItems(S)
|
|
if not pc[id] then return Ops.say(S, ("%s is not in PC storage"):format(id)) end
|
|
if delta > 0 and pc[id] >= Ops.STACK_MAX then
|
|
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
|
|
end
|
|
pc[id] = clamp(pc[id] + delta, 0, Ops.STACK_MAX)
|
|
if pc[id] <= 0 then
|
|
pc[id] = nil
|
|
return Ops.mark(S, ("Removed %s from PC storage"):format(id))
|
|
end
|
|
return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id]))
|
|
end
|
|
|
|
function Ops.pcDrop(S, id)
|
|
if not id then return Ops.say(S, "No PC row selected") end
|
|
local pc = Ops.pcItems(S)
|
|
local qty = pc[id] or 0
|
|
pc[id] = nil
|
|
return Ops.mark(S, ("Dropped all %d %s from PC storage"):format(qty, id))
|
|
end
|
|
|
|
-- Badges are truthy inventory flags, not stackable items, which is why the
|
|
-- design gives them toggle chips instead of quantity rows.
|
|
function Ops.isBadgeId(id)
|
|
return id:find("BADGE", 1, true) ~= nil
|
|
end
|
|
|
|
function Ops.badgeIds(S)
|
|
local ids = {}
|
|
for _, id in ipairs(S.cat.items) do
|
|
if Ops.isBadgeId(id) then ids[#ids + 1] = id end
|
|
end
|
|
return ids
|
|
end
|
|
|
|
function Ops.toggleBadge(S, id)
|
|
-- #515: badges are truthy inventory entries written as 1 by the in-game
|
|
-- grant (checkVictoryRewards, src/world/OverworldController.lua) and by
|
|
-- GenSave's .sav import; read and write that same shape here, or a badge
|
|
-- earned in game reads as unowned and an editor-written boolean blows up
|
|
-- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua).
|
|
local on = S.save.inventory[id] and true or false
|
|
S.save.inventory[id] = (not on) and 1 or nil
|
|
return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned"))
|
|
end
|
|
|
|
-- ----------------------------------------------------------------- events
|
|
function Ops.setFlag(S, name, on)
|
|
S.save.flags[name] = on and true or nil
|
|
return Ops.mark(S, ("%s = %s"):format(name, tostring(on and true or false)))
|
|
end
|
|
|
|
function Ops.setKey(S, tableKey, key, on)
|
|
S.save[tableKey] = S.save[tableKey] or {}
|
|
S.save[tableKey][key] = on and true or nil
|
|
return Ops.mark(S, ("%s.%s = %s"):format(tableKey, key, tostring(on and true or false)))
|
|
end
|
|
|
|
function Ops.setToggle(S, mapId, name, on)
|
|
local toggles = S.save.objectToggles or {}
|
|
S.save.objectToggles = toggles
|
|
toggles[mapId] = toggles[mapId] or {}
|
|
toggles[mapId][name] = on and true or false
|
|
return Ops.mark(S, ("%s / %s = %s"):format(mapId, name, tostring(on and true or false)))
|
|
end
|
|
|
|
function Ops.clearTable(S, tableKey, label)
|
|
local count = 0
|
|
for _ in pairs(S.save[tableKey] or {}) do count = count + 1 end
|
|
if count == 0 then return Ops.say(S, ("%s is already empty"):format(label)) end
|
|
if not Ops.arm(S, "clear-" .. tableKey,
|
|
("Clear all %d %s entries? Click again to confirm"):format(count, label)) then
|
|
return false
|
|
end
|
|
S.save[tableKey] = {}
|
|
return Ops.mark(S, ("Cleared %d %s entries"):format(count, label))
|
|
end
|
|
|
|
-- -------------------------------------------------------------------- dex
|
|
function Ops.dex(S)
|
|
S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} }
|
|
S.save.pokedex.seen = S.save.pokedex.seen or {}
|
|
S.save.pokedex.owned = S.save.pokedex.owned or {}
|
|
return S.save.pokedex
|
|
end
|
|
|
|
function Ops.dexCounts(S)
|
|
local dex = Ops.dex(S)
|
|
local seen, owned = 0, 0
|
|
for _ in pairs(dex.seen) do seen = seen + 1 end
|
|
for _ in pairs(dex.owned) do owned = owned + 1 end
|
|
return seen, owned, #S.cat.species
|
|
end
|
|
|
|
-- Owning implies having seen; un-seeing clears owned. Both directions are
|
|
-- the game's own rule, enforced here so a hand-edited dex stays legal.
|
|
function Ops.dexSeen(S, species, on)
|
|
local dex = Ops.dex(S)
|
|
dex.seen[species] = on and true or nil
|
|
if not on then dex.owned[species] = nil end
|
|
return Ops.mark(S, ("%s %s"):format(species, on and "marked seen" or "cleared"))
|
|
end
|
|
|
|
function Ops.dexOwned(S, species, on)
|
|
local dex = Ops.dex(S)
|
|
dex.owned[species] = on and true or nil
|
|
if on then dex.seen[species] = true end
|
|
return Ops.mark(S, ("%s %s"):format(species, on and "marked owned" or "un-owned"))
|
|
end
|
|
|
|
function Ops.dexStamp(S)
|
|
local dex = Ops.dex(S)
|
|
local n = 0
|
|
local function stamp(mon)
|
|
if not dex.owned[mon.species] then n = n + 1 end
|
|
dex.seen[mon.species] = true
|
|
dex.owned[mon.species] = true
|
|
end
|
|
for _, m in ipairs(S.save.party) do stamp(m) end
|
|
for _, box in ipairs(S.save.boxes or {}) do
|
|
for _, m in ipairs(box) do stamp(m) end
|
|
end
|
|
if n == 0 then return Ops.say(S, "Party and boxes are already all in the dex") end
|
|
return Ops.mark(S, ("Owned %d more species from party + boxes"):format(n))
|
|
end
|
|
|
|
function Ops.dexSeeAll(S)
|
|
local dex = Ops.dex(S)
|
|
for _, species in ipairs(S.cat.species) do dex.seen[species] = true end
|
|
return Ops.mark(S, ("Marked all %d species seen"):format(#S.cat.species))
|
|
end
|
|
|
|
function Ops.dexOwnAll(S)
|
|
local dex = Ops.dex(S)
|
|
for _, species in ipairs(S.cat.species) do
|
|
dex.seen[species] = true
|
|
dex.owned[species] = true
|
|
end
|
|
return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species))
|
|
end
|
|
|
|
function Ops.dexClear(S)
|
|
if not Ops.arm(S, "dex-clear", "Wipe the whole Pokedex? Click again to confirm") then
|
|
return false
|
|
end
|
|
S.save.pokedex = { seen = {}, owned = {} }
|
|
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
|
|
-- has already visited.
|
|
function Ops.isOutdoor(S, map)
|
|
if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then
|
|
return true
|
|
end
|
|
if next(map.def.connections or {}) ~= nil then return true end
|
|
return (S.save.visited and S.save.visited[map.id]) or false
|
|
end
|
|
|
|
function Ops.setPlayerHere(S)
|
|
local cell = S.mapClickCell
|
|
if not cell then return Ops.say(S, "Click a cell first") end
|
|
S.save.player.map = S.mapId
|
|
S.save.player.x = cell.cx
|
|
S.save.player.y = cell.cy
|
|
return Ops.mark(S, ("Player set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
|
|
end
|
|
|
|
function Ops.setLastOutdoor(S, map)
|
|
local cell = S.mapClickCell
|
|
if not cell then return Ops.say(S, "Click a cell first") end
|
|
if not Ops.isOutdoor(S, map) then
|
|
return Ops.say(S, S.mapId .. " doesn't look outdoor (no connections, not visited)")
|
|
end
|
|
S.save.lastOutdoor = { id = S.mapId, x = cell.cx, y = cell.cy }
|
|
return Ops.mark(S, ("lastOutdoor set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
|
|
end
|
|
|
|
function Ops.setLastHeal(S)
|
|
local cell = S.mapClickCell
|
|
if not cell then return Ops.say(S, "Click a cell first") end
|
|
S.save.lastHeal = { map = S.mapId, x = cell.cx, y = cell.cy }
|
|
return Ops.mark(S, ("lastHeal set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy))
|
|
end
|
|
|
|
return Ops
|