Add Gold save-editor support, rearrange the launcher, and ship a patch-notes modal.

The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
This commit is contained in:
1jamie
2026-08-14 18:43:38 -05:00
parent fb738fa1ce
commit df0be1cba6
36 changed files with 3429 additions and 1632 deletions
-22
View File
@@ -76,15 +76,6 @@ end
local editorHost, editorVersion, editorWindow
local closeEditor -- forward declaration: openEditor hands it to the editor
-- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row
-- carries fields its MonOps and panels have no idea about (dvs, statExp,
-- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table
-- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua
-- then has to quarantine on the next boot. Refuse by name, the same way the
-- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so
-- Red/Blue/Yellow slots are untouched.
local GEN2_NO_EDITOR = { gold = "Pokemon Gold" }
-- The editor's modules use flat names (require("Kit"), require("Party")), so
-- their directories have to be on the require path. It must be
-- love.filesystem's path, not package.path: in a packaged build these files
@@ -137,11 +128,6 @@ local function openEditor(version, slotId)
Importer.saveNotice = Importer.saveNotice or {}
Importer.saveNotice[version] = { ok = false, text = text }
end
local gen2Name = GEN2_NO_EDITOR[version]
if gen2Name then
refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
return
end
local SaveData = require("src.core.SaveData")
local path = SaveData.slotDiskPath(version, slotId)
if not path then
@@ -320,14 +306,6 @@ function love.load(args)
-- cache has to be mounted before the editor's Data:load.
if editorMode then
local version = os.getenv("POKEPORT_VERSION") or "red"
local gen2Name = GEN2_NO_EDITOR[version]
if gen2Name then
-- No launcher behind this run to carry a notice, so say it and stop
-- rather than open a Gen 2 slot on Gen 1 panels.
print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.")
love.event.quit(1)
return
end
require("src.core.GameVersion").set(version)
require("src.import.CacheFs").mountVersion(version)
addEditorRequirePath()
+6
View File
@@ -51,6 +51,12 @@ rm -f "$OUTPUT"
tools/rom_manifest.json tools/rom_manifest_blue.json \
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
fi
if [ -f "$ROOT/mobile/ios/app-repo.json" ]; then
(cd "$ROOT" && zip -q "$OUTPUT" mobile/ios/app-repo.json)
fi
if [ -n "$BUILD_INFO" ]; then
[ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO"
+1
View File
@@ -135,6 +135,7 @@ if [ -f data/generated/maps.lua ]; then
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
+59
View File
@@ -98,10 +98,69 @@ function Mon.stats(baseStats, dvs, level, statExp)
}
end
-- The species string is the source of truth. `mon.name` is a copy of that
-- species' display name (GetPokemonName), kept so menus can print without a
-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil
-- and prints this copy. Changing species without rewriting it leaves the
-- previous species' name on the party list and the SUMMARY's top line.
function Mon.syncIdentity(mon, data)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
if not def then return mon end
mon.name = def.name or mon.species
if def.types then mon.types = def.types end
if mon.dvs then
mon.gender = Mon.gender(def, mon.dvs,
{ species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs,
{ species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
else
mon.unownLetter = nil
end
end
return mon
end
-- Every screen that prints a mon without a Data lookup should go through here:
-- nickname if the player set one, otherwise the species display copy `name`.
-- Skipping `name` and jumping to `species` is how a swapped mon can still
-- read as ABRA on one menu and RAYQUAZA on another.
function Mon.displayName(mon)
if type(mon) ~= "table" then return "?" end
return mon.nickname or mon.name or mon.species or "?"
end
-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and
-- hydrate have to walk the same set: leaving dayCare.man.mon on the old
-- `name` is the ABRA bug in a second closet.
function Mon.eachSaveMon(save, fn)
if type(save) ~= "table" or type(fn) ~= "function" then return end
for _, mon in ipairs(save.party or {}) do fn(mon) end
for _, box in pairs(save.boxes or {}) do
if type(box) == "table" then
for _, mon in ipairs(box) do fn(mon) end
end
end
local dc = save.dayCare
if type(dc) == "table" then
if dc.man and dc.man.mon then fn(dc.man.mon) end
if dc.lady and dc.lady.mon then fn(dc.lady.mon) end
if dc.egg then fn(dc.egg) end
end
if save.daycare and save.daycare.mon then fn(save.daycare.mon) end
end
function Mon.syncSaveIdentity(save, data)
Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end)
end
function Mon.refreshStats(mon, data)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
if not (def and def.baseStats) then return mon end
Mon.syncIdentity(mon, data)
-- engine/pokemon/move_mon.asm:1402
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
mon.stats = stats
+5 -2
View File
@@ -283,6 +283,9 @@ function Game2:continueGame(save)
local modsDiff = SaveData.modsDiff(save, activeMods)
self.save = save
self:adoptSave(save)
-- Editor species swaps used to leave mon.name on the previous species.
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data)
-- options.lua wins over anything a save file carries: options are a display
-- preference that survives New Game and is edited from the launcher, so a
-- save written before they moved out must not drag old values back in.
@@ -588,13 +591,13 @@ function Game2:useFieldItem(itemId)
end
if not allowed then
self:say(("%s can't learn %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
return
end
for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then
self:say(("%s already knows %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
return
end
end
File diff suppressed because it is too large Load Diff
+202 -64
View File
@@ -299,6 +299,11 @@ end
local CART_DRAG_SLOP = 8
local TAU = math.pi * 2
-- The 3D mesh is inset inside the hit box so yaw/pitch and the 1.05 hover
-- scale cannot climb into the title row (or the gear) on desktop, high-DPI,
-- or a portrait phone. Fraction of the shorter side, with a pixel floor.
local CART_MESH_PAD = 0.07
local CART_MESH_PAD_MIN = 8
local function cartridgeState(imp, version)
imp._cartridge = imp._cartridge or {}
@@ -543,8 +548,11 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
Theme.A.focus, 2, Theme.cardRadius() + 2)
end
local halfW, halfH = w / 2, h / 2
local depth = math.max(8, w * 0.14)
local meshPad = math.max(CART_MESH_PAD_MIN,
math.floor(math.min(w, h) * CART_MESH_PAD))
local halfW = math.max(1, w / 2 - meshPad)
local halfH = math.max(1, h / 2 - meshPad)
local depth = math.max(8, (halfW * 2) * 0.14)
local project = function(px, py, pz)
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
px * pressedScale, py * pressedScale, pz * pressedScale)
@@ -638,14 +646,8 @@ local function modStatusColor(status)
end
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
local function buildModScopeRow(imp, x, y, w, m)
local function modScopeOptions(imp)
local GameVersion = require("src.core.GameVersion")
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Show for:")
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
local options = { { id = nil, label = Strings("All games") } }
for _, version in ipairs(GameVersion.ORDER) do
if imp.ready and imp.ready[version] then
@@ -653,6 +655,33 @@ local function buildModScopeRow(imp, x, y, w, m)
{ id = version, label = GameVersion.info(version).label }
end
end
return options
end
local function modScopeCurrentLabel(imp, options)
for _, opt in ipairs(options) do
if imp.modScope == opt.id then return opt.label end
end
return options[1] and options[1].label or Strings("All games")
end
local function modScopeChipsWidth(options, gap, m)
local need = 0
for i, opt in ipairs(options) do
need = need + Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if i < #options then need = need + gap end
end
return need
end
local function buildModScopeRow(imp, x, y, w, m)
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Show for:")
Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted)
local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s)
local options = modScopeOptions(imp)
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local _, activeProf = LauncherMods.getProfiles()
@@ -690,9 +719,12 @@ local function buildModScopeRow(imp, x, y, w, m)
})
if #options >= 2 then
local avail = profX - gap - cx
-- Chips stay when they all fit; otherwise they used to be skipped and
-- vanish off the portrait edge. Collapse to one menu in that case only.
if modScopeChipsWidth(options, gap, m) <= avail then
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if cx + cw <= profX - gap then
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
"mod-scope-" .. tostring(opt.id or "all")) then
local want = opt.id
@@ -701,6 +733,15 @@ local function buildModScopeRow(imp, x, y, w, m)
end
cx = cx + cw + gap
end
elseif avail > 0 then
local shown = Kit.ellipsize("micro", modScopeCurrentLabel(imp, options),
math.max(0, avail - math.floor(18 * m.s)))
local cw = math.min(avail,
Kit.textWidth("micro", shown) + math.floor(18 * m.s))
if Kit.chip(cx, y, cw, h, shown, true, PAL.lineStrong, "mod-scope-menu") then
queueAction(imp, "mod-scope-menu",
function() imp._modScopePopup = true end)
end
end
end
return h + math.floor(8 * m.s)
@@ -740,8 +781,8 @@ local function setPage(imp, key, v)
imp._pages[key] = v
end
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
-- no guaranteed glyph, and the launcher ships no icon asset for it.
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
-- and the launcher ships no icon asset for them.
local function drawCross(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
@@ -752,6 +793,18 @@ local function drawCross(x, y, size, color)
love.graphics.pop()
end
local function drawCheck(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
love.graphics.setLineJoin("bevel")
love.graphics.line(
x + size * 0.02, y + size * 0.52,
x + size * 0.38, y + size * 0.80,
x + size * 1.015, y + size * 0.18)
love.graphics.pop()
end
-- ------------------------------------------------------------- header
-- Rail, logo row (settings and quit on the right), tab bar.
-- Returns the y at which content may start. Its vertical arithmetic is
@@ -1316,20 +1369,32 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
or tostring(version)
local ready = (not locked) and imp.ready[version] or false
-- title + status tag
-- title + status tag. Ready is a check chip (the font has no tick glyph);
-- missing ROM stays a yellow "ROM REQUIRED" tag so it still reads as an action.
local titleH = Kit.textHeight("title")
Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading)
local tagText, tagCol
if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green
elseif imp.baseRoms and imp.baseRoms[version] then
tagText, tagCol = Strings("ROM FOUND"), PAL.green
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
local tagH = Kit.textHeight("micro") + math.floor(10 * m.s)
local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6))
+ math.floor(12 * m.s)
Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol)
local tagY = y + (titleH - tagH) / 2
local tagW, tagCol
if ready then
tagCol = PAL.green
tagW = tagH
if love.graphics then
Theme.strokeRounded(tagX, tagY, tagW, tagH, tagCol, 0.7, 1)
local ck = math.floor(tagH * 0.55)
drawCheck(tagX + (tagW - ck) / 2, tagY + (tagH - ck) / 2, ck, tagCol)
end
else
local tagText
if imp.baseRoms and imp.baseRoms[version] then
tagText, tagCol = Strings("ROM FOUND"), PAL.green
elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel
else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end
tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s)
Kit.tag(tagX, tagY, tagW, tagH, tagText, tagCol)
end
if ready then
local hint = Strings("(PRESS THE CART TO PLAY)")
local hintX = tagX + tagW + math.floor(10 * m.s)
@@ -1337,8 +1402,11 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX,
y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading)
end
local cy = y + titleH + math.floor(12 * m.s)
local remaining = availH - (titleH + math.floor(12 * m.s))
-- Extra gap under the title when the cart is showing: 12px left the 3D
-- shell sitting on the hairline. Scaled, and still small on a phone.
local afterTitle = math.floor((ready and 22 or 12) * m.s)
local cy = y + titleH + afterTitle
local remaining = availH - (titleH + afterTitle)
local gap = m.gap
local lx, lw, rx2, rw
@@ -1446,20 +1514,6 @@ local function currentSort(imp)
return sortKey
end
-- A hand-drawn check mark: the UI font has no guaranteed glyph for one, and
-- a tofu box on the "you already have this" signal would be worse than none.
local function drawCheck(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
love.graphics.setLineJoin("bevel")
love.graphics.line(
x + size * 0.02, y + size * 0.52,
x + size * 0.38, y + size * 0.80,
x + size * 1.015, y + size * 0.18)
love.graphics.pop()
end
-- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow.
local function modGameCheckbox(x, y, size, checked, game, id)
@@ -1978,21 +2032,47 @@ local TRUST_WARNING = "if you did not get this from bryanthaboi's github "
.. "it might have been tampered with. go to the discord to verify "
.. COMMUNITY_URL .. " (or click the logo above)"
-- Mark + optional updater + Patch notes. Chips match the mark's 22px
-- height so they do not read as bigger than the logo; the row can still
-- be tapMin tall for spacing. On a phone the notes chip drops onto a
-- second row rather than overflowing the mark.
local function footerLayout(imp, m, markW)
local markH = math.floor(22 * m.s)
local rowH = math.max(markH, Kit.tapMin())
local notesLabel = Strings("Patch notes")
-- Tight chip, still enough for Kit.button's labelInset so the words survive.
local chipPad = math.floor(24 * m.s)
local nw = Kit.textWidth("micro", notesLabel) + chipPad
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
local uw = upStatus
and (Kit.textWidth("micro", upLabel) + chipPad) or 0
local gap = math.floor(10 * m.s)
local inner = m.w - 2 * m.pad
local topW = (markW or 0) + (upStatus and (gap + uw) or 0) + gap + nw
return {
rowH = rowH, chipH = markH, gap = gap,
notesLabel = notesLabel, nw = nw,
upStatus = upStatus, upLabel = upLabel, upAction = upAction, upGlow = upGlow,
uw = uw, wrap = topW > inner,
}
end
-- Pinned to the bottom of the window; returns the y it starts at, so the
-- panels above know how much room they have.
-- Deliberately compact: at a large UI scale the footer is pure overhead
-- competing with the panel for a short window's height, so the mark and the
-- link share one line and the trust warning is capped at a single line.
local function footerHeight(imp, m)
-- Top pad + mark/update row + gap + the FULL wrapped trust message +
-- bottom pad. The message wraps to as many lines as it needs: truncating
-- a trust warning defeats its purpose, and the bottom pad is not optional
-- either (without it the last line sits flush on the window edge and its
-- lower half clips off). The row is tapMin tall because the small update
-- button rides beside the mark.
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
return math.floor(8 * m.s) + rowH + math.floor(6 * m.s)
+ Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
-- Top pad + mark/update row + optional notes wrap row + gap + the FULL
-- wrapped trust message + bottom pad. The message wraps to as many lines
-- as it needs: truncating a trust warning defeats its purpose, and the
-- bottom pad is not optional either (without it the last line sits flush
-- on the window edge and its lower half clips off). The row is tapMin
-- tall because the small update button rides beside the mark.
local f = footerLayout(imp, m, math.floor(130 * m.s))
local h = math.floor(8 * m.s) + f.rowH + math.floor(6 * m.s)
if f.wrap then h = h + f.chipH + math.floor(6 * m.s) end
return h + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW)
+ math.floor(8 * m.s)
end
@@ -2009,19 +2089,17 @@ local function buildFooter(imp, m, y)
local bw, bh = imp.bcg:getDimensions()
local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh)
local dw, dh = bw * scale, bh * scale
local rowH = math.max(math.floor(22 * m.s), Kit.tapMin())
-- The mark and the small self-update control share the row, centred as a
-- group. The updater moved down here from the header, where it overlapped
-- the wordmark on a phone; small on purpose, its glow still carries the
-- "act on me" signal.
local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp)
-- Kit.button insets its label 16*scale per side, so the width must budget
-- more than that or the label ellipsizes ("Check for updat...").
local uw = upStatus
and (Kit.textWidth("micro", upLabel) + math.floor(36 * m.s)) or 0
local groupW = dw + (upStatus and (math.floor(10 * m.s) + uw) or 0)
local bx = m.x + math.floor((m.w - groupW) / 2)
local f = footerLayout(imp, m, dw)
local rowH, gap, chipH = f.rowH, f.gap, f.chipH
-- The mark, the small self-update control, and Patch notes share the row,
-- centred as a group. The updater moved down here from the header, where
-- it overlapped the wordmark on a phone; small on purpose, its glow still
-- carries the "act on me" signal. Notes wrap under the mark on a phone.
local topW = dw + (f.upStatus and (gap + f.uw) or 0)
if not f.wrap then topW = topW + gap + f.nw end
local bx = m.x + math.floor((m.w - topW) / 2)
local my = cy + math.floor((rowH - dh) / 2)
local chipY = cy + math.floor((rowH - chipH) / 2)
local hot = Kit.hover(bx, my, dw, dh)
love.graphics.setShader(imp.invertShader)
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85)
@@ -2031,14 +2109,30 @@ local function buildFooter(imp, m, y)
if Kit.press(bx, my, dw, dh) then
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
end
if upStatus then
btn(imp, bx + dw + math.floor(10 * m.s), cy, uw, rowH, "updater",
upLabel, {
kind = upGlow and "warn" or "ghost", font = "micro",
glow = upGlow, action = upAction,
local cx = bx + dw
if f.upStatus then
cx = cx + gap
btn(imp, cx, chipY, f.uw, chipH, "updater",
f.upLabel, {
kind = f.upGlow and "warn" or "ghost", font = "micro",
glow = f.upGlow, action = f.upAction,
})
cx = cx + f.uw
end
local function notesBtn(x, y)
btn(imp, x, y, f.nw, chipH, "patch-notes", f.notesLabel, {
kind = "ghost", font = "micro",
action = function() imp._appPatchNotes = true end,
})
end
if f.wrap then
cy = cy + rowH + gap
notesBtn(m.x + math.floor((m.w - f.nw) / 2), cy)
cy = cy + chipH + math.floor(6 * m.s)
else
notesBtn(cx + gap, chipY)
cy = cy + rowH + math.floor(6 * m.s)
end
-- The trust message wraps in full, each line centred under the mark, and
-- the URL inside it IS the link -- no separate link floating elsewhere.
-- font:getWrap never splits an unspaced word, so the URL stays whole on
@@ -2543,6 +2637,34 @@ local function buildSortModal(imp, m)
action = function() imp._sortPopup = nil end })
end
-- Game-scope chooser used when the Show-for chips cannot all fit on the
-- mods toolbar (portrait phones). Same options as the chip row.
local function buildModScopeModal(imp, m)
local options = modScopeOptions(imp)
local pad = math.floor(18 * m.s)
local w = math.floor(360 * m.s)
local gap = math.floor(8 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #options * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Show for"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
for _, opt in ipairs(options) do
local key = tostring(opt.id or "all")
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-" .. key, opt.label, {
kind = (imp.modScope == opt.id) and "primary" or "ghost", font = "small",
action = function()
imp:_setModScope(opt.id)
imp._modScopePopup = nil
end })
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-close",
Strings("Close"), { font = "small",
action = function() imp._modScopePopup = nil end })
end
-- Category filter for FIND MODS. Two columns, because an index can list
-- enough categories to overflow a single stacked column on a short window.
local function buildFilterModal(imp, m)
@@ -3253,8 +3375,9 @@ end
local function modalUp(imp)
return (imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._indexManage or imp._modActions
or imp._filterPopup or imp._modScopePopup or imp._indexManage or imp._modActions
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
end
@@ -3351,6 +3474,20 @@ local function buildModals(imp, m)
return true
end
if imp._modConfirm then buildConfirmModal(imp, m) return true end
if imp._appPatchNotes then
local PatchNotes = require("src.update.PatchNotes")
local ModUpdate = require("src.mods.ModUpdate")
local raw, ver = PatchNotes.body(imp.Check)
local body = ModUpdate.cleanBody(raw or "", 0)
if body == "" then body = Strings("(No patch notes.)") end
local title = Strings("Patch notes")
if ver and ver ~= "" then
title = title .. " v" .. tostring(ver)
end
buildTextModal(imp, m, "patch-notes-modal", title, body,
function() imp._appPatchNotes = nil end)
return true
end
if imp._modReleaseNotes then
local ModUpdate = require("src.mods.ModUpdate")
local n = imp._modReleaseNotes
@@ -3380,6 +3517,7 @@ local function buildModals(imp, m)
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
if imp._modActions then buildModActionsModal(imp, m) return true end
+3 -1
View File
@@ -2820,7 +2820,7 @@ function RomImporter:keypressed(key)
return
end
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
or self._findDetails or self._appPatchNotes then
-- Focus navigation belongs to the visible modal as well as the launcher
-- beneath it. Route arrows and an already-armed confirm before this guard
-- returns; unarmed Enter still falls through to the modal guard. Keep this
@@ -2833,6 +2833,8 @@ function RomImporter:keypressed(key)
self._findDetails = nil
elseif self._modReleaseNotes then
self._modReleaseNotes = nil
elseif self._appPatchNotes then
self._appPatchNotes = nil
else
self._modConfirm = nil
self._modVersions = nil
+1 -1
View File
@@ -154,7 +154,7 @@ function HallOfFame.monPlacements(mon, def)
put(out, genderGlyph(mon.gender), 18, 13)
-- (8,14) is a bare '/', so the nickname starts at (9,14).
put(out, "/", 8, 14)
put(out, mon.nickname or mon.species, 9, 14)
put(out, mon.nickname or mon.name or mon.species, 9, 14)
put(out, levelText(mon.level), 1, 16)
end
-- '<ID>' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16).
+2 -2
View File
@@ -630,7 +630,7 @@ function PackMenu:openTeachParty(row)
if not allowed then
if game.say then
game:say(("%s can't learn %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
end
return
end
@@ -638,7 +638,7 @@ function PackMenu:openTeachParty(row)
if move.id == moveId then
if game.say then
game:say(("%s already knows %s!"):format(
mon.nickname or mon.species or "?", moveName))
require("src.battle.gen2.Mon").displayName(mon), moveName))
end
return
end
+12 -3
View File
@@ -71,6 +71,9 @@ function Check.parseRelease(jsonText, Json)
payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
-- GitHub release body: already fetched with the update check, shown by
-- the launcher's Patch notes footer button.
notes = type(doc.body) == "string" and doc.body or "",
}
end
@@ -135,6 +138,10 @@ local function drain()
if stateCh then
local msg = stateCh:pop()
while msg do
if type(msg) == "table" and type(msg.notes) ~= "string"
and type(cache.notes) == "string" then
msg.notes = cache.notes
end
cache = msg
msg = stateCh:pop()
end
@@ -158,11 +165,11 @@ function Check.start()
return
end
requested = true
cache = { status = "checking" }
cache = { status = "checking", notes = cache.notes, latest = cache.latest }
cmdCh:push({ cmd = "check" })
end
-- Current snapshot: { status, latest, progress, error }. status is one of
-- Current snapshot: { status, latest, progress, error, notes }. status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
function Check.state()
drain()
@@ -171,6 +178,7 @@ function Check.state()
latest = cache.latest,
progress = cache.progress,
error = cache.error,
notes = cache.notes,
}
end
@@ -180,7 +188,8 @@ function Check.download()
drain()
if not cmdCh then return end
if cache.status ~= "available" then return end
cache = { status = "downloading", latest = cache.latest, progress = 0 }
cache = { status = "downloading", latest = cache.latest, progress = 0,
notes = cache.notes }
cmdCh:push({ cmd = "download" })
end
+112
View File
@@ -0,0 +1,112 @@
-- Resolve the game's patch notes for the launcher footer modal.
--
-- Sources, first hit wins:
-- 1. The GitHub body the self-updater already fetched (Check.parseRelease)
-- 2. PATCH_NOTES.md, if a build packed one
-- 3. mobile/ios/app-repo.json -- CI copies each release's notes into
-- versions[].localizedDescription, so a checkout already has them
-- on disk even when the updater has not run
local Json = require("src.link.Json")
local PatchNotes = {}
PatchNotes.FILES = {
"PATCH_NOTES.md",
"assets/PATCH_NOTES.md",
}
PatchNotes.REPO_FILES = {
"mobile/ios/app-repo.json",
}
local function nonempty(s)
return type(s) == "string" and s:find("%S")
end
function PatchNotes.fromCheck(Check)
if not (Check and Check.state) then return nil, nil end
local ok, st = pcall(Check.state)
st = (ok and type(st) == "table") and st or nil
if not st then return nil, nil end
if nonempty(st.notes) then
return st.notes, st.latest
end
return nil, st.latest
end
local function readPath(path)
local fs = love and love.filesystem
if fs and fs.read then
local ok, text = pcall(fs.read, path)
if ok and nonempty(text) then return text end
end
local f = io.open(path, "rb")
if not f then return nil end
local text = f:read("*a")
f:close()
return nonempty(text) and text or nil
end
function PatchNotes.fromFile()
for _, path in ipairs(PatchNotes.FILES) do
local text = readPath(path)
if text then return text end
end
return nil
end
-- Newest-first list of { version, notes } from the iOS sidecar.
function PatchNotes.parseRepo(jsonText)
local doc = Json.decode(jsonText)
if type(doc) ~= "table" or type(doc.apps) ~= "table" then return {} end
local out = {}
for _, app in ipairs(doc.apps) do
if type(app) == "table" and type(app.versions) == "table" then
for _, row in ipairs(app.versions) do
if type(row) == "table" and nonempty(row.localizedDescription) then
out[#out + 1] = {
version = row.version,
notes = row.localizedDescription,
}
end
end
end
end
return out
end
function PatchNotes.fromRepo(engine)
for _, path in ipairs(PatchNotes.REPO_FILES) do
local text = readPath(path)
if text then
local list = PatchNotes.parseRepo(text)
if #list == 0 then return nil, nil end
if engine and engine ~= "0.0.0-dev" then
for _, row in ipairs(list) do
if row.version == engine then
return row.notes, row.version
end
end
end
return list[1].notes, list[1].version
end
end
return nil, nil
end
function PatchNotes.body(Check)
local notes, ver = PatchNotes.fromCheck(Check)
if notes then return notes, ver end
notes = PatchNotes.fromFile()
if notes then return notes, ver end
local Version = require("src.core.Version")
local engine = (Version and Version.engine) or "?"
notes, ver = PatchNotes.fromRepo(engine)
if notes then return notes, ver end
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
.. "They appear here after the launcher checks GitHub for the latest "
.. "release.", engine
end
return PatchNotes
+6 -1
View File
@@ -45,7 +45,12 @@ local Boot = loadModule("src/update/Boot.lua")
local cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state")
local function post(t) stateCh:push(t) end
local function post(t)
if pending and type(t) == "table" and t.notes == nil then
t.notes = pending.notes
end
stateCh:push(t)
end
local osName = (love.system and love.system.getOS and love.system.getOS()) or ""
local isWindows = osName == "Windows"
+186
View File
@@ -0,0 +1,186 @@
-- Bake a Gen 2 map canvas without a World instance. The save editor's map
-- tab uses this so Gold rooms draw the same tiles the overworld would,
-- instead of the checkerboard fallback Map2 has no renderer for.
local Assets = require("src.render.Assets")
local BorderFill = require("src.world.gen2.BorderFill")
local GbcPalette = require("src.render.GbcPalette")
local Palettes = require("src.world.gen2.Palettes")
local MapPreview = {}
local ROOF_TILESETS = {
TILESET_JOHTO = true,
TILESET_JOHTO_MODERN = true,
TILESET_KANTO = true,
}
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
local atlasData = love.image.newImageData(Assets.resolve(atlasPath))
local roofData = love.image.newImageData(Assets.resolve(roofPath))
for t = 0, 8 do
local destId = 0x0a + t
local dx = (destId % tilesPerRow) * 8
local dy = math.floor(destId / tilesPerRow) * 8
local sx = t * 8
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = roofData:getPixel(sx + x, y)
atlasData:setPixel(dx + x, dy + y, r, g, b, a)
end
end
end
local image = love.graphics.newImage(atlasData)
image:setFilter("nearest", "nearest")
return image
end
local function optionalTable(data, gen2Key, plainKey, generated)
if data[gen2Key] then return data[gen2Key] end
if data[plainKey] then return data[plainKey] end
local ok, mod = pcall(require, "data.generated." .. generated)
if ok and type(mod) == "table" then return mod end
return nil
end
function MapPreview.baker(data)
data = data or {}
return {
tilesets = data.gen2Tilesets or data.tilesets or {},
-- Data:load does not pull roofs.lua; the Gold cache still has it.
roofs = optionalTable(data, "gen2Roofs", "roofs", "roofs"),
palettes = optionalTable(data, "gen2Palettes", "palettes", "palettes"),
atlasCache = {},
mapImages = {},
}
end
function MapPreview.atlasFor(baker, mapDef)
if not (baker and mapDef) then return nil, nil end
local tileset = baker.tilesets and baker.tilesets[mapDef.tileset]
if not tileset then return nil, nil end
local cacheKey = mapDef.tileset
local roofName = nil
local roofs = baker.roofs
if ROOF_TILESETS[mapDef.tileset] then
roofName = roofs and roofs.mapGroupRoofs and roofs.mapGroupRoofs[mapDef.group]
end
if roofName then cacheKey = cacheKey .. "|" .. roofName end
local cached = baker.atlasCache[cacheKey]
if cached then return cached, tileset end
local tilesPerRow = tileset.tilesPerRow or 16
local atlas
local roofSpec = roofName and roofs and roofs.roofs and roofs.roofs[roofName]
if roofSpec and roofSpec.image and love.image and love.image.newImageData then
local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow)
if ok then atlas = img end
end
if not atlas then
if not tileset.image then return nil, tileset end
local ok, img = pcall(Assets.image, tileset.image)
if not ok then return nil, tileset end
atlas = img
if atlas.setFilter then atlas:setFilter("nearest", "nearest") end
end
baker.atlasCache[cacheKey] = atlas
return atlas, tileset
end
-- Same bake as World:bakeMapImage (src/world/gen2/World.lua), minus the
-- World fields the overworld keeps for anim overlays and cave flicker.
function MapPreview.bake(baker, map, daytime)
local atlas, tileset = MapPreview.atlasFor(baker, map.def)
if not atlas or not tileset then return nil end
if not (love.graphics and love.graphics.newCanvas) then return nil end
local blocks = tileset.blocks
local tilesPerRow = tileset.tilesPerRow or 16
local pw, ph = map.width * 32, map.height * 32
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph)
if not okCanvas or not canvas then return nil end
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
local quads = {}
local function quadFor(tile)
local q = quads[tile]
if q then return q end
local sx = (tile % tilesPerRow) * 8
local sy = math.floor(tile / tilesPerRow) * 8
q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions())
quads[tile] = q
return q
end
local tilePalettes = tileset.tilePalettes
local bgSet = baker.palettes and daytime
and Palettes.bgSet(baker.palettes, map.def, daytime) or nil
local colored = bgSet and tilePalettes and GbcPalette.available()
local clearColor = { 0.15, 0.55, 0.25 }
if bgSet and bgSet[1] and bgSet[1][1] then
local c = GbcPalette.color(bgSet[1], 1)
clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 }
end
local function drawTiles(slot)
for by = 0, map.height - 1 do
for bx = 0, map.width - 1 do
local blockId = BorderFill.blockFor(
map.blocks[by * map.width + bx + 1], map.borderBlock)
local block = blocks and blocks[(blockId or 0) + 1]
if block then
for i = 0, 15 do
local tile = block[i + 1] or 0
local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1
if not slot or tileSlot == slot then
local tx = bx * 32 + (i % 4) * 8
local ty = by * 32 + math.floor(i / 4) * 8
love.graphics.draw(atlas, quadFor(tile), tx, ty)
end
end
end
end
end
end
local function paint()
love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.push()
love.graphics.origin()
if colored then
for slot = 1, 8 do
GbcPalette.with(bgSet[slot], function() drawTiles(slot) end)
end
else
drawTiles(nil)
end
love.graphics.pop()
end
if canvas.renderTo then
canvas:renderTo(paint)
else
paint()
end
return canvas
end
function MapPreview.imageFor(baker, map)
if not (baker and map and map.id) then return nil end
local cached = baker.mapImages[map.id]
if cached then return cached end
local img = MapPreview.bake(baker, map, "DAY")
baker.mapImages[map.id] = img or false
return img
end
function MapPreview.renderer(baker, map)
local img = MapPreview.imageFor(baker, map)
if not img then return nil end
return {
draw = function(_, camX, camY)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, -math.floor(camX or 0), -math.floor(camY or 0))
end,
}
end
return MapPreview
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
-- Mods "Show for" chips collapse to a dropdown when they cannot all fit.
-- Landscape / desktop keep the individual game chips. No pokered cite: the
-- launcher is port-only chrome.
-- luajit tests/engine/launcher_mod_scope_dropdown.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function navIds()
local ids = {}
for i = 1, (Kit._navPrevN or 0) do
local slot = Kit._nav[i]
if slot and slot.id then ids[slot.id] = true end
end
return ids
end
local function drawMods(W, H)
window(W, H)
local imp = RomImporter.new(function() end, { launcher = true })
imp.tab = "mods"
imp.ready = { red = true, blue = true, yellow = true, gold = true }
local ok, err = pcall(LauncherView.draw, imp)
check(ok, ("%dx%d mods draws: %s"):format(W, H, tostring(err)))
return navIds()
end
local portrait = drawMods(360, 780)
check(portrait["mod-scope-menu"] == true,
"portrait: Show-for collapses to a dropdown when the chips cannot all fit")
check(portrait["mod-scope-gold"] == nil
and portrait["mod-scope-red"] == nil
and portrait["mod-scope-blue"] == nil
and portrait["mod-scope-yellow"] == nil,
"portrait: individual version chips are not drawn beside the dropdown")
local desktop = drawMods(1280, 720)
check(desktop["mod-scope-menu"] == nil,
"desktop: chips fit, so there is no dropdown")
check(desktop["mod-scope-all"] == true
and desktop["mod-scope-red"] == true
and desktop["mod-scope-blue"] == true
and desktop["mod-scope-yellow"] == true
and desktop["mod-scope-gold"] == true,
"desktop: every Show-for chip stays reachable")
print("ok launcher mod scope dropdown")
@@ -23,7 +23,7 @@ local function importer(field)
end
local modalFields = {
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
"_modConfirm", "_modVersions", "_modReleaseNotes", "_appPatchNotes", "_findDetails",
}
for _, field in ipairs(modalFields) do
@@ -77,6 +77,12 @@ do
imp:keypressed("escape")
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
end
do
resetFocus()
local imp = importer("_appPatchNotes")
imp:keypressed("escape")
eq(imp._appPatchNotes, nil, "Escape closes patch notes")
end
do
resetFocus()
local imp = importer("_modVersions")
+89
View File
@@ -0,0 +1,89 @@
-- Launcher footer Patch notes control. The GitHub release body is already
-- fetched by the updater; this is the in-app viewer for it.
-- luajit tests/engine/launcher_patch_notes.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
local Kit = require("src.ui.kit.Kit")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local PatchNotes = require("src.update.PatchNotes")
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function freshLauncher()
return RomImporter.new(function() end, { launcher = true })
end
local realPrint = love.graphics.print
local function drawAndCapture(imp)
local seen = {}
love.graphics.print = function(str, ...)
seen[#seen + 1] = tostring(str)
return realPrint(str, ...)
end
local ok, err = pcall(LauncherView.draw, imp)
love.graphics.print = realPrint
check(ok, "the frame draws: " .. tostring(err))
return table.concat(seen, "\n")
end
window(1280, 720)
local imp = freshLauncher()
local text = drawAndCapture(imp)
check(text:find("Patch notes", 1, true) ~= nil,
"desktop footer prints Patch notes")
window(360, 780)
local phone = freshLauncher()
check(drawAndCapture(phone):find("Patch notes", 1, true) ~= nil,
"portrait footer still prints Patch notes")
do
local body, ver = PatchNotes.body({
state = function()
return { notes = "## Issues closed\n\n- #12 cart padding", latest = "1.4.2" }
end,
})
eq(body, "## Issues closed\n\n- #12 cart padding",
"PatchNotes prefers the updater's GitHub body")
eq(ver, "1.4.2", "PatchNotes carries the release version")
end
do
local body, ver = PatchNotes.body(nil)
check(type(body) == "string" and body:find("Issues closed", 1, true),
"without a check result PatchNotes uses the stashed iOS app-repo notes")
check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil,
"stashed notes name a release version")
end
do
local f = assert(io.open("mobile/ios/app-repo.json", "rb"))
local list = PatchNotes.parseRepo(f:read("*a"))
f:close()
check(#list >= 2, "app-repo.json stashes more than one release")
local notes, ver = PatchNotes.fromRepo(list[2].version)
eq(ver, list[2].version, "fromRepo can pick a specific stashed version")
eq(notes, list[2].notes, "fromRepo returns that version's notes")
end
imp._appPatchNotes = true
local modal = drawAndCapture(imp)
check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself")
check(modal:find("Close", 1, true) ~= nil, "the modal can be closed")
imp:keypressed("escape")
eq(imp._appPatchNotes, nil, "Escape dismisses patch notes")
T.finish("launcher patch notes")
+9
View File
@@ -32,6 +32,15 @@ eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version"
eq(rel.payload.url, "http://x/love", "payload asset url picked")
eq(rel.payload.size, 12345, "payload asset size picked")
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
eq(rel.notes, "", "missing release body becomes empty notes")
local withNotes = Check.parseRelease(Json.encode({
tag_name = "v1.4.2",
body = "## Issues closed\n\n- #1 cart padding",
assets = {},
}))
eq(withNotes.notes, "## Issues closed\n\n- #1 cart padding",
"parseRelease keeps the GitHub release body")
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
-- so the worker will route to needs_full rather than an in-place update
+1
View File
@@ -287,6 +287,7 @@ eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url")
eq(rel.payload.size, 12345, "parseRelease picks the payload asset size")
eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url")
eq(rel.sums.size, 99, "parseRelease picks the sums asset size")
eq(rel.notes, "", "parseRelease treats a missing body as empty notes")
-- a release with no .love yet still parses; payload/sums are nil so the worker
-- routes to a full reinstall rather than an in-place update
+1
View File
@@ -9,6 +9,7 @@
-- tests/save_editor_task7_tests.lua
-- tests/save_editor_task8_tests.lua
-- tests/save_editor_mod_tests.lua
-- tests/save_editor_gen2_tests.lua
-- See tools/save-editor/README.md for the full list.
--
-- All of them drive tools/save-editor/Ops.lua rather than clicking pixel
+2
View File
@@ -3475,6 +3475,8 @@ do
local lua = (arg and arg[-1]) or "luajit"
local status = os.execute(("%q tests/save_editor_mod_tests.lua"):format(lua))
check(status == 0 or status == true, "save_editor_mod_tests suite")
status = os.execute(("%q tests/save_editor_gen2_tests.lua"):format(lua))
check(status == 0 or status == true, "save_editor_gen2_tests suite")
end
-- ---------------------------------------------- input hold regressions
+352
View File
@@ -0,0 +1,352 @@
-- Headless Gold save-editor rules. Run from repo root:
-- luajit tests/save_editor_gen2_tests.lua
package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
local love_stub = require("tests.love_stub")
love = love_stub
local passed, failed = 0, 0
local function check(cond, msg)
if cond then
passed = passed + 1
else
failed = failed + 1
print("FAIL: " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
end
print("== save editor gen2 tests ==")
local Gen = require("Gen")
local Catalog = require("Catalog")
local MonOps = require("MonOps")
local Ops = require("Ops")
local State = require("State")
local Save2 = require("src.core.gen2.Save")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local data = {
pokemon = {
CYNDAQUIL = {
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155,
types = { "FIRE" },
baseStats = {
hp = 39, attack = 52, defense = 43, speed = 65,
specialAttack = 60, specialDefense = 50,
},
catchRate = 45, baseExp = 65,
growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "TACKLE" } },
genderRatio = 31,
},
TOTODILE = {
id = "TOTODILE", name = "TOTODILE", dex = 158,
types = { "WATER" },
baseStats = {
hp = 50, attack = 65, defense = 64, speed = 43,
specialAttack = 44, specialDefense = 48,
},
catchRate = 45, baseExp = 66,
growthRate = "MEDIUM_FAST",
levelMoves = { { level = 1, move = "SCRATCH" } },
genderRatio = 31,
},
},
moves = {
TACKLE = { pp = 35 },
SCRATCH = { pp = 35 },
},
items = {
POTION = { pocket = "ITEM" },
MASTER_BALL = { pocket = "BALL" },
FLOWER_MAIL = { pocket = "ITEM" },
},
maps = {},
}
local function newState()
local S = State.new()
S.data = data
S.cat = Catalog.build(data)
S.save = Save2.newGame()
S.version = "gold"
Gen.ensureBoxes(S.save)
return S
end
do
GameVersion.set("gold")
eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field")
eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold")
eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame")
eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame")
end
do
local S = newState()
check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable")
local S1 = State.new()
S1.data = {
pokemon = {
PIDGEY = { baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, special = 35 } },
BROKEN = { baseStats = { hp = 1 } },
},
}
check(Ops.speciesUsable(S1, "PIDGEY"), "gen1 special species is usable")
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
end
do
local S = newState()
Ops.partyAdd(S)
eq(#S.save.party, 1, "partyAdd on gold")
local mon = S.save.party[1]
eq(mon.species, "CYNDAQUIL", "first catalog species")
check(mon.experience ~= nil, "gold mon has experience")
check(mon.exp == nil or mon.experience ~= nil, "does not rely on gen1 exp")
check(mon.stats.specialAttack and mon.stats.specialDefense,
"gold stats have spa/spd")
check(mon.happiness ~= nil, "gold mon has happiness")
eq(mon.ot, S.save.player.name, "stampOT copies player name")
Ops.setLevel(S, mon, 20)
eq(mon.level, 20, "setLevel 20")
check(mon.experience > 0, "experience resynced")
Ops.setHappiness(S, mon, 200)
eq(mon.happiness, 200, "happiness 200")
Ops.setPokerus(S, mon, 15)
eq(mon.pokerus, 15, "pokerus byte")
Ops.setHeldItem(S, mon, "POTION")
eq(mon.item, "POTION", "held item")
eq(mon.name, "CYNDAQUIL", "new mon copies species display name")
Ops.setSpecies(S, mon, "TOTODILE")
eq(mon.species, "TOTODILE", "setSpecies id")
eq(mon.name, "TOTODILE", "setSpecies rewrites the Gold display name")
check(mon.nickname == nil, "setSpecies does not invent a nickname")
eq(mon.types[1], "WATER", "setSpecies rewrites copied types")
end
do
local S = newState()
Ops.partyAdd(S)
Ops.partyAdd(S)
local Mail = require("src.core.gen2.Mail")
Mail.set(S.save, 1, Mail.entry("FLOWER_MAIL", "hi", "GOLD", 1, "CYNDAQUIL"))
Mail.set(S.save, 2, Mail.entry("SURF_MAIL", "bye", "GOLD", 1, "CYNDAQUIL"))
S.selectedParty = 1
Ops.partyMove(S, 1)
eq(Mail.state(S.save).party[1].message, "bye", "partyMove carries mail with the mon")
eq(Mail.state(S.save).party[2].message, "hi", "partyMove swaps the other letter")
S.selectedParty = 1
check(Ops.partyRemove(S) == false, "partyRemove arms")
check(Ops.partyRemove(S) == true, "partyRemove commits")
eq(Mail.state(S.save).party[1].message, "hi", "partyRemove shifts leftover mail up")
check(Mail.state(S.save).party[2] == nil, "partyRemove clears the vacated slot")
end
do
local S = newState()
Ops.partyAdd(S)
local mon = S.save.party[1]
local Mail = require("src.core.gen2.Mail")
Ops.setHeldItem(S, mon, "FLOWER_MAIL")
eq(mon.item, "FLOWER_MAIL", "held mail item")
local letter = Mail.state(S.save).party[1]
check(letter ~= nil, "giving mail writes sPartyMail")
eq(letter.species, "CYNDAQUIL", "new letter stamps current species")
Ops.setSpecies(S, mon, "TOTODILE")
eq(Mail.state(S.save).party[1].species, "TOTODILE",
"setSpecies updates the letter's species copy")
Ops.setHeldItem(S, mon, "POTION")
check(Mail.state(S.save).party[1] == nil, "non-mail held item drops the letter")
end
do
local Mon = require("src.battle.gen2.Mon")
local S = newState()
local stale = Mon.new(data, "CYNDAQUIL", 5)
stale.species = "TOTODILE"
stale.name = "CYNDAQUIL"
stale.types = { "FIRE" }
S.save.dayCare = { man = { mon = stale }, lady = {} }
Mon.syncSaveIdentity(S.save, data)
eq(stale.name, "TOTODILE", "syncSaveIdentity rewrites Day-Care display name")
eq(stale.types[1], "WATER", "syncSaveIdentity rewrites Day-Care types")
eq(Mon.displayName({ nickname = nil, name = "ABRA", species = "RAYQUAZA" }),
"ABRA", "displayName prefers the species copy over the id")
eq(Mon.displayName({ nickname = "BOB", name = "ABRA", species = "RAYQUAZA" }),
"BOB", "displayName prefers nickname")
end
do
local S = newState()
eq(Ops.boxCount(S), 14, "14 gold boxes")
eq(Ops.boxCapacity(S), 20, "20 per box")
Ops.boxAdd(S)
eq(#Ops.boxes(S)[1], 1, "boxAdd into box 1")
end
do
local S = newState()
Ops.partyAdd(S)
S.save.party[1].hp = 0
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(ok, "deposit fainted mon while a healthy remains")
end
do
local S = newState()
Ops.partyAdd(S)
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(ok, "deposit one of two healthy mons")
end
do
local S = newState()
Ops.partyAdd(S)
S.selectedParty = 1
S.selectedBox = 1
local ok = Ops.deposit(S)
check(not ok, "refuse depositing last healthy mon")
check(S.status:lower():find("last", 1, true) or S.status:find("POKéMON")
or S.status:find("POKEMON") or S.status:find("last"),
"deposit refusal names the last-healthy rule: " .. tostring(S.status))
eq(#S.save.party, 1, "party still has the mon")
end
do
local S = newState()
eq(Gen.money(S.save), 3000, "gold start money on player")
Ops.addMoney(S, 1000)
eq(S.save.player.money, 4000, "money writes player.money")
check(S.save.money == nil or S.save.money ~= 4000, "does not write save.money")
Ops.maxMoney(S)
eq(S.save.player.money, 999999, "money cap")
Ops.addCoins(S, 250)
eq(S.save.player.coins, 250, "coins write player.coins")
end
do
local S = newState()
check(not Gen.hasBadge(S.save, "ZEPHYR"), "no zephyr yet")
Ops.toggleBadge(S, "ZEPHYR")
check(Gen.hasBadge(S.save, "ZEPHYR"), "zephyr earned")
check(S.save.player.badges.ZEPHYR, "stored on player.badges")
Ops.toggleBadge(S, "BOULDER")
check(S.save.player.kantoBadges.BOULDER, "kanto badge store")
end
do
local S = newState()
Ops.dexOwned(S, "CYNDAQUIL", true)
check(S.save.pokedex.caught.CYNDAQUIL, "dex writes caught")
check(S.save.pokedex.owned == nil or S.save.pokedex.owned.CYNDAQUIL == nil,
"does not write owned on gold")
check(S.save.pokedex.seen.CYNDAQUIL, "owned implies seen")
local _, owned = Ops.dexCounts(S)
eq(owned, 1, "dexCounts reads caught")
end
do
local S = newState()
local name = "EVENT_BEAT_FALKNER"
Ops.setFlag(S, name, true)
check(Gen.getFlag(S.save, name), "gold EVENT_ sets bitfield")
check(S.save.flags[name] == nil, "numeric flags are not string keys")
Ops.setFlag(S, "MOD_EDITMON_GIFT", true)
check(S.save.flags.MOD_EDITMON_GIFT, "mod flags stay named on gold")
end
do
local S = newState()
S.mapId = "NEW_BARK_TOWN"
S.mapClickCell = { cx = 4, cy = 5 }
Ops.setPlayerHere(S)
eq(S.save.position.map, "NEW_BARK_TOWN", "position.map")
eq(S.save.position.x, 4, "position.x")
eq(S.save.position.y, 5, "position.y")
check(S.save.player.map == nil or S.save.player.map ~= "NEW_BARK_TOWN",
"does not write player.map on gold")
end
do
local encoded = SaveData.encode(Save2.newGame())
local back = SaveData.decode(encoded)
eq(back.generation, 2, "round-trip keeps generation 2")
end
do
local names = Catalog.goldEventList()
local hasFalkner = false
for _, n in ipairs(names) do
if n == "EVENT_BEAT_FALKNER" then hasFalkner = true break end
end
check(hasFalkner, "gold event list includes EVENT_BEAT_FALKNER")
end
do
local maps = Gen.maps({ gen2Maps = { AZALEA_GYM = true }, maps = { PALLET_TOWN = true } })
check(maps.AZALEA_GYM, "Gen.maps includes gen2Maps")
check(maps.PALLET_TOWN, "Gen.maps keeps Data:load maps beside gen2Maps")
check(Gen.maps({ maps = { PALLET_TOWN = true } }).PALLET_TOWN,
"Gen.maps falls back to maps")
local mansion = Gen.maps({
maps = {
CELADON_MANSION_2F = { id = "CELADON_MANSION_2F", width = 4, height = 5 },
},
gen2Maps = {
CELADON_MANSION_2F = { objects = { { name = "NPC" } } },
BERRY_FARM = { id = "BERRY_FARM", width = 19, height = 12 },
},
})
eq(mansion.CELADON_MANSION_2F.width, 4,
"Gen.maps keeps extractor width under a gen2Maps objects patch")
eq(mansion.CELADON_MANSION_2F.objects[1].name, "NPC",
"Gen.maps still applies the gen2Maps patch fields")
eq(mansion.BERRY_FARM.width, 19, "Gen.maps keeps mod maps only on gen2Maps")
local bound = Gen.bindGoldData({ maps = { A = true }, tilesets = { T = true } })
check(bound.gen2Maps == bound.maps, "bindGoldData aliases gen2Maps")
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
"Gen.tilesets prefers gen2Tilesets")
end
do
local Map2 = require("src.world.gen2.Map")
local MapPreview = require("src.world.gen2.MapPreview")
local def = {
id = "AZALEA_GYM", tileset = "TILESET_GYM",
width = 1, height = 1, blocks = { 1 }, borderBlock = 1,
warps = {}, environment = "INDOOR",
}
local tileset = {
id = "TILESET_GYM",
image = "assets/generated/tilesets/gym.png",
tilesPerRow = 16,
blocks = { { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } },
}
local map = Map2.new(def, tileset)
check(map.renderer == nil, "Map2 does not ship a renderer")
local baker = MapPreview.baker({ tilesets = { TILESET_GYM = tileset } })
local renderer = MapPreview.renderer(baker, map)
check(renderer ~= nil and renderer.draw ~= nil,
"MapPreview attaches a draw for a Gold map")
end
print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end
+36 -1
View File
@@ -142,6 +142,41 @@ local ok, err = pcall(function()
local report = SaveData.validate(probe, Data)
check(#report.lostMons == 0 and probe.party[1].species == "EDITMON",
"validate keeps the modded mon while the mod is enabled")
-- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only
-- manifest stays out of Gold (no ROM cache / App.load("gold") required).
local ModTargets = require("src.mods.ModTargets")
local Ops = require("Ops")
check(not ModTargets.supports({}, "gold"),
"legacy gen1-only fixture does not support gold")
check(not ModTargets.supports({ games = { "red" } }, "gold"),
"explicit gen1 games list does not support gold")
check(ModTargets.supports({ games = { "gold" } }, "gold"),
"gold-targeted manifest supports gold")
check(ModTargets.supports({ gen2compat = true }, "gold"),
"gen2compat legacy still supports gold")
local goldS = {
data = {
pokemon = {
EDITMON = {
baseStats = {
hp = 50, attack = 50, defense = 50, speed = 50,
specialAttack = 50, specialDefense = 50,
},
},
G1ONLY = {
baseStats = {
hp = 50, attack = 50, defense = 50, speed = 50, special = 50,
},
},
},
},
}
check(Ops.speciesUsable(goldS, "EDITMON"),
"spa/spd EDITMON is usable on gold")
check(Ops.speciesUsable(goldS, "G1ONLY"),
"gen1 special record remains usable (dual-key gate)")
end)
os.remove(MOD_ROOT .. "/main.lua")
@@ -154,7 +189,7 @@ os.remove(tmpPath)
love.filesystem = savedFS
-- leave shared singletons the way we found them (the fixture merged one
-- record into Data.pokemon)
Data.pokemon.EDITMON = nil
if Data.pokemon then Data.pokemon.EDITMON = nil end
Assets.loader = savedBridge
Assets.invalidate()
Runtime.install(savedEvents, savedHooks, savedErrors)
+1 -1
View File
@@ -12,7 +12,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local Flags = dofile("tools/goldwalk/flags.lua")
local ROOT = arg[1] or "../pokegold"
local OUT = "tests/drivers/gold/flag_names.lua"
local OUT = "src/core/gen2/FlagNames.lua"
local events, _ = Flags.parse(ROOT .. "/constants/event_flags.asm")
local engine, _ = Flags.parse(ROOT .. "/constants/engine_flags.asm")
+45 -38
View File
@@ -25,6 +25,7 @@ local State = require("State")
local Kit = require("Kit")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PadInput = require("PadInput")
local PAL = Theme.PAL
@@ -87,58 +88,45 @@ local function applyLoaded(path, statusVerb)
if save then
S.save = save
S.status = statusVerb .. " " .. path
S.mapId = save.player.map
S.loadError = false
S.allowSave = true
elseif existed then
-- File is present but SaveIO.load couldn't decode it: treat it as a
-- real (corrupt) save, not a missing one. Editing a stub here is fine,
-- but Save must stay disabled so we never clobber the corrupt file
-- until the user fixes it and Reload succeeds.
S.save = require("src.core.SaveData").newGame()
S.save = Gen.newGame(S.version)
S.status = "Corrupt save at " .. path .. " (" .. tostring(err) ..
"), Save disabled, use Reload after fixing the file"
S.mapId = S.save.player.map
S.loadError = true
S.allowSave = false
else
S.save = require("src.core.SaveData").newGame()
S.save = Gen.newGame(S.version)
S.status = "No save at " .. path .. " (" .. tostring(err) ..
"), editing new game stub"
S.mapId = S.save.player.map
S.loadError = false
S.allowSave = true
end
local mapId = Gen.playerMap(S.save)
S.mapId = mapId
S.dirty = false
S._quitArmed = false
S._openArmed = false
S.editingMon = nil
Ops.disarm(S)
local boxes = require("src.pokemon.Boxes").ensure(S.save)
-- Imported .sav box mons have no stat block (box_struct stops before
-- MON_STATS). The game derives them in SaveData.validate; the editor
-- only validates a copy, so hydrate here for Boxes/Party/MonEditor.
local Stats = require("src.pokemon.Stats")
local function ensureStats(mon)
Stats.ensure(Data.pokemon and Data.pokemon[mon.species], mon)
end
for _, mon in ipairs(S.save.party or {}) do ensureStats(mon) end
for _, box in ipairs(boxes) do
for _, mon in ipairs(box) do ensureStats(mon) end
end
if S.save.daycare and S.save.daycare.mon then
ensureStats(S.save.daycare.mon)
end
-- what the running game would quarantine, computed on a copy so the
-- editor never mutates the file behind the user's back
local SaveData = require("src.core.SaveData")
Gen.ensureBoxes(S.save)
Gen.hydrateSave(Data, S.save)
local probe = require("src.mods.Merge").deepCopy(S.save)
S.validation = SaveData.validate(probe, Data)
if not SaveData.emptyReport(S.validation) then
S.validation = Gen.validate(probe, Data)
if not Gen.emptyReport(S.save, S.validation) then
if Gen.of(S.save, S.version) == 2 then
S.status = S.status .. string.format(
", game would quarantine: %d script bytes, %d mail, %d events",
#(S.validation.lostScriptMem or {}),
#(S.validation.lostMail or {}),
#(S.validation.lostEvents or {}))
else
S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps",
#S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps)
end
end
end
-- pathOverride lets tests point App.load at a scratch file instead of the
-- real default save path (used to exercise the corrupt-save branch below).
@@ -152,6 +140,9 @@ function App.load(pathOverride, opts)
S.slotId = opts.slotId
S.embedded = opts.embedded or false
S.onClose = opts.onClose
if opts.version then
require("src.core.GameVersion").set(opts.version)
end
-- the same mod set the game loads, merged into Data before the catalogs
-- build, so modded species/items/moves are editable and MonOps stops
-- asserting on them
@@ -163,6 +154,10 @@ function App.load(pathOverride, opts)
-- loaded at least once, so it doubles as the "needs evicting" marker.
if Data._pristineKeys then Data:unloadGenerated() end
Data:load()
if Gen.of(nil, opts.version) == 2
or require("src.core.GameVersion").generation() == 2 then
Gen.bindGoldData(Data)
end
local ModLoader = require("src.mods.Loader")
mods = ModLoader.new()
mods:load(Data)
@@ -174,9 +169,16 @@ function App.load(pathOverride, opts)
for _, mod in ipairs(S.mods:status().loaded) do
modRoots[#modRoots + 1] = mod.path
end
if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then
S.events = Catalog.goldEventList(modRoots)
else
S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua",
nil, modRoots)
end
applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded")
if Gen.of(S.save, S.version) == 2 then
S.events = Catalog.goldEventList(modRoots)
end
end
-- Switch to another save file (Open button, drag-drop, or --save arg).
@@ -582,11 +584,9 @@ local function tabCount(id)
return tostring(n)
elseif id == "items" then
local Bag = require("src.inventory.Bag")
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
return ("%d/%d"):format(Bag.slots(S.save, S.data), Bag.capacity(S.data))
elseif id == "events" then
local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end
return tostring(n)
return tostring(Gen.flagCount(S.save))
elseif id == "map" then
-- map ids run long (REDS_HOUSE_2F); the rail is a summary, not a label
return Kit.ellipsize("tiny", S.mapId or "", 110 * Kit.scale)
@@ -602,21 +602,28 @@ end
-- something. Returns what to draw plus the tab that owns the first problem,
-- so the rail can reserve the pill's width before laying out the tiles.
local function validationPill()
local SaveData = require("src.core.SaveData")
local report = S.validation
if not report or SaveData.emptyReport(report) then
if not report or Gen.emptyReport(S.save, report) then
return "Save validates clean", PAL.green, nil, true
end
local parts = {}
local target
local function add(n, singular, plural, tab)
n = n or 0
if n <= 0 then return end
parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural)
target = target or tab
end
add(#report.lostMons, "mon", "mons", "party")
add(#report.lostItems, "item", "items", "items")
add(#report.remappedMaps, "map", "maps", "map")
if Gen.of(S.save, S.version) == 2 then
add(#(report.lostScriptMem or {}), "script byte", "script bytes", "events")
add(#(report.lostMail or {}), "mail", "mail", "party")
add(#(report.lostEvents or {}), "event", "events", "events")
add(#(report.lostMapScenes or {}), "map scene", "map scenes", "map")
else
add(#(report.lostMons or {}), "mon", "mons", "party")
add(#(report.lostItems or {}), "item", "items", "items")
add(#(report.remappedMaps or {}), "map", "maps", "map")
end
return "Would quarantine " .. table.concat(parts, ", "), PAL.yellow, target, false
end
+23 -1
View File
@@ -91,7 +91,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
end
end
local dirs = { scriptDir }
local dirs = {}
if scriptDir then dirs[#dirs + 1] = scriptDir end
for _, dir in ipairs(extraDirs or {}) do
dirs[#dirs + 1] = dir
end
@@ -110,4 +111,25 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs)
return sortedKeys(found)
end
function Catalog.goldEventList(extraDirs)
local names = {}
local ok, flags = pcall(require, "src.core.gen2.FlagNames")
if ok and flags and flags.events then
for name in pairs(flags.events) do
names[#names + 1] = name
end
end
table.sort(names)
local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs)
local seen = {}
for _, name in ipairs(names) do seen[name] = true end
for _, name in ipairs(modFlags) do
if not seen[name] then
names[#names + 1] = name
seen[name] = true
end
end
return names
end
return Catalog
+350
View File
@@ -0,0 +1,350 @@
-- Generation adapter for the save editor. Panels stay generation-blind;
-- Ops and App read Gold vs RBY through this module so a Gold write never
-- lands in Gen 1 fields (save.money, pokedex.owned, 12 boxes, ...).
local GameVersion = require("src.core.GameVersion")
local Gen = {}
local function versionGeneration(version)
if type(version) ~= "string" then return nil end
local info = GameVersion.info(version)
if info then return info.generation or 1 end
return nil
end
function Gen.of(save, version)
if type(save) == "table" then
if save.generation == 2 then return 2 end
local fromVersion = versionGeneration(save.version)
if fromVersion then return fromVersion end
end
local fromArg = versionGeneration(version)
if fromArg then return fromArg end
return GameVersion.generation()
end
function Gen.ofState(S)
if not S then return GameVersion.generation() end
return Gen.of(S.save, S.version)
end
function Gen.is2(save, version)
return Gen.of(save, version) == 2
end
-- Data:load writes Gold maps/tilesets to the Gen 1 keys; Game2 and the mod
-- merge write gen2Maps / gen2Tilesets. Overlay the gen2 table on the loaded
-- cache so a mod patch that landed on an empty gen2Maps (objects only, no
-- width) does not hide the extractor's record, and a new map like BERRY_FARM
-- still appears.
local function overlayRecords(base, overlay)
if not overlay then return base or {} end
if not base or base == overlay then return overlay end
local out = {}
for id, def in pairs(base) do out[id] = def end
for id, def in pairs(overlay) do
local prior = out[id]
if type(def) == "table" and type(prior) == "table" then
local merged = {}
for k, v in pairs(prior) do merged[k] = v end
for k, v in pairs(def) do merged[k] = v end
out[id] = merged
else
out[id] = def
end
end
return out
end
function Gen.maps(data)
if type(data) ~= "table" then return {} end
return overlayRecords(data.maps, data.gen2Maps)
end
function Gen.tilesets(data)
if type(data) ~= "table" then return {} end
return overlayRecords(data.tilesets, data.gen2Tilesets)
end
-- Point the Gen 2 Data keys at the tables Data:load already filled, before
-- mods:load folds into gen2Maps. Same wiring Game2 does when it boots Gold.
function Gen.bindGoldData(data)
if type(data) ~= "table" then return data end
if data.maps and data.gen2Maps == nil then data.gen2Maps = data.maps end
if data.tilesets and data.gen2Tilesets == nil then
data.gen2Tilesets = data.tilesets
end
if data.palettes and data.gen2Palettes == nil then
data.gen2Palettes = data.palettes
end
if data.gen2Roofs == nil and data.roofs == nil then
local ok, roofs = pcall(require, "data.generated.roofs")
if ok and type(roofs) == "table" then
data.roofs = roofs
data.gen2Roofs = roofs
end
elseif data.roofs and data.gen2Roofs == nil then
data.gen2Roofs = data.roofs
end
return data
end
function Gen.newGame(version)
if (versionGeneration(version) or GameVersion.generation(version)) == 2 then
return require("src.core.gen2.Save").newGame()
end
return require("src.core.SaveData").newGame()
end
function Gen.validate(save, data)
if Gen.of(save) == 2 then
return require("src.core.gen2.Save").validate(save)
end
return require("src.core.SaveData").validate(save, data)
end
function Gen.emptyReport(save, report)
if Gen.of(save) == 2 then
return require("src.core.gen2.Save").emptyReport(report)
end
return require("src.core.SaveData").emptyReport(report)
end
function Gen.hydrateMon(data, mon)
if type(mon) ~= "table" then return mon end
local def = data and data.pokemon and data.pokemon[mon.species]
local gen2 = (mon.stats and mon.stats.specialAttack)
or (def and def.baseStats and def.baseStats.specialAttack)
or mon.experience ~= nil
if gen2 then
require("src.battle.gen2.Mon").refreshStats(mon, data)
else
require("src.pokemon.Stats").ensure(def, mon)
end
return mon
end
-- Party, boxes, Day-Care. Gold's dayCare.man/lady/egg are not save.daycare.
function Gen.hydrateSave(data, save)
if type(save) ~= "table" then return save end
if Gen.of(save) == 2 then
local Mon = require("src.battle.gen2.Mon")
Mon.eachSaveMon(save, function(mon) Mon.refreshStats(mon, data) end)
return save
end
for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end
for _, box in ipairs(save.boxes or {}) do
if type(box) == "table" then
for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end
end
end
if save.daycare and save.daycare.mon then
Gen.hydrateMon(data, save.daycare.mon)
end
return save
end
function Gen.ensureBoxes(save)
if Gen.of(save) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
save.boxes = save.boxes or {}
for i = 1, Boxes2.NUM_BOXES do
save.boxes[i] = save.boxes[i] or {}
end
save.currentBox = math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1))
return save.boxes
end
return require("src.pokemon.Boxes").ensure(save)
end
function Gen.boxCount(save)
if Gen.of(save) == 2 then
return require("src.core.gen2.Boxes").NUM_BOXES
end
return require("src.pokemon.Boxes").COUNT
end
function Gen.boxCapacity(save)
if Gen.of(save) == 2 then
return require("src.core.gen2.Boxes").MONS_PER_BOX
end
return require("src.pokemon.Boxes").CAPACITY
end
function Gen.money(save)
if Gen.of(save) == 2 then
return (save.player and save.player.money) or 0
end
return save.money or 0
end
function Gen.setMoney(save, amount)
if Gen.of(save) == 2 then
save.player = save.player or {}
save.player.money = amount
else
save.money = amount
end
end
function Gen.coins(save)
if Gen.of(save) == 2 then
return (save.player and save.player.coins) or 0
end
return save.coins or 0
end
function Gen.setCoins(save, amount)
if Gen.of(save) == 2 then
save.player = save.player or {}
save.player.coins = amount
else
save.coins = amount
end
end
function Gen.dexOwnedKey(save)
if Gen.of(save) == 2 then return "caught" end
return "owned"
end
function Gen.playerMap(save)
if Gen.of(save) == 2 then
local p = save.position
if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end
return save.spawn, 0, 0
end
local p = save.player or {}
return p.map, p.x or 0, p.y or 0
end
function Gen.setPlayerHere(save, mapId, x, y, facing)
if Gen.of(save) == 2 then
local prev = save.position or {}
save.position = {
map = mapId,
x = x,
y = y,
facing = facing or prev.facing or "down",
}
return
end
save.player = save.player or {}
save.player.map = mapId
save.player.x = x
save.player.y = y
end
local JOHTO = {
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
}
local KANTO = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
function Gen.badgeIds(save, cat)
if Gen.of(save) == 2 then
local ids = {}
for _, name in ipairs(JOHTO) do ids[#ids + 1] = name end
for _, name in ipairs(KANTO) do ids[#ids + 1] = name end
return ids
end
local ids = {}
for _, id in ipairs((cat and cat.items) or {}) do
if tostring(id):find("BADGE", 1, true) then ids[#ids + 1] = id end
end
return ids
end
local KANTO_SET = {}
for _, name in ipairs(KANTO) do KANTO_SET[name] = true end
function Gen.hasBadge(save, id)
if Gen.of(save) == 2 then
local p = save.player or {}
local store = KANTO_SET[id] and (p.kantoBadges or {}) or (p.badges or {})
if store[id] then return true end
local list = KANTO_SET[id] and KANTO or JOHTO
for index, name in ipairs(list) do
if name == id then return store[index] == true end
end
return false
end
return save.inventory and save.inventory[id] and true or false
end
function Gen.toggleBadge(save, id)
if Gen.of(save) == 2 then
save.player = save.player or {}
local storeName = KANTO_SET[id] and "kantoBadges" or "badges"
save.player[storeName] = save.player[storeName] or {}
local store = save.player[storeName]
local on = Gen.hasBadge(save, id)
store[id] = (not on) and true or nil
for index, name in ipairs(KANTO_SET[id] and KANTO or JOHTO) do
if name == id then store[index] = nil end
end
return not on
end
local on = save.inventory[id] and true or false
save.inventory[id] = (not on) and 1 or nil
return not on
end
local function goldFlagId(name)
local flags = require("src.core.gen2.FlagNames")
return flags.events and flags.events[name]
end
function Gen.getFlag(save, name)
if Gen.of(save) == 2 then
local id = goldFlagId(name)
if id then
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(save.events)
return ev:get(id)
end
return save.flags and save.flags[name] == true
end
return save.flags and save.flags[name] == true
end
function Gen.setFlag(save, name, on)
if Gen.of(save) == 2 then
local id = goldFlagId(name)
if id then
local Events2 = require("src.world.gen2.Events")
local ev = Events2.new()
ev:restore(save.events)
ev:set(id, on and true or false)
save.events = ev:serialize()
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
return
end
save.flags = save.flags or {}
save.flags[name] = on and true or nil
end
function Gen.flagCount(save)
if Gen.of(save) == 2 then
local n = 0
for _ in pairs(save.events or {}) do n = n + 1 end
for _ in pairs(save.flags or {}) do n = n + 1 end
return n
end
local n = 0
for _ in pairs(save.flags or {}) do n = n + 1 end
return n
end
function Gen.exp(mon)
return mon.experience or mon.exp or 0
end
return Gen
+45 -8
View File
@@ -4,23 +4,40 @@ local Growth = require("src.pokemon.Growth")
local MonOps = {}
function MonOps.create(data, species, level)
function MonOps.create(data, species, level, gen)
if gen == 2 then
local Mon = require("src.battle.gen2.Mon")
local mon = Mon.new(data, species, level)
assert(mon, "unknown species " .. tostring(species))
return mon
end
return Pokemon.new(data, species, level)
end
function MonOps.recalc(data, mon)
function MonOps.recalc(data, mon, gen)
if gen == 2 or (mon.stats and mon.stats.specialAttack) or mon.experience then
require("src.battle.gen2.Mon").refreshStats(mon, data)
return
end
local def = data.pokemon[mon.species]
assert(def, "unknown species")
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp))
end
function MonOps.setLevel(data, mon, level)
function MonOps.setLevel(data, mon, level, gen)
level = math.max(1, math.min(100, math.floor(level)))
local def = data.pokemon[mon.species]
mon.level = level
if gen == 2 or mon.experience ~= nil then
local Mon = require("src.battle.gen2.Mon")
local growth = Mon.growthFor(data, def.growthRate)
mon.experience = Mon.experienceForLevel(growth, level)
Mon.refreshStats(mon, data)
return
end
mon.exp = Growth.expForLevel(def.growthRate, level)
MonOps.recalc(data, mon)
MonOps.recalc(data, mon, gen)
end
function MonOps.setMove(data, mon, slot, moveId)
@@ -32,6 +49,7 @@ function MonOps.setMove(data, mon, slot, moveId)
id = moveId,
pp = mdef.pp + ((mon.moves[slot] and mon.moves[slot].ppUps) or 0) * math.floor(mdef.pp / 5),
ppUps = mon.moves[slot] and mon.moves[slot].ppUps or nil,
maxPp = mdef.pp,
}
end
@@ -42,19 +60,38 @@ function MonOps.syncHpDv(dvs)
return dvs
end
function MonOps.setDv(data, mon, key, value)
function MonOps.setDv(data, mon, key, value, gen)
mon.dvs[key] = math.max(0, math.min(15, math.floor(value)))
if key ~= "hp" then
MonOps.syncHpDv(mon.dvs)
end
MonOps.recalc(data, mon)
if gen == 2 or (mon.stats and mon.stats.specialAttack) then
local Mon = require("src.battle.gen2.Mon")
mon.dvs.hp = Mon.hpDV(mon.dvs)
local def = data.pokemon[mon.species]
if def then
mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level })
local Unown = require("src.core.gen2.Unown")
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
end
end
end
MonOps.recalc(data, mon, gen)
end
-- Keep level; resync exp to the species growth curve (species changes).
function MonOps.setSpecies(data, mon, species)
-- Gold also copies def.name onto mon.name: the party list and SUMMARY print
-- that field when nickname is nil, so leaving the previous species' name
-- made a swapped mon still read as ABRA (or whoever was added first).
function MonOps.setSpecies(data, mon, species, gen)
assert(data.pokemon[species], "unknown species")
mon.species = species
MonOps.setLevel(data, mon, mon.level)
MonOps.setLevel(data, mon, mon.level, gen)
if gen == 2 or mon.experience ~= nil or (mon.stats and mon.stats.specialAttack) then
require("src.battle.gen2.Mon").syncIdentity(mon, data)
end
end
return MonOps
+244 -68
View File
@@ -17,6 +17,7 @@ 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 Gen = require("Gen")
local Ops = {}
@@ -35,6 +36,62 @@ local function clamp(n, lo, hi)
end
Ops.clamp = clamp
local function stampNewMon(S, mon)
if Gen.ofState(S) == 2 then
require("src.battle.gen2.Mon").stampOT(S.save, mon)
else
mon.ot = S.save.player.name
mon.otId = S.save.player.id
end
return mon
end
local function createMon(S, species, level)
local mon = MonOps.create(S.data, species, level, Gen.ofState(S))
return stampNewMon(S, mon)
end
local function partySlot(S, mon)
for i, member in ipairs(S.save.party or {}) do
if member == mon then return i end
end
end
-- Portrait mail (and CheckPokeMail) store species on the letter, not the mon.
local function partyMailEntry(S, slot)
local Mail = require("src.core.gen2.Mail")
return Mail.state(S.save).party[slot]
end
local function syncPartyMailSpecies(S, mon)
if Gen.ofState(S) ~= 2 then return end
local slot = partySlot(S, mon)
if not slot then return end
local entry = partyMailEntry(S, slot)
if entry then entry.species = mon.species end
end
local function syncPartyMailHeldItem(S, mon, prevItem, newItem)
if Gen.ofState(S) ~= 2 then return end
local slot = partySlot(S, mon)
if not slot then return end
local Mail = require("src.core.gen2.Mail")
if Mail.isMail(newItem) then
local prev = partyMailEntry(S, slot)
local player = S.save.player or {}
Mail.set(S.save, slot, Mail.entry(
newItem,
prev and prev.message or "",
tostring(mon.otName or mon.ot or player.name or ""):sub(1, Mail.AUTHOR_LENGTH),
mon.otId or player.id or 0,
mon.species))
return
end
if Mail.isMail(prevItem) or partyMailEntry(S, slot) then
Mail.clear(S.save, slot)
end
end
local function now()
if love and love.timer and love.timer.getTime then
return love.timer.getTime()
@@ -111,9 +168,7 @@ function Ops.partyAdd(S)
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
local mon = createMon(S, species, 5)
table.insert(S.save.party, mon)
S.selectedParty = #S.save.party
S.editingMon = mon
@@ -129,6 +184,11 @@ function Ops.partyRemove(S)
return false
end
table.remove(S.save.party, index)
-- sPartyMail is keyed by party slot, not by mon: dropping a member without
-- shifting letters hands the next mon someone else's mail.
if Gen.ofState(S) == 2 then
require("src.core.gen2.Mail").removeSlot(S.save, index)
end
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]
@@ -144,6 +204,9 @@ function Ops.partyMove(S, delta)
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]
if Gen.ofState(S) == 2 then
require("src.core.gen2.Mail").swapSlots(S.save, i, j)
end
S.selectedParty = j
return Ops.mark(S, ("Moved %s to slot %d"):format(party[j].species, j))
end
@@ -157,7 +220,7 @@ function Ops.setLevel(S, mon, level)
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)
MonOps.setLevel(S.data, mon, want, Gen.ofState(S))
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
end
@@ -172,15 +235,23 @@ end
-- 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" }
local BASE_STAT_KEYS_G1 = { "hp", "attack", "defense", "speed", "special" }
local BASE_STAT_KEYS_G2 = {
"hp", "attack", "defense", "speed", "specialAttack", "specialDefense",
}
local function baseStatsComplete(bs, keys)
for _, key in ipairs(keys) do
if type(bs[key]) ~= "number" then return false end
end
return true
end
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
return baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G1)
or baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G2)
end
-- The one funnel every species change goes through (the picker, the stepper,
@@ -199,14 +270,19 @@ function Ops.setSpecies(S, mon, 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)
local wasSpecies, wasLevel, wasExp, wasExperience = mon.species, mon.level, mon.exp, mon.experience
local wasStats, wasHp, wasName = mon.stats, mon.hp, mon.name
local wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp =
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id, Gen.ofState(S))
if not ok then
mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp
mon.stats, mon.hp = wasStats, wasHp
mon.species, mon.level, mon.exp, mon.experience = wasSpecies, wasLevel, wasExp, wasExperience
mon.stats, mon.hp, mon.name = wasStats, wasHp, wasName
mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp =
wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp
return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err)))
end
syncPartyMailSpecies(S, mon)
return Ops.mark(S, ("Species set to %s"):format(id))
end
@@ -313,9 +389,9 @@ end
-- 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
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
end
S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" }
if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529)
@@ -327,17 +403,15 @@ end
-- 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
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
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
local mon = createMon(S, id, 5)
table.insert(box, mon)
S.selectedBoxSlot = #box
S.editingMon = mon
@@ -351,7 +425,7 @@ function Ops.setDv(S, mon, key, value)
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)
MonOps.setDv(S.data, mon, key, want, Gen.ofState(S))
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
end
@@ -382,7 +456,17 @@ 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)
local gen = Gen.ofState(S)
local learned
if gen == 2 then
local Mon = require("src.battle.gen2.Mon")
learned = {}
for _, mv in ipairs(Mon.movesAtLevel(def, mon.level, S.data.moves)) do
learned[#learned + 1] = mv.id
end
else
learned = Pokemon.movesAtLevel(def, mon.level)
end
mon.moves = {}
for slot, id in ipairs(learned) do
MonOps.setMove(S.data, mon, slot, id)
@@ -397,6 +481,7 @@ function Ops.healMon(S, mon)
return Ops.say(S, ("%s is already at full HP"):format(mon.species))
end
mon.hp = mon.stats.hp
if mon.maxHp then mon.maxHp = mon.stats.hp end
mon.status = nil
for _, mv in ipairs(mon.moves or {}) do
local def = S.data.moves[mv.id]
@@ -554,27 +639,35 @@ function Ops.clearNickname(S, mon)
end
-- ------------------------------------------------------------------ boxes
function Ops.boxCount(S)
return Gen.boxCount(S.save)
end
function Ops.boxCapacity(S)
return Gen.boxCapacity(S.save)
end
function Ops.boxes(S)
return BoxesMod.ensure(S.save)
return Gen.ensureBoxes(S.save)
end
function Ops.selectBox(S, index)
S.selectedBox = clamp(index, 1, BoxesMod.COUNT)
S.selectedBox = clamp(index, 1, Ops.boxCount(S))
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)
S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, Ops.boxCapacity(S))
return true
end
function Ops.stepBox(S, delta)
local n = BoxesMod.COUNT
local n = Ops.boxCount(S)
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)
S.selectedBoxSlot = clamp(index, 1, Ops.boxCapacity(S))
local mon = box[S.selectedBoxSlot]
S.editingMon = mon
S.status = mon
@@ -589,14 +682,12 @@ end
-- 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
if #box >= Ops.boxCapacity(S) then
return Ops.say(S, ("Box %d is full (%d/%d)")
:format(S.selectedBox, #box, BoxesMod.CAPACITY))
:format(S.selectedBox, #box, Ops.boxCapacity(S)))
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
local mon = createMon(S, species, 5)
table.insert(box, mon)
S.selectedBoxSlot = #box
S.editingMon = mon
@@ -612,9 +703,16 @@ function Ops.withdraw(S)
return Ops.say(S, ("Party is full (%d/%d), deposit one first")
:format(#S.save.party, PartyMod.MAX))
end
if Gen.ofState(S) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
local ok, reason = Boxes2.canWithdraw(S.save, S.selectedBox, S.selectedBoxSlot)
if not ok then return Ops.say(S, reason) end
Boxes2.withdraw(S.save, S.selectedBox, S.selectedBoxSlot)
else
table.remove(box, S.selectedBoxSlot)
table.insert(S.save.party, mon)
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1))
end
S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#Ops.boxes(S)[S.selectedBox], 1))
S.selectedParty = #S.save.party
return Ops.mark(S, ("Withdrew %s to party slot %d"):format(mon.species, #S.save.party))
end
@@ -639,6 +737,17 @@ 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
if Gen.ofState(S) == 2 then
local Boxes2 = require("src.core.gen2.Boxes")
local boxIndex = S.selectedBox or S.save.currentBox or 1
local ok, reason = Boxes2.canDeposit(S.save, i, boxIndex)
if not ok then return Ops.say(S, reason) end
Boxes2.deposit(S.save, i, boxIndex)
S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1))
S.selectedBox = boxIndex
if S.editingMon == mon then S.editingMon = nil end
return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxIndex))
end
local boxNum = BoxesMod.deposit(S.save, mon)
if not boxNum then
return Ops.say(S, "Every box is full, release something first")
@@ -652,12 +761,13 @@ 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
local have = Gen.money(S.save)
local want = clamp(have + delta, 0, Ops.MONEY_MAX)
if want == have 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
Gen.setMoney(S.save, want)
return Ops.mark(S, ("Money set to $%d"):format(want))
end
@@ -665,15 +775,29 @@ function Ops.maxMoney(S)
return Ops.addMoney(S, Ops.MONEY_MAX)
end
Ops.COIN_MAX = 9999
function Ops.addCoins(S, delta)
local have = Gen.coins(S.save)
local want = clamp(have + delta, 0, Ops.COIN_MAX)
if want == have then
return Ops.say(S, delta < 0 and "Coins are already 0"
or ("Coins are already capped at %d"):format(Ops.COIN_MAX))
end
Gen.setCoins(S.save, want)
return Ops.mark(S, ("Coins set to %d"):format(want))
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)
local pocket = Bag.pocketOf(id, S.data)
local capacity = Bag.capacity(S.data, pocket)
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))
return Ops.mark(S, ("Added %s to the bag (%d/%d %s slots)")
:format(id, Bag.slots(S.save, S.data, pocket), capacity, pocket))
end
return Ops.say(S, ("Bag is full (%d/%d slots)")
:format(Bag.slots(S.save), capacity))
return Ops.say(S, ("Bag is full (%d/%d %s slots)")
:format(Bag.slots(S.save, S.data, pocket), capacity, pocket))
end
function Ops.bagAdjust(S, id, delta)
@@ -715,6 +839,12 @@ end
function Ops.addToPc(S, id)
if not id then return Ops.say(S, "Pick an item first") end
local pc = Ops.pcItems(S)
local n = 0
for _ in pairs(pc) do n = n + 1 end
if not pc[id] and Gen.ofState(S) == 2 and n >= 50 then
return Ops.say(S, "PC item storage is full (50 stacks)")
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
@@ -749,27 +879,17 @@ function Ops.isBadgeId(id)
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
return Gen.badgeIds(S.save, S.cat)
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"))
local nowOn = Gen.toggleBadge(S.save, id)
return Ops.mark(S, ("%s %s"):format(id, nowOn and "earned" or "removed"))
end
-- ----------------------------------------------------------------- events
function Ops.setFlag(S, name, on)
S.save.flags[name] = on and true or nil
Gen.setFlag(S.save, name, on)
return Ops.mark(S, ("%s = %s"):format(name, tostring(on and true or false)))
end
@@ -801,17 +921,19 @@ end
-- -------------------------------------------------------------------- dex
function Ops.dex(S)
S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} }
local key = Gen.dexOwnedKey(S.save)
S.save.pokedex = S.save.pokedex or { seen = {}, [key] = {} }
S.save.pokedex.seen = S.save.pokedex.seen or {}
S.save.pokedex.owned = S.save.pokedex.owned or {}
S.save.pokedex[key] = S.save.pokedex[key] or {}
return S.save.pokedex
end
function Ops.dexCounts(S)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
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
for _ in pairs(dex[key] or {}) do owned = owned + 1 end
return seen, owned, #S.cat.species
end
@@ -819,25 +941,28 @@ end
-- 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)
local key = Gen.dexOwnedKey(S.save)
dex.seen[species] = on and true or nil
if not on then dex.owned[species] = nil end
if not on then dex[key][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
local key = Gen.dexOwnedKey(S.save)
dex[key][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 key = Gen.dexOwnedKey(S.save)
local n = 0
local function stamp(mon)
if not dex.owned[mon.species] then n = n + 1 end
if not dex[key][mon.species] then n = n + 1 end
dex.seen[mon.species] = true
dex.owned[mon.species] = true
dex[key][mon.species] = true
end
for _, m in ipairs(S.save.party) do stamp(m) end
for _, box in ipairs(S.save.boxes or {}) do
@@ -855,9 +980,10 @@ end
function Ops.dexOwnAll(S)
local dex = Ops.dex(S)
local key = Gen.dexOwnedKey(S.save)
for _, species in ipairs(S.cat.species) do
dex.seen[species] = true
dex.owned[species] = true
dex[key][species] = true
end
return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species))
end
@@ -866,7 +992,8 @@ 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 = {} }
local key = Gen.dexOwnedKey(S.save)
S.save.pokedex = { seen = {}, [key] = {} }
return Ops.mark(S, "Pokedex wiped")
end
@@ -927,6 +1054,11 @@ end
-- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save
-- has already visited.
function Ops.isOutdoor(S, map)
if not map or not map.def then return false end
local Map2 = require("src.world.gen2.Map")
if Gen.ofState(S) == 2 and Map2.isOutdoor then
return Map2.isOutdoor(map.def) and true or false
end
if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then
return true
end
@@ -937,15 +1069,17 @@ 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
Gen.setPlayerHere(S.save, S.mapId, cell.cx, 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 Gen.ofState(S) == 2 then
S.save.spawn = S.mapId
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
end
if not Ops.isOutdoor(S, map) then
return Ops.say(S, S.mapId .. " doesn't look outdoor (no connections, not visited)")
end
@@ -956,8 +1090,50 @@ end
function Ops.setLastHeal(S)
local cell = S.mapClickCell
if not cell then return Ops.say(S, "Click a cell first") end
if Gen.ofState(S) == 2 then
S.save.spawn = S.mapId
return Ops.mark(S, ("spawn set to %s"):format(S.mapId))
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
function Ops.setHeldItem(S, mon, id)
if not mon then return false end
if id == "" or id == nil then
if not mon.item then return Ops.say(S, "No held item to clear") end
local was = mon.item
mon.item = nil
syncPartyMailHeldItem(S, mon, was, nil)
return Ops.mark(S, ("Cleared held item (%s)"):format(was))
end
if not S.data.items[id] then
return Ops.say(S, ("%s is not an item"):format(tostring(id)))
end
local was = mon.item
mon.item = id
syncPartyMailHeldItem(S, mon, was, id)
return Ops.mark(S, ("%s now holds %s"):format(mon.species, id))
end
function Ops.setHappiness(S, mon, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 255)
if want == (mon.happiness or 0) then
return Ops.say(S, ("Happiness is already %d"):format(want))
end
mon.happiness = want
return Ops.mark(S, ("%s happiness %d"):format(mon.species, want))
end
function Ops.setPokerus(S, mon, value)
if not mon then return false end
local want = clamp(math.floor(value), 0, 255)
if want == (mon.pokerus or 0) then
return Ops.say(S, ("Pokerus is already %d"):format(want))
end
mon.pokerus = want
return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want))
end
return Ops
+9 -10
View File
@@ -19,7 +19,6 @@
-- in the selected box as a Lv5 mon built by the same MonOps path partyAdd
-- uses, so its stats, exp and moves are consistent.
local BoxesMod = require("src.pokemon.Boxes")
local PartyMod = require("src.pokemon.Party")
local Theme = require("Theme")
local Ops = require("Ops")
@@ -33,12 +32,12 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
local s = Kit.scale
local pad = 16 * s
Kit.card(x, y, stripW, h)
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(BoxesMod.COUNT))
Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(Ops.boxCount(S)))
local stripTop = y + pad + Kit.textHeight("caption") + 10 * s
local stripInner = stripW - 2 * pad
local bRowH = math.min(30 * s, math.max(22 * s,
(h - (stripTop - y) - pad - (BoxesMod.COUNT - 1) * 6 * s) / BoxesMod.COUNT))
for i = 1, BoxesMod.COUNT do
(h - (stripTop - y) - pad - (Ops.boxCount(S) - 1) * 6 * s) / Ops.boxCount(S)))
for i = 1, Ops.boxCount(S) do
local ry = stripTop + (i - 1) * (bRowH + 6 * s)
if ry + bRowH > y + h - pad then break end
if Kit.row(x + pad, ry, stripInner, bRowH, i == S.selectedBox, PAL.blue, 9 * s) then
@@ -52,7 +51,7 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h)
ry + (bRowH - Kit.textHeight("tiny")) / 2, PAL.caption)
local mx = x + pad + stripInner - 10 * s - countW - 8 * s - 44 * s
Kit.meter(mx, ry + (bRowH - 5 * s) / 2, 44 * s, 5 * s,
fill / BoxesMod.CAPACITY * 100, fill >= BoxesMod.CAPACITY and PAL.yellow or PAL.blue)
fill / Ops.boxCapacity(S) * 100, fill >= Ops.boxCapacity(S) and PAL.yellow or PAL.blue)
end
end
@@ -66,7 +65,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
Kit.text("tab", ("Box %d"):format(S.selectedBox), gx,
y + gpad + (headH - Kit.textHeight("tab")) / 2, PAL.heading)
local titleW = Kit.textWidth("tab", ("Box %d"):format(S.selectedBox))
Kit.text("mono", ("%d/%d"):format(#box, BoxesMod.CAPACITY),
Kit.text("mono", ("%d/%d"):format(#box, Ops.boxCapacity(S)),
gx + titleW + 14 * s, y + gpad + (headH - Kit.textHeight("mono")) / 2, PAL.caption)
local navW = 34 * s
if Kit.stepper(gx + ginner - 2 * navW - 8 * s, y + gpad, navW, headH, "<",
@@ -102,7 +101,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
end
if Kit.button(gx + wdW + 10 * s, actY, addW, actH, addLabel,
{ font = "small", radius = 9 * s,
enabled = #box < BoxesMod.CAPACITY }) then
enabled = #box < Ops.boxCapacity(S) }) then
Ops.openBoxAddPicker(S, Kit)
end
if Kit.button(gx + ginner - relW, actY, relW, actH, relLabel,
@@ -119,7 +118,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
-- of five slivers.
local cols = math.max(2, math.min(COLS,
math.floor((ginner + cellGap) / (86 * s + cellGap))))
local rows = math.ceil(BoxesMod.CAPACITY / cols)
local rows = math.ceil(Ops.boxCapacity(S) / cols)
local cellW = math.max(0, (ginner - cellGap * (cols - 1)) / cols)
-- floor at Kit's 26px tap target so a short window shrinks the cells but
-- never inverts them (#715); overflow clips inside the grid body rather
@@ -128,7 +127,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h)
math.min((gridH - cellGap * (rows - 1)) / rows, 110 * s))
Kit.pushClip(gx, gridTop, ginner, gridH)
for i = 1, BoxesMod.CAPACITY do
for i = 1, Ops.boxCapacity(S) do
local cc = (i - 1) % cols
local cr = math.floor((i - 1) / cols)
local bx = gx + cc * (cellW + cellGap)
@@ -220,7 +219,7 @@ function M.draw(S, Kit, x, y, w, h)
local s = Kit.scale
local gap = 20 * s
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT)
S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, Ops.boxCount(S))
S.save.currentBox = S.selectedBox
local boxes = Ops.boxes(S)
local box = boxes[S.selectedBox]
+2 -1
View File
@@ -145,7 +145,8 @@ function M.draw(S, Kit, x, y, w, h)
local rx = cx + ci * (colW + colGap)
local ry = gridTop + ri * (rowH + rowGap)
local isSeen = dex.seen[id] == true
local isOwned = dex.owned[id] == true
local ownedKey = require("Gen").dexOwnedKey(S.save)
local isOwned = dex[ownedKey] and dex[ownedKey][id] == true
Theme.row(rx, ry, colW, rowH, 9 * s, 0.6)
local def = S.data.pokemon[id]
+8 -2
View File
@@ -9,6 +9,7 @@
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local M = {}
@@ -50,7 +51,7 @@ local function buildRows(S)
if contains(name, filter) then
rows[#rows + 1] = {
label = name,
checked = S.save.flags[name] == true,
checked = Gen.getFlag(S.save, name),
set = function(on) Ops.setFlag(S, name, on) end,
}
end
@@ -107,7 +108,12 @@ function M.draw(S, Kit, x, y, w, h)
-- past the card edge.
local pillH = 32 * s
local px, py = cx, y + pad
for _, t in ipairs(SUB_TABS) do
local pills = SUB_TABS
if Gen.of(S.save) == 2 then
pills = { SUB_TABS[1] }
if S.eventsTab ~= "flags" then S.eventsTab = "flags" end
end
for _, t in ipairs(pills) do
local pw = Kit.textWidth("small", t.label) + 32 * s
if px > cx and px + pw > cx + inner then
px = cx
+26 -12
View File
@@ -18,6 +18,7 @@
local Bag = require("src.inventory.Bag")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local M = {}
@@ -46,7 +47,11 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu
local qtyW = Kit.textWidth("monoRow", qtyText)
Kit.textRight("monoRow", qtyText, bx - 10 * s,
y + (h - Kit.textHeight("monoRow")) / 2, PAL.heading)
Kit.text("mono", Kit.ellipsize("mono", id, bx - qtyW - 30 * s - (x + 10 * s)),
local label = id
if Gen.of(S.save) == 2 then
label = (Bag.pocketOf(id, S.data) or "ITEM") .. " " .. id
end
Kit.text("mono", Kit.ellipsize("mono", label, bx - qtyW - 30 * s - (x + 10 * s)),
x + 10 * s, y + (h - Kit.textHeight("mono")) / 2, PAL.text)
return clicked
end
@@ -55,9 +60,13 @@ end
-- Each card is a function of its own rect so the wide (three column) and the
-- stacked (#715) layouts are the same drawing code with different geometry.
local function moneyHeight(Kit, s, pad)
return pad * 2 + Kit.textHeight("caption") + 8 * s
local function moneyHeight(Kit, s, pad, S)
local h = pad * 2 + Kit.textHeight("caption") + 8 * s
+ Kit.textHeight("headline") + 10 * s + 30 * s
if S and Gen.of(S.save) == 2 then
h = h + 28 * s
end
return h
end
local function drawMoney(S, Kit, x, y, w, h)
@@ -68,11 +77,19 @@ local function drawMoney(S, Kit, x, y, w, h)
local maxW = 74 * s
if Kit.button(x + w - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out",
{ kind = "accent", font = "tiny", radius = 7 * s,
enabled = (S.save.money or 0) < Ops.MONEY_MAX }) then
enabled = Gen.money(S.save) < Ops.MONEY_MAX }) then
Ops.maxMoney(S)
end
Kit.text("headline", ("$%d"):format(S.save.money or 0), x + pad,
Kit.text("headline", ("$%d"):format(Gen.money(S.save)), x + pad,
y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow)
if Gen.of(S.save) == 2 then
Kit.text("mono", ("COINS %d"):format(Gen.coins(S.save)), x + pad + 160 * s,
y + pad + Kit.textHeight("caption") + 8 * s, PAL.muted)
if Kit.button(x + w - pad - 74 * s, y + pad + 26 * s, 74 * s, 22 * s, "+100 coins",
{ kind = "ghost", font = "tiny", radius = 6 * s }) then
Ops.addCoins(S, 100)
end
end
local mbY = y + h - pad - 30 * s
local mbW = (w - 2 * pad - 3 * 8 * s) / 4
for i, delta in ipairs(MONEY_STEPS) do
@@ -99,10 +116,7 @@ local function drawBadges(S, Kit, x, y, w, h)
Kit.card(x, y, w, h)
local earned = 0
for _, id in ipairs(badgeIds) do
-- #515: truthy check, not `== true` -- the in-game grant path stores a
-- number (see OverworldController.lua checkVictoryRewards), matching
-- src/inventory/Badges.lua's own truthy read.
if S.save.inventory[id] then earned = earned + 1 end
if Gen.hasBadge(S.save, id) then earned = earned + 1 end
end
Kit.caption(x + pad, y + pad, "BADGES")
Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + w - pad,
@@ -112,7 +126,7 @@ local function drawBadges(S, Kit, x, y, w, h)
for i, id in ipairs(badgeIds) do
local bc = (i - 1) % BADGE_COLS
local br = math.floor((i - 1) / BADGE_COLS)
local on = S.save.inventory[id]
local on = Gen.hasBadge(S.save, id)
local short = id:gsub("BADGE$", "")
if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s),
bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on,
@@ -253,7 +267,7 @@ function M.draw(S, Kit, x, y, w, h)
-- drag over their own bodies first.
local off = Theme.clamp(S.itemsScroll or 0, 0,
math.max(0, (S._itemsContentH or 0) - h))
local moneyH = moneyHeight(Kit, s, pad)
local moneyH = moneyHeight(Kit, s, pad, S)
local badgeH = badgeHeight(S, Kit, s, pad)
local pickH = 280 * s
local listH = 300 * s
@@ -278,7 +292,7 @@ function M.draw(S, Kit, x, y, w, h)
-- Money and badges are fixed-height so the picker gets every pixel left
-- over: cycling through ~250 item ids in a two-row list was the thing that
-- made the old panel unusable.
local moneyH = moneyHeight(Kit, s, pad)
local moneyH = moneyHeight(Kit, s, pad, S)
local badgeH = badgeHeight(S, Kit, s, pad)
drawMoney(S, Kit, x, y, leftW, moneyH)
drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap)
+99 -19
View File
@@ -14,12 +14,18 @@ local MapLoader = require("src.world.MapLoader")
local Warp = require("src.world.Warp")
local Theme = require("Theme")
local Ops = require("Ops")
local Gen = require("Gen")
local PAL = Theme.PAL
local MapBrowser = {}
local CELL = 16 -- the walk grid; a cell is 16px of map art
local function playerPos(S)
local map, x, y = Gen.playerMap(S.save)
return map, x or 0, y or 0
end
local function clampZoom(z)
if z < 1 then return 1 end
if z > 4 then return 4 end
@@ -38,7 +44,7 @@ MapBrowser.centerOn = centerOn
local function sortedMapIds(data)
local ids = {}
for id in pairs(data.maps) do ids[#ids + 1] = id end
for id in pairs(Gen.maps(data)) do ids[#ids + 1] = id end
table.sort(ids)
return ids
end
@@ -52,7 +58,19 @@ local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true }
local function goToWarp(S, warp)
local def = warp.def
local fromMap = S.data.maps[S.mapId]
if Gen.of(S.save) == 2 then
local dest = def.destMap or def.map
if dest then
S.mapId = dest
S.mapClickCell = nil
S._mapCenteredFor = dest
S.status = "Followed warp to " .. tostring(dest)
else
S.status = "Warp has no destination map"
end
return
end
local fromMap = Gen.maps(S.data)[S.mapId]
if fromMap and OUTSIDE_TILESETS[fromMap.tileset]
and def.destMap ~= "LAST_MAP" and def.destMap ~= S.mapId then
S.save.lastOutdoor = { id = S.mapId, x = def.x, y = def.y }
@@ -125,13 +143,15 @@ local function drawOverlays(S, map)
return cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL
end
love.graphics.setColor(0.27, 0.59, 1, 0.55)
for _, wdef in ipairs(map.def.warps) do
for _, wdef in ipairs(map.def.warps or {}) do
love.graphics.rectangle("line", cellRect(wdef.x, wdef.y))
end
if S.save.player.map == S.mapId then
local playerMap, px, py = playerPos(S)
if playerMap == S.mapId then
love.graphics.setColor(1, 0.36, 0.4, 0.9)
love.graphics.rectangle("fill", cellRect(S.save.player.x, S.save.player.y))
love.graphics.rectangle("fill", cellRect(px, py))
end
if Gen.of(S.save) ~= 2 then
local heal = S.save.lastHeal
if heal and heal.map == S.mapId then
love.graphics.setColor(0.24, 0.88, 0.54, 0.9)
@@ -142,6 +162,7 @@ local function drawOverlays(S, map)
love.graphics.setColor(1, 0.8, 0.02, 0.9)
love.graphics.rectangle("line", cellRect(out.x, out.y))
end
end
if S.mapClickCell then
love.graphics.setColor(1, 1, 0.35, 0.95)
love.graphics.rectangle("line", cellRect(S.mapClickCell.cx, S.mapClickCell.cy))
@@ -239,9 +260,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
#ids, perPage)
if Kit.button(lr.x + pad, gotoY, listInner, gotoH, "Go to save location",
{ font = "small", radius = 9 * s }) then
MapBrowser.select(S, S.save.player.map)
Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map,
S.save.player.x, S.save.player.y))
local pmap, px, py = playerPos(S)
if pmap then
MapBrowser.select(S, pmap)
Ops.say(S, ("Jumped to %s (%d,%d)"):format(pmap, px, py))
else
Ops.say(S, "No player location on this save")
end
end
-- ---------------------------------------------------------- the viewport
@@ -253,7 +278,32 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
Kit.text("monoBig", tostring(S.mapId), vx0,
vr.y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading)
local ok, map = pcall(MapLoader.load, S.data, S.mapId)
local ok, map
if Gen.of(S.save) == 2 then
local def = Gen.maps(S.data)[S.mapId]
if def then
local Map2 = require("src.world.gen2.Map")
if type(def.width) ~= "number" or type(def.height) ~= "number" then
ok, map = false, "incomplete map record (missing width/height)"
else
local tileset = Gen.tilesets(S.data)[def.tileset]
ok, map = pcall(Map2.new, def, tileset or {})
if ok and map and not map.renderer then
local MapPreview = require("src.world.gen2.MapPreview")
S._g2MapBaker = S._g2MapBaker or MapPreview.baker({
tilesets = Gen.tilesets(S.data),
gen2Roofs = S.data.gen2Roofs, roofs = S.data.roofs,
gen2Palettes = S.data.gen2Palettes, palettes = S.data.palettes,
})
map.renderer = MapPreview.renderer(S._g2MapBaker, map)
end
end
else
ok, map = false, "unknown map"
end
else
ok, map = pcall(MapLoader.load, S.data, S.mapId)
end
if not ok then
Kit.text("mono", "Failed to load map: " .. tostring(map), vx0,
vr.y + vpad + headH + 20 * s, PAL.red)
@@ -279,12 +329,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
local zBtn = 32 * s
local rightEdge = vx0 + vinner
local zoomW = 2 * zBtn + 56 * s + 12 * s
local pmap, px, py = playerPos(S)
local showCenter = vinner >= zoomW + 10 * s + centerW + 160 * s
if showCenter then
if Kit.button(rightEdge - centerW, vr.y + vpad, centerW, headH, "Center on player",
{ kind = "accent", font = "small", radius = 7 * s }) then
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
if pmap == S.mapId then
centerOn(S, px, py)
Ops.say(S, "Centred on the player")
else
Ops.say(S, "Player isn't on this map")
@@ -312,10 +363,11 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
-- the panel has laid itself out.
if S._mapCenteredFor ~= S.mapId then
S._mapCenteredFor = S.mapId
if S.save.player.map == S.mapId then
centerOn(S, S.save.player.x, S.save.player.y)
if pmap == S.mapId then
centerOn(S, px, py)
else
centerOn(S, map.widthCells / 2, map.heightCells / 2)
centerOn(S, (map.widthCells or map.width or 10) / 2,
(map.heightCells or map.height or 10) / 2)
end
end
@@ -334,7 +386,24 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
love.graphics.push()
love.graphics.translate(vx0, vy0)
love.graphics.scale(S.mapZoom, S.mapZoom)
if map.renderer and map.renderer.draw then
map.renderer:draw(S.mapCamX, S.mapCamY)
else
local wc = map.widthCells or ((map.width or 8) * 2)
local hc = map.heightCells or ((map.height or 8) * 2)
for cy = 0, hc - 1 do
for cx = 0, wc - 1 do
if (cx + cy) % 2 == 0 then
love.graphics.setColor(0.18, 0.22, 0.32, 1)
else
love.graphics.setColor(0.14, 0.17, 0.26, 1)
end
love.graphics.rectangle("fill",
cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
drawOverlays(S, map)
love.graphics.pop()
love.graphics.setScissor()
@@ -402,12 +471,22 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
Kit.caption(sx0 + pad, sr.y + pad, "SPAWN POINTS")
local sTop = sr.y + pad + Kit.textHeight("caption") + 12 * s
local sInner = sr.w - 2 * pad
local player = S.save.player
local pmap2, px2, py2 = playerPos(S)
local playerValue = pmap2 and ("%s (%d,%d)"):format(pmap2, px2, py2) or "unset"
local spawns
if Gen.of(S.save) == 2 then
spawns = {
{ key = "PLAYER", color = PAL.red, value = playerValue,
set = function() Ops.setPlayerHere(S) end },
{ key = "SPAWN", color = PAL.green,
value = tostring(S.save.spawn or "SPAWN_HOME"),
set = function() Ops.setLastHeal(S) end },
}
else
local out = S.save.lastOutdoor
local heal = S.save.lastHeal
local spawns = {
{ key = "PLAYER", color = PAL.red,
value = ("%s (%d,%d)"):format(player.map, player.x, player.y),
spawns = {
{ key = "PLAYER", color = PAL.red, value = playerValue,
set = function() Ops.setPlayerHere(S) end },
{ key = "LAST HEAL", color = PAL.green,
value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset",
@@ -416,6 +495,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset",
set = function() Ops.setLastOutdoor(S, map) end },
}
end
local spawnH = 62 * s
for i, sp in ipairs(spawns) do
local ry = sTop + (i - 1) * (spawnH + 8 * s)
@@ -431,7 +511,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h)
sx0 + pad + 12 * s, ry + spawnH - 10 * s - Kit.textHeight("mono"), PAL.muted)
end
local noteY = sTop + 3 * (spawnH + 8 * s) + 6 * s
local noteY = sTop + #spawns * (spawnH + 8 * s) + 6 * s
Kit.textCenter("tiny",
"Click a cell first. Warp cells follow the warp instead of selecting. " ..
"Arrow keys / WASD pan, the wheel zooms.",
+52 -5
View File
@@ -19,17 +19,26 @@
local Theme = require("Theme")
local Ops = require("Ops")
local PAL = Theme.PAL
local Gen = require("Gen")
local MonEditor = {}
local DV_KEYS = { "attack", "defense", "speed", "special" }
local STAT_KEYS = {
local STAT_KEYS_G1 = {
{ key = "HP", field = "hp" },
{ key = "ATK", field = "attack" },
{ key = "DEF", field = "defense" },
{ key = "SPD", field = "speed" },
{ key = "SPC", field = "special" },
}
local STAT_KEYS_G2 = {
{ key = "HP", field = "hp" },
{ key = "ATK", field = "attack" },
{ key = "DEF", field = "defense" },
{ key = "SPA", field = "specialAttack" },
{ key = "SPD", field = "specialDefense" },
{ key = "SPE", field = "speed" },
}
-- Front sprites are read straight off the generated cache. One image per
-- species, cached for the process: the old panel called newImage every frame,
@@ -96,7 +105,7 @@ local function drawLevelRow(S, Kit, mon, lx0, ly)
end
lx = lx + bw + 8 * s
end
Kit.text("mono", ("EXP %d"):format(mon.exp or 0), lx + 6 * s,
Kit.text("mono", ("EXP %d"):format(Gen.exp(mon)), lx + 6 * s,
ly + (lh - Kit.textHeight("mono")) / 2, PAL.muted)
return lh
end
@@ -106,7 +115,7 @@ end
local function levelRowWidth(Kit, mon)
local s = Kit.scale
return 52 * s + 2 * (40 * s + 8 * s) + 58 * s + 8 * s + 2 * (40 * s + 8 * s)
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(mon.exp or 0))
+ 6 * s + Kit.textWidth("mono", ("EXP %d"):format(Gen.exp(mon)))
end
local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
@@ -179,7 +188,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
local tw = math.min(w - 40 * s, 340 * s)
Kit.textCenter("button",
"Pick a slot on the left to inspect it. Every change here re-runs the " ..
"Gen1 stat formulas, so HP and stats stay legal.",
"stat formulas, so HP and stats stay legal.",
x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted)
return
end
@@ -220,10 +229,13 @@ function MonEditor.draw(S, Kit, x, y, w, h)
end
-- the nickname section: a caption line (with the Clear button on it) plus
-- the field + Set row
local extraH = 0
if Gen.ofState(S) == 2 then extraH = 88 * s end
local nickFieldH = 30 * s
local contentH = pad + headerH + 18 * s
+ capH + 10 * s + nickFieldH + 18 * s
+ capH + 10 * s + cellH + 18 * s
+ extraH
+ colsH + pad
-- Called before the widgets so this frame already draws at the updated
@@ -311,8 +323,9 @@ function MonEditor.draw(S, Kit, x, y, w, h)
local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s
Kit.caption(cx, statsY, "STATS . recalculated from level + DVs")
statsY = statsY + capH + 10 * s
local STAT_KEYS = Gen.ofState(S) == 2 and STAT_KEYS_G2 or STAT_KEYS_G1
local gap = 12 * s
local cellW = (inner - gap * 4) / 5
local cellW = (inner - gap * (#STAT_KEYS - 1)) / #STAT_KEYS
for i, st in ipairs(STAT_KEYS) do
local bx = cx + (i - 1) * (cellW + gap)
Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6)
@@ -326,6 +339,40 @@ function MonEditor.draw(S, Kit, x, y, w, h)
-- --------------------------------------------------- DVs | moves split
local colY = statsY + cellH + 18 * s
if Gen.ofState(S) == 2 then
local extraY = colY
Kit.caption(cx, extraY, "GOLD")
extraY = extraY + capH + 8 * s
local row = 28 * s
Kit.text("tiny", "HELD " .. tostring(mon.item or "none"), cx, extraY, PAL.text)
if Kit.button(cx + inner - 70 * s, extraY, 70 * s, row, "Clear item",
{ kind = "danger", font = "tiny", radius = 6 * s }) then
Ops.setHeldItem(S, mon, nil)
end
extraY = extraY + row + 6 * s
Kit.text("tiny", ("HAPPINESS %d"):format(mon.happiness or 0), cx, extraY, PAL.text)
if Kit.stepper(cx + 140 * s, extraY, 28 * s, row, "-", { font = "small" }) then
Ops.setHappiness(S, mon, (mon.happiness or 0) - 10)
end
if Kit.stepper(cx + 174 * s, extraY, 28 * s, row, "+", { font = "small" }) then
Ops.setHappiness(S, mon, (mon.happiness or 0) + 10)
end
Kit.text("tiny", ("PKRS %d"):format(mon.pokerus or 0), cx + 220 * s, extraY, PAL.text)
if Kit.stepper(cx + 300 * s, extraY, 28 * s, row, "-", { font = "small" }) then
Ops.setPokerus(S, mon, (mon.pokerus or 0) - 1)
end
if Kit.stepper(cx + 334 * s, extraY, 28 * s, row, "+", { font = "small" }) then
Ops.setPokerus(S, mon, (mon.pokerus or 0) + 1)
end
extraY = extraY + row + 4 * s
local bits = {}
if mon.gender then bits[#bits + 1] = mon.gender end
if mon.shiny then bits[#bits + 1] = "shiny" end
if mon.unownLetter then bits[#bits + 1] = "Unown " .. tostring(mon.unownLetter) end
Kit.text("tiny", table.concat(bits, " ") ~= "" and table.concat(bits, " ")
or "gender/shiny follow DVs", cx, extraY, PAL.caption)
colY = extraY + 22 * s
end
if narrow then
-- stacked: DVs first, then moves, then the two actions side by side at
-- full width (#715)