diff --git a/docs/updater.md b/docs/updater.md index b045f272..15beb16f 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -45,7 +45,9 @@ untouched): reports itself up to date (it never chases a release, and it never counts as a valid payload to chainload). - `shell` - the native-shell contract this build's fused executable - implements. + implements. Any change to `main.lua`'s `love.run` bumps it and adds the new + loop as `tests/data/love_run/shell.lua`; `tests/engine/love_run_shell_matrix.lua` + fails until both are done, and runs the payload under every shipped loop. - `payloadHost` - the native host family an in-place payload targets. Ordinary LÖVE packages use `"love"`. A specialized native package uses a distinct, stable identifier and accepts only payloads carrying that same identifier. diff --git a/src/core/Version.lua b/src/core/Version.lua index ae234d4d..e0297725 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -7,7 +7,7 @@ local Version = { engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the -- "-dev" placeholder; CI stamps the real X.Y.Z into -- the packed game.love only, never the working tree. - shell = 2, -- native-shell contract this build implements + shell = 3, -- native-shell contract this build implements payloadHost = "love", -- native host family for in-place Lua payloads. -- A payload must name the same family; this prevents -- mounting code packaged for a different native host. diff --git a/src/core/game3/battle/anim_tasks.lua b/src/core/game3/battle/anim_tasks.lua index 26d18f04..f3c6fbc1 100644 --- a/src/core/game3/battle/anim_tasks.lua +++ b/src/core/game3/battle/anim_tasks.lua @@ -3820,7 +3820,7 @@ AnimTasks.REGISTRY.AnimTask_ExtrasensoryDistortion = AnimTasks.ScreenDistortionW AnimTasks.REGISTRY.UproarDistortion = AnimTasks.ScreenDistortionWobble AnimTasks.REGISTRY.AnimTask_UproarDistortion = AnimTasks.ScreenDistortionWobble ---- pret CreateSpotlight & RemoveSpotlight (Spotlight, Follow Me) +-- pokeemerald/src/battle_anim_effects_3.c:1674 function AnimTasks.Spotlight(t, vm) local frame = t.data[14] or 0 t.data[14] = frame + 1 diff --git a/src/core/game3/battle/ui.lua b/src/core/game3/battle/ui.lua index 84432611..8a7a2233 100644 --- a/src/core/game3/battle/ui.lua +++ b/src/core/game3/battle/ui.lua @@ -2193,13 +2193,8 @@ local function battle_font() return require(P.font.module) end -local function is_rs_battle() - local P = require("src.core.game3.profile").forSession(Ui._session) - return P.font.nativeLayout == "rs" -end - function Ui.drawMenuFrames(panelMode) - BattleChrome.drawMenuFrames(panelMode, is_rs_battle() and "rs" or nil) + BattleChrome.drawMenuFrames(panelMode) end local function c5to8(x) @@ -2383,8 +2378,9 @@ local function draw_move_menu_rse(st) end local function draw_action_menu(st) - if is_rs_battle() then return draw_action_menu_rs(st) end - if BattleChrome.isRse() then return draw_action_menu_rse(st) end + local L = BattleChrome.layout() + if L == "rs" then return draw_action_menu_rs(st) end + if L == "emerald" then return draw_action_menu_rse(st) end -- B_WIN_ACTION_PROMPT @ (1,15) after scroll → px (8,120); printer (2,2) → (10,122) -- B_WIN_ACTION_MENU @ (17,15) → (136,120); printer (0,2) → (136,122) -- ActionSelectionCreateCursorAt: tile (16+7*col, 35+row) → after scroll (128,120); @@ -2409,8 +2405,9 @@ local function draw_action_menu(st) end local function draw_move_menu(st) - if is_rs_battle() then return draw_move_menu_rs(st) end - if BattleChrome.isRse() then return draw_move_menu_rse(st) end + local L = BattleChrome.layout() + if L == "rs" then return draw_move_menu_rs(st) end + if L == "emerald" then return draw_move_menu_rse(st) end local ab = st and (is_double(st) and active_battler(st) or st.player) local mon = ab and ab.mon local positions = { diff --git a/src/core/game3/profiles/rs/ui.lua b/src/core/game3/profiles/rs/ui.lua index f64d610f..ed0bb382 100644 --- a/src/core/game3/profiles/rs/ui.lua +++ b/src/core/game3/profiles/rs/ui.lua @@ -145,7 +145,10 @@ return { }, startMenu = "src.ui.game3.rs.start_menu_data", shopMenu = "src.ui.game3.rs.shop_menu", - saveMenu = "rse", + -- pokeruby/src/save_menu_util.c:12 + saveMenu = "rs", + -- pokeruby/src/coins.c:16 + coinsWindow = { frameOrigin = true, text = "gOtherText_Coins2" }, battleSpriteLayout = "rs", healthbox = { layout = "rs", doublesCenters = { [0] = { x = 159, y = 77 }, [1] = { x = 44, y = 19 }, diff --git a/src/core/game3/step_events.lua b/src/core/game3/step_events.lua index d342a747..6e4f274e 100644 --- a/src/core/game3/step_events.lua +++ b/src/core/game3/step_events.lua @@ -3,7 +3,7 @@ -- 1. Lockstep Event Queue: Prevents simultaneous tick collisions; flushes on party white-out. -- 2. Happiness Step Counter (128 steps): +1 friendship to all party Pokémon. -- 3. VS Seeker Battery (100 steps): Increments while the VS SEEKER is in the bag. --- 4. Overworld Poison (4 steps): 4-frame reddish screen flash, SE_FIELD_POISON, lethal faint at 0 HP. +-- 4. Overworld Poison (4 steps RSE, 5 FRLG): 4-frame reddish screen flash, SE_FIELD_POISON, lethal faint at 0 HP. -- 5. Egg Cycles & Daycare (daycare stepCounter == 255): Decrements egg cycles -> EggHatch; +1 EXP per step in Daycare. -- 6. Repel Counter: Decrements steps -> Text_RepelWoreOff on expiration. @@ -231,11 +231,18 @@ function StepEvents.onStepTaken(session, game) return end - -- 3. Overworld Poison Counter (every 4 steps, pret field_poison.c) + -- pokeemerald/src/field_control_avatar.c:641 + local Map = package.loaded["src.core.game3.map"] + local curDef = Map and Map.currentDef and Map.currentDef() + -- pokeemerald/include/constants/map_types.h:13 + local psnGate = not forced and not (curDef and tonumber(curDef.mapType) == 9) local psnVar = Sem.var(session, "poisonSteps") - local psnSteps = (tonumber(session.vars[psnVar] or session.poisonSteps) or 0) + 1 - if psnSteps >= 4 then - psnSteps = 0 + local psnSteps = tonumber(session.vars[psnVar] or session.poisonSteps) or 0 + -- pokeemerald/src/field_control_avatar.c:645 + -- pokefirered/src/field_control_avatar.c:718 + local psnPeriod = isRse and 4 or 5 + if psnGate then psnSteps = (psnSteps + 1) % psnPeriod end + if psnGate and psnSteps == 0 then local anyPoisonDamage = false local faintedMons = {} local profileField = require("src.core.game3.profile").forSession(session).field diff --git a/src/import/BoxUI.lua b/src/import/BoxUI.lua index 0fae3180..f7531f66 100644 --- a/src/import/BoxUI.lua +++ b/src/import/BoxUI.lua @@ -3,6 +3,7 @@ local Theme = require("src.ui.kit.Theme") local Transition = require("src.ui.kit.Transition") local Strings = require("src.core.Strings") local Icons = require("src.ui.kit.Icons") +local Toast = require("src.ui.kit.Toast") local UI = {} local PAL = Theme.PAL UI.ICONS = { List = "book-open", Filters = "list-filter", Arrange = "arrow-left-right", @@ -31,46 +32,37 @@ function UI.button(imp, x, y, w, h, id, label, action, opts) opts = opts or {}; opts.action, opts.font = action, opts.font or "small" view().btn(imp, x, y, w, h, "box-" .. id, Strings(label), opts) end -local TOAST = { ok = { color = "green", icon = "check", hold = 3.5 }, - error = { color = "red", icon = "triangle-alert", hold = 7 }, - info = { color = "blue", icon = "circle-help", hold = 5 } } -function UI.toast(imp, text, kind, sticky) - if text == nil or text == "" then imp._boxToast = nil; return end - imp._boxToast = { text = Strings(tostring(text)), kind = TOAST[kind] and kind or "info", - at = love.timer.getTime(), sticky = sticky } +local function toastStore(imp, key) + imp._toasts = imp._toasts or {} + imp._toasts[key] = imp._toasts[key] or {} + return imp._toasts[key] +end +function UI.toast(imp, text, kind, sticky, key) + local store = toastStore(imp, key or "box") + if text == nil or text == "" then return Toast.clear(store) end + Toast.show(store, Strings(tostring(text)), kind, { sticky = sticky }) +end +function UI.currentToast(imp) + local store = imp._toasts and imp.tab and imp._toasts[imp.tab] + return store and Toast.current(store) and store or nil end function UI.occludeToast(imp) - local t = imp.tab == "box" and imp._boxToast - if t and t.rect then Kit.occlude(t.rect[1], t.rect[2], t.rect[3], t.rect[4]) end + local store = UI.currentToast(imp) + local r = store and store.toast.rect + if r then Kit.occlude(r[1], r[2], r[3], r[4]) end end +local function wrapHeight(text, w, maxLines) return Kit.wrapHeight("small", text, w, maxLines) end +local function drawText(text, x, y, w, c, maxLines) Kit.textWrapped("small", text, x, y, w, c, maxLines) end function UI.drawToast(imp, m, bottom) - local t = imp._boxToast - if not t or imp.tab ~= "box" then return end - local style = TOAST[t.kind] - local elapsed = love.timer.getTime() - t.at - local hold = t.sticky and math.huge or style.hold - if elapsed > hold + .25 then imp._boxToast = nil; return end - local slide = math.min(1, elapsed / .18, (hold + .25 - elapsed) / .25) - slide = 1 - (1 - math.max(0, slide)) ^ 3 - local s, color = m.s, PAL[style.color] - local pad, gap, icon = 12 * s, 10 * s, 20 * s - local w = math.min(420 * s, m.W - 32 * s) - local textW = w - 2 * pad - icon - gap - local h = 2 * pad + math.max(icon, Kit.wrapHeight("small", t.text, textW, 4)) - local x = (m.W - w) / 2 - local y = bottom - h - 12 * s + (1 - slide) * (h + 24 * s) - t.rect = { x, y, w, h } - local r = Theme.cardRadius() - Theme.fillRounded(x + 2 * s, y + 4 * s, w, h, PAL.inverse, .35, r) - Theme.fillRounded(x, y, w, h, PAL.surface, .98, r) - Theme.fillRounded(x, y, w, h, color, .16, r) - Theme.strokeRounded(x, y, w, h, color, .75, 1.5, r) - Icons.draw(style.icon, x + pad, y + (h - icon) / 2, icon, color, 1) - Kit.textWrapped("small", t.text, x + pad + icon + gap, y + pad, textW, PAL.heading, 4) + local store = UI.currentToast(imp) + if not store then return end + local rect = Toast.draw(store, { x = 0, w = m.W, bottom = bottom, s = m.s, + wrapHeight = wrapHeight, drawText = drawText }) + if not rect then return end local overlay = Kit._overlay Kit._overlay = true - view().btn(imp, x, y, w, h, "box-toast", "", { face = "bare", ring = false, - action = function() imp._boxToast = nil end }) + view().btn(imp, rect[1], rect[2], rect[3], rect[4], "box-toast", "", { face = "bare", ring = false, + action = function() Toast.clear(store) end }) Kit._overlay = overlay end function UI.close(imp) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index dff478d5..7c23cbcb 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -647,6 +647,7 @@ function RomImporter:_setNxSavesInboxNotice(version) self.saveNotice = self.saveNotice or {} self.saveNotice[version] = { ok = true, + persistent = true, text = Strings("Copy your %s .sav into:\n%s/%s/\nDBI MTP → 1: SD Card/%s%s/", game, saveDir, inbox, rel, inbox), } @@ -959,6 +960,7 @@ function RomImporter:rescanSavesAction(version) elseif skipCount > 0 then self.saveNotice[version] = { ok = true, + kind = "info", text = Strings("Already imported: %d file(s) skipped. Check SAVE SLOT.", skipCount), } @@ -3409,6 +3411,7 @@ function RomImporter:exportSave(version, format, scope, slotId) local outDir = exportsDir(version) self.saveNotice[noticeScope] = { ok = true, + persistent = true, text = Strings("Exported to %s\nDBI MTP → 1: SD Card/%s%s/", res, rel, outDir), } return @@ -3433,7 +3436,7 @@ function RomImporter:exportSave(version, format, scope, slotId) if love.system.createFile and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then self.pickPending = true self.pickTimer = 0 - self.saveNotice[noticeScope] = { ok = true, + self.saveNotice[noticeScope] = { ok = true, kind = "info", text = "Pick where to save " .. suggested .. "..." } else self.androidPendingExportVersion = nil @@ -4647,7 +4650,18 @@ end -- Pair with POKEPORT_LAUNCHER_TAB / POKEPORT_WIN to profile a specific panel. local profN, profSamples = tonumber(os.getenv("POKEPORT_LAUNCHER_PROF") or ""), {} +function RomImporter:toastSaveNotices() + local BoxUI = require("src.import.BoxUI") + for scope, notice in pairs(self.saveNotice or {}) do + if not notice.persistent and notice.text and notice.text ~= "" then + BoxUI.toast(self, notice.text, notice.kind or (notice.ok and "ok" or "error"), nil, self.tab) + self.saveNotice[scope] = notice.dir and { ok = notice.ok, text = "", dir = notice.dir } or nil + end + end +end + function RomImporter:draw() + self:toastSaveNotices() local View = require("src.import.LauncherView") if not profN then return View.draw(self) end local t0 = love.timer.getTime() diff --git a/src/ui/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua index c43ed6a8..b4fe89f3 100644 --- a/src/ui/game3/battle_chrome.lua +++ b/src/ui/game3/battle_chrome.lua @@ -413,11 +413,14 @@ end function BattleChrome.layout() local m = BattleChrome._manifest - return type(m) == "table" and m.layout or "frlg" + if type(m) ~= "table" then return "frlg" end + if m.assetLayout == "rs" or m.layout == "rs" then return "rs" end + return m.layout == "rse" and "emerald" or "frlg" end -function BattleChrome.isRse() - return BattleChrome.layout() == "rse" +function BattleChrome.isHoenn() + local l = BattleChrome.layout() + return l == "rs" or l == "emerald" end -- pokeemerald/include/constants/battle.h:347 @@ -463,7 +466,7 @@ function BattleChrome.textOrigin(id) end function BattleChrome.messageOrigin() - if not BattleChrome.isRse() then return 10, 122, 224 end + if BattleChrome.layout() ~= "emerald" then return 10, 122, 224 end local x, y, w = BattleChrome.textOrigin(BattleChrome.WIN.MSG) return x, y, w end @@ -500,15 +503,16 @@ BattleChrome.RS_MENU_FRAMES = { } function BattleChrome.menuFrameRects(mode, layout) - local set = layout == "rs" and BattleChrome.RS_MENU_FRAMES or BattleChrome.RSE_MENU_FRAMES - return set[mode] + local set = (layout == "rs" and BattleChrome.RS_MENU_FRAMES) + or (layout == "emerald" and BattleChrome.RSE_MENU_FRAMES) or nil + return set and set[mode] end -- pokeemerald/src/battle_bg.c:744 -- pokeruby/src/battle_bg.c:266 -function BattleChrome.drawMenuFrames(mode, layout) - if not BattleChrome.isRse() then return end - local rects = BattleChrome.menuFrameRects(mode, layout) +function BattleChrome.drawMenuFrames(mode) + if not BattleChrome.isHoenn() then return end + local rects = BattleChrome.menuFrameRects(mode, BattleChrome.layout()) if not rects then return end local Chrome = require("src.ui.game3.chrome") for _, r in ipairs(rects) do diff --git a/src/ui/game3/choice.lua b/src/ui/game3/choice.lua index a557d83d..0e11dc83 100644 --- a/src/ui/game3/choice.lua +++ b/src/ui/game3/choice.lua @@ -36,6 +36,25 @@ function Choice.reset() return true end +local function uiProfile() + local okR, Runtime = pcall(require, "src.core.game3.runtime") + local session = okR and type(Runtime) == "table" and Runtime.getSession and Runtime.getSession() or nil + local okP, Profile = pcall(require, "src.core.game3.profile") + local okF, row = pcall(function() return okP and Profile.forSession(session) end) + return okF and type(row) == "table" and type(row.ui) == "table" and row.ui or nil +end + +function Choice.fieldLayout() + local ui = uiProfile() + return ui and ui.saveMenu == "rs" and "rs" or "frlg" +end + +-- pokeemerald/src/menu.c:98, pokefirered/src/new_menu_helpers.c:48 +function Choice.yesNoWidth() + local ui = uiProfile() + return ui and ui.saveMenu == "rse" and 5 or 6 +end + function Choice.yesNo(cb, layout) Choice.active = true Choice.kind = "yesno" @@ -51,9 +70,18 @@ function Choice.yesNo(cb, layout) end Choice.cursor = 1 Choice.done = cb - -- pokefirered/src/new_menu_helpers.c:48 - Choice.left = tonumber(layout.left) or 21 - Choice.top = tonumber(layout.top) or 9 + if layout.style == "battle" then + Choice.left = tonumber(layout.left) or 24 + Choice.top = tonumber(layout.top) or 9 + elseif Choice.fieldLayout() == "rs" then + -- pokeruby/src/script_menu.c:765, pokeruby/src/start_menu.c:698 + Choice.left = tonumber(layout.left) or 20 + Choice.top = tonumber(layout.top) or 8 + else + -- pokeemerald/src/script_menu.c:198, pokefirered/src/script_menu.c:864 + Choice.left = 21 + Choice.top = 9 + end Choice.maxRight = nil Choice.cols = 1 Choice.ignoreBPress = layout.ignoreBPress or false @@ -168,7 +196,18 @@ end -- pokefirered/src/menu.c:531 function Choice.drawYesNo(L, Tp, cursor, labels) - Window.stdFrame(Window.template(L, Tp, 6, 4)) + if Choice.fieldLayout() == "rs" then + -- pokeruby/src/menu.c:608 + Window.stdFrame(Window.template(L + 1, Tp + 1, 5, 4)) + for i, lab in ipairs(labels) do + -- pokeruby/src/menu.c:602 + Window.printPx(lab, (L + 1) * 8, (Tp + 1 + 2 * (i - 1)) * 8) + end + -- pokeruby/src/menu.c:721, :750 + require("src.ui.game3.rs.menu_cursor").draw((L + 1) * 8, (Tp + 1) * 8 + (cursor - 1) * 16, 40) + return + end + Window.stdFrame(Window.template(L, Tp, Choice.yesNoWidth(), 4)) for i, lab in ipairs(labels) do local rowPx = Tp * 8 + 2 + (i - 1) * 14 if i == cursor then Window.cursorPx(L * 8, rowPx) end @@ -176,22 +215,118 @@ function Choice.drawYesNo(L, Tp, cursor, labels) end end +function Choice.battleYesNoGeometry(layout, L, Tp) + L, Tp = L or 24, Tp or 9 + if layout == "rs" then + -- pokeruby/src/battle_script_commands.c:5273 + return { + frame = { left = L + 1, top = Tp, width = 4, height = 4 }, + cursor = "rs", cursorX = (L + 1) * 8, cursorWidth = 32, + -- pokeruby/src/battle_script_commands.c:9617 + rowY = function(i) return Tp * 8 + (i - 1) * 16 end, + -- pokeruby/src/battle_script_commands.c:5274 + textX = (L + 1) * 8, textDy = 0, + } + elseif layout == "emerald" then + -- pokeemerald/include/battle_script_commands.h:11 + return { + frame = { left = L + 1, top = Tp, width = 4, height = 4 }, + -- pokeemerald/src/battle_script_commands.c:10206 + cursor = "arrow", cursorX = (L + 1) * 8, + rowY = function(i) return (Tp + (i - 1) * 2) * 8 end, + -- pokeemerald/src/battle_message.c:1600 + textX = (L + 2) * 8, textDy = 1, + } + end + -- pokefirered/src/battle_script_commands.c:5149 + return { + frame = { left = L, top = Tp, width = 5, height = 4 }, + -- pokefirered/src/battle_script_commands.c:9775 + cursor = "arrow", cursorX = L * 8, + rowY = function(i) return (Tp + (i - 1) * 2) * 8 end, + -- pokefirered/src/battle_message.c:2570 + textX = (L + 1) * 8, textDy = 2, + } +end + +-- pokeruby/src/script_menu.c:626 +local function rsWidthTiles(labels) + local FrlgFont = require("src.ui.game3.frlg_font") + local w = 0 + for _, lab in ipairs(labels) do + local px = FrlgFont.measure(tostring(lab or "")) + local tiles = math.floor((px + 7) / 8) + if tiles > w then w = tiles end + end + return w +end + +-- pokeruby/src/menu.c:458 +local function rsGridRows(n, cols) + if cols == 1 or cols == n or not (math.floor(n / 2) < cols or n % 2 ~= 0) then + return math.floor(n / cols) + end + return math.floor(n / cols) + 1 +end + +function Choice.rsMultiGeometry(labels, left, top, cols) + local n = #labels + local w = rsWidthTiles(labels) + local tx, ty = left, top + if cols <= 1 then + -- pokeruby/src/script_menu.c:647 + if tx + w > 29 then tx = 29 - w end + local cells = {} + for i = 1, n do + cells[i] = { x = tx * 8, y = (ty + 2 * (i - 1)) * 8 } + end + return { frame = { tx, ty, w, 2 * n }, cells = cells, barWidth = w * 8 } + end + -- pokeruby/src/menu.c:454 + local rows = rsGridRows(n, cols) + local total = cols * (w + 1) - 1 + local cells = {} + for i = 1, n do + local c = (i - 1) % cols + local r = math.floor((i - 1) / cols) + cells[i] = { x = (tx + c * (w + 1)) * 8, y = (ty + 2 * r) * 8 } + end + return { frame = { tx, ty, total, 2 * rows }, cells = cells, barWidth = w * 8 } +end + +-- pokeruby/src/script_menu.c:655, pokeruby/src/menu.c:524 +function Choice.drawRsMulti() + local g = Choice.rsMultiGeometry(Choice.options, Choice.left or 20, Choice.top or 5, Choice.cols or 1) + Window.stdFrame(Window.template(g.frame[1], g.frame[2], g.frame[3], g.frame[4])) + for i, lab in ipairs(Choice.options) do + Window.printPx(lab, g.cells[i].x, g.cells[i].y) + end + local cur = g.cells[Choice.cursor] or g.cells[1] + if cur then + -- pokeruby/src/menu.c:748 + require("src.ui.game3.rs.menu_cursor").draw(cur.x, cur.y, g.barWidth) + end +end + function Choice.draw() if not Choice.active or not Choice.options then return end if Choice.style == "battle" and Choice.kind == "yesno" then - -- pokefirered/src/battle_script_commands.c:9775 - local L, Tp = Choice.left, Choice.top + local okB, BattleChrome = pcall(require, "src.ui.game3.battle_chrome") + local layout = okB and type(BattleChrome) == "table" and BattleChrome.layout and BattleChrome.layout() or "frlg" + local g = Choice.battleYesNoGeometry(layout, Choice.left, Choice.top) local okR, Runtime = pcall(require, "src.core.game3.runtime") local session = okR and type(Runtime) == "table" and Runtime.getSession and Runtime.getSession() local opts = type(session) == "table" and session.options or nil local frameType = tonumber(type(opts) == "table" and opts.frameType or nil) or 0 - -- pokefirered/src/battle_bg.c:693 - Window.userFrame(Window.template(L, Tp, 5, 4), frameType) + -- pokefirered/src/battle_bg.c:694 + Window.userFrame(Window.template(g.frame.left, g.frame.top, g.frame.width, g.frame.height), frameType) for i, lab in ipairs(Choice.options) do - local rowPx = (Tp + (i - 1) * 2) * 8 - if i == Choice.cursor then Window.cursorPx(L * 8, rowPx) end - -- pokefirered/src/battle_message.c:2574 - Window.printPx(lab, (L + 1) * 8, rowPx + 2) + local rowPx = g.rowY(i) + if i == Choice.cursor and g.cursor == "arrow" then Window.cursorPx(g.cursorX, rowPx) end + Window.printPx(lab, g.textX, rowPx + g.textDy) + end + if g.cursor == "rs" then + require("src.ui.game3.rs.menu_cursor").draw(g.cursorX, g.rowY(Choice.cursor), g.cursorWidth) end return end @@ -201,6 +336,11 @@ function Choice.draw() return end + if Choice.fieldLayout() == "rs" then + Choice.drawRsMulti() + return + end + local n = #Choice.options local cols = Choice.cols or 1 if cols <= 1 then diff --git a/src/ui/game3/coins_box.lua b/src/ui/game3/coins_box.lua index 467c1d3d..28722f58 100644 --- a/src/ui/game3/coins_box.lua +++ b/src/ui/game3/coins_box.lua @@ -70,7 +70,20 @@ end function CoinsBox.draw() if not CoinsBox.visible then return end - if require("src.core.game3.profile").family() == "rse" then + local Profile = require("src.core.game3.profile") + local row = Profile.forSession() + local cw = type(row) == "table" and type(row.ui) == "table" and row.ui.coinsWindow or nil + if cw and cw.frameOrigin then + -- pokeruby/src/coins.c:16 + local left, top = CoinsBox.x + 1, CoinsBox.y + 1 + Window.stdFrame(Window.template(left, top, 8, 2)) + -- pokeruby/src/coins.c:25 + local digits = tostring(clamp(CoinsBox._amount)) + local text = RomText.plain(cw.text or "gText_Coins", { stringVars = { digits } }) + Window.printPx(text, left * 8 + 7 + math.max(0, 4 - #digits) * 6, top * 8) + return + end + if Profile.family() == "rse" then -- pokeemerald/src/coins.c:14 PrintCoinsString, :24 ShowCoinsWindow local tpl = Window.template(CoinsBox.x, CoinsBox.y, 8, 2) Window.stdFrame(tpl) diff --git a/src/ui/game3/save_menu.lua b/src/ui/game3/save_menu.lua index 6ee02739..8837f62e 100644 --- a/src/ui/game3/save_menu.lua +++ b/src/ui/game3/save_menu.lua @@ -47,8 +47,9 @@ end -- pokefirered/src/save_menu_util.c:25 function SaveMenu.countDex(session) local dex = type(session) == "table" and session.dex or nil - if SaveMenu.layout(session) == "rse" then + if SaveMenu.isHoenn(session) then -- pokeemerald/src/menu.c:2122 + -- pokeruby/src/save_menu_util.c:118 local st = SaveMenu.flagStore(session) return Dex.summaryCount({ version = session.version, dex = dex, flags = st.flags, vars = st.vars }) end @@ -75,6 +76,10 @@ function SaveMenu.layout(session) return ui and ui.saveMenu or "frlg" end +function SaveMenu.isHoenn(session) + return SaveMenu.layout(session) ~= "frlg" +end + local function saveExists(session) local ok, raw = pcall(require("src.core.SaveData").load, session and session.version) return ok and type(raw) == "table" @@ -85,7 +90,8 @@ function SaveMenu.show(opts) SaveMenu.open = true SaveMenu.cursor = 1 SaveMenu._phase = "confirm" - SaveMenu._rse = SaveMenu.layout(opts.session) == "rse" + SaveMenu._layout = SaveMenu.layout(opts.session) + SaveMenu._rse = SaveMenu._layout ~= "frlg" SaveMenu._timer = nil SaveMenu._error = nil SaveMenu._session = opts.session @@ -245,7 +251,7 @@ end function SaveMenu.locationName(session) session = session or {} - if SaveMenu.layout(session) == "rse" then return rseLocationName(session) end + if SaveMenu.isHoenn(session) then return rseLocationName(session) end if type(session.mapName) == "string" and session.mapName ~= "" and not session.mapName:find("^FR_") and not session.mapName:find("^SEVII_") then return session.mapName:upper() end @@ -310,7 +316,40 @@ function SaveMenu.drawRse() local pt = session.playtime or session.playTime or {} row(y, "gText_SavingTime", string.format("%d:%02d", tonumber(pt.hours or session.hours) or 0, tonumber(pt.minutes or session.minutes) or 0)) + SaveMenu.drawDialog(session) +end +-- pokeruby/src/save_menu_util.c:12 +function SaveMenu.drawRs() + local session = SaveMenu._session or {} + local hasDex = SaveMenu.hasDex(session) + local win = Window.template(1, 1, 12, hasDex and 10 or 8) + Window.stdFrame(win) + local x0, y0 = win.left * 8, win.top * 8 + local right = (win.left + 12) * 8 + local NORMAL = FrlgFont.COLOR.NORMAL + local function row(y, labelKey, value) + FrlgFont.draw(RomText.plain(labelKey), x0, y0 + y, { colors = NORMAL }) + -- pokeruby/src/save_menu_util.c:68 + FrlgFont.draw(value, right - FrlgFont.measure(value), y0 + y, { colors = NORMAL }) + end + -- pokeruby/src/save_menu_util.c:76 + FrlgFont.draw(Strings(SaveMenu.locationName(session)), x0, y0, { colors = NORMAL }) + row(16, "gText_SavingPlayer", tostring(session.name or session.playerName or "")) + row(32, "gText_SavingBadges", tostring(SaveMenu.countBadges(session))) + local y = 48 + if hasDex then + row(y, "gText_SavingPokedex", tostring(SaveMenu.countDex(session))) + y = y + 16 + end + local pt = session.playtime or session.playTime or {} + row(y, "gText_SavingTime", string.format("%d:%02d", tonumber(pt.hours or session.hours) or 0, + tonumber(pt.minutes or session.minutes) or 0)) + SaveMenu.drawDialog(session) +end + +function SaveMenu.drawDialog(session) + local NORMAL = FrlgFont.COLOR.NORMAL Chrome.dialogueFrame() local left, top, width = Chrome.dialogueWindow() local key = ({ confirm = "gText_ConfirmSave", overwrite = "gText_AlreadySavedFile", @@ -332,6 +371,14 @@ function SaveMenu.drawRse() local yn = Window.template(21, 9, 5, 4) Window.stdFrame(yn) local yx, yy = yn.left * 8, yn.top * 8 + if SaveMenu._layout == "rs" then + -- pokeruby/src/menu.c:602 + FrlgFont.draw(RomText.plain("gText_Yes"), yx, yy, { colors = NORMAL }) + FrlgFont.draw(RomText.plain("gText_No"), yx, yy + 16, { colors = NORMAL }) + -- pokeruby/src/menu.c:721 + require("src.ui.game3.rs.menu_cursor").draw(yx, yy + (SaveMenu.cursor - 1) * 16, 40) + return + end FrlgFont.draw(RomText.plain("gText_Yes"), yx + 8, yy + 1, { colors = NORMAL }) FrlgFont.draw(RomText.plain("gText_No"), yx + 8, yy + 17, { colors = NORMAL }) Window.cursorPx(yx, yy + 1 + (SaveMenu.cursor == 2 and 16 or 0)) @@ -340,6 +387,7 @@ end function SaveMenu.draw() if not SaveMenu.open then return end + if SaveMenu._layout == "rs" then return SaveMenu.drawRs() end if SaveMenu._rse then return SaveMenu.drawRse() end local session = SaveMenu._session or {} local name = tostring(session.name or session.playerName or "") diff --git a/src/ui/game3/start_menu.lua b/src/ui/game3/start_menu.lua index 0df10315..4a047d07 100644 --- a/src/ui/game3/start_menu.lua +++ b/src/ui/game3/start_menu.lua @@ -437,6 +437,17 @@ function StartMenu.draw() local popY = 9 local popW = 6 local popH = 4 + if d and d.drawCursor then + -- pokeruby/src/menu.c:608 + Window.stdFrame(Window.template(popX, popY, 5, popH)) + local cur = StartMenu._confirmCursor == 1 and 0 or 1 + -- pokeruby/src/menu.c:602 + FrlgFont.draw(RomText.plain("gText_Yes"), popX * 8, popY * 8, { colors = FrlgFont.COLOR.NORMAL }) + FrlgFont.draw(RomText.plain("gText_No"), popX * 8, popY * 8 + 16, { colors = FrlgFont.COLOR.NORMAL }) + -- pokeruby/src/menu.c:721, :750 + require("src.ui.game3.rs.menu_cursor").draw(popX * 8, popY * 8 + cur * 16, 40) + return + end Window.stdFrame(Window.template(popX, popY, popW, popH)) local rowY1 = popY * 8 + 2 local rowY2 = popY * 8 + 18 diff --git a/src/ui/kit/Toast.lua b/src/ui/kit/Toast.lua new file mode 100644 index 00000000..4d0c92e9 --- /dev/null +++ b/src/ui/kit/Toast.lua @@ -0,0 +1,151 @@ +local Toast = {} + +Toast.KINDS = { + ok = { color = "green", icon = "check", hold = 3.5 }, + error = { color = "red", icon = "triangle-alert", hold = 7 }, + warn = { color = "yellow", icon = "triangle-alert", hold = 6 }, + info = { color = "blue", icon = "circle-help", hold = 5 }, +} +Toast.SLIDE = .18 +Toast.FADE = .25 + +local function now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return nil +end + +local function holdOf(t) + if t.sticky then return math.huge end + return t.hold or Toast.KINDS[t.kind].hold +end + +local function expired(t, at) + return t.at and at and (at - t.at) > holdOf(t) + Toast.FADE +end + +function Toast.show(store, text, kind, opts) + if text == nil or text == "" then store.toast = nil; return end + text = tostring(text) + kind = Toast.KINDS[kind] and kind or "info" + opts = opts or {} + local t, at = store.toast, now() + if t and t.text == text and t.kind == kind and not expired(t, at) then + if t.at and at then t.at = at - math.min(Toast.SLIDE, at - t.at) end + t.sticky, t.hold = opts.sticky, opts.hold + return t + end + store.toast = { text = text, kind = kind, at = at, sticky = opts.sticky, hold = opts.hold } + return store.toast +end + +function Toast.clear(store) + store.toast = nil +end + +function Toast.current(store) + local t = store and store.toast + if not t then return nil end + if expired(t, now()) then store.toast = nil; return nil end + return t +end + +function Toast.hit(store, x, y) + local t = Toast.current(store) + local r = t and t.rect + if r and t.at then + local at = now() + local elapsed = at and (at - t.at) + if elapsed and (elapsed < Toast.SLIDE or elapsed > holdOf(t)) then return false end + end + return r ~= nil and x ~= nil and y ~= nil + and x >= r[1] and x <= r[1] + r[3] and y >= r[2] and y <= r[2] + r[4] +end + +local function wrapLines(font, text, w) + if not font then return { text } end + local ok, _, lines = pcall(font.getWrap, font, text, w) + return (ok and lines and #lines > 0) and lines or { text } +end + +local function lineHeight(font) + return font and font:getHeight() or 12 +end + +local function cachedLines(t, font, w) + local c = t._wrap + if c and c.font == font and c.w == w then return c.lines end + local lines = wrapLines(font, t.text, w) + t._wrap = { font = font, w = w, lines = lines } + return lines +end + +local function defaultText(font, lines, x, y, w, color, maxLines) + local G = love and love.graphics + if not G or not font then return end + local Theme = require("src.ui.kit.Theme") + local lh = lineHeight(font) + G.setFont(font) + Theme.col(color, 1) + for i = 1, math.min(#lines, maxLines) do + local line = lines[i] + if i == maxLines and #lines > maxLines then line = Theme.ellipsize(font, line .. "...", w) end + pcall(G.print, line, Theme.snap(x), Theme.snap(y + (i - 1) * lh)) + end +end + +function Toast.draw(store, area) + local t = store and store.toast + if not t then return nil end + local at = now() + if not t.at then t.at = at end + local elapsed = (at and t.at) and (at - t.at) or 0 + local hold = holdOf(t) + if elapsed > hold + Toast.FADE then store.toast = nil; return nil end + local Theme = require("src.ui.kit.Theme") + local Icons = require("src.ui.kit.Icons") + local PAL = Theme.PAL + local style = Toast.KINDS[t.kind] + local slide = math.min(1, elapsed / Toast.SLIDE, (hold + Toast.FADE - elapsed) / Toast.FADE) + slide = 1 - (1 - math.max(0, slide)) ^ 3 + local s, color = area.s or 1, PAL[style.color] + local pad, gap, icon = 12 * s, 10 * s, 20 * s + local maxLines = area.maxLines or 4 + local w = area.width or math.min(420 * s, area.w - 32 * s) + local textW = w - 2 * pad - icon - gap + local lines = (not area.wrapHeight or not area.drawText) and cachedLines(t, area.font, textW) + local textH = area.wrapHeight and area.wrapHeight(t.text, textW, maxLines) + or math.min(#lines, maxLines) * lineHeight(area.font) + local h = 2 * pad + math.max(icon, textH) + local x = (area.x or 0) + (area.w - w) / 2 + local y, fromAbove + if area.place then y, fromAbove = area.place(x, w, h) end + if y == nil then + if area.centerY then + y, fromAbove = area.centerY - h / 2, true + elseif area.top and not (area.bottom and area.maxY and area.top + 12 * s + h > area.maxY) then + y, fromAbove = area.top + 12 * s, true + else + y, fromAbove = area.bottom - h - 12 * s, false + end + end + if fromAbove then + y = y - (1 - slide) * (h + 24 * s) + else + y = y + (1 - slide) * (h + 24 * s) + end + t.rect = { x, y, w, h } + local r = area.radius or Theme.cardRadius() + Theme.fillRounded(x + 2 * s, y + 4 * s, w, h, PAL.inverse, .35, r) + Theme.fillRounded(x, y, w, h, PAL.surface, .98, r) + Theme.fillRounded(x, y, w, h, color, .16, r) + Theme.strokeRounded(x, y, w, h, color, .75, 1.5, r) + Icons.draw(style.icon, x + pad, y + (h - icon) / 2, icon, color, 1) + if area.drawText then + area.drawText(t.text, x + pad + icon + gap, y + pad, textW, PAL.heading, maxLines) + else + defaultText(area.font, lines, x + pad + icon + gap, y + pad, textW, PAL.heading, maxLines) + end + return t.rect +end + +return Toast diff --git a/tests/data/love_run/shell3.lua b/tests/data/love_run/shell3.lua new file mode 100644 index 00000000..71dde5fc --- /dev/null +++ b/tests/data/love_run/shell3.lua @@ -0,0 +1,108 @@ +function love.run() + _G.POKEPORT_LOOP_RESTART = true + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + _G.POKEPORT_LOOP_PANEL_SYNC = true + FrameCap.bootPanelSync() + local RefreshRate = require("src.core.RefreshRate") + local FixedStep = require("src.core.FixedStep") + local VSync = require("src.core.VSync") + local PresentSync = require("src.core.PresentSync") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local function sleepUntilFrame(deadline) + while true do + local remaining = deadline - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + if remaining > 0.004 then + love.timer.sleep(remaining - 0.002) + else + love.timer.sleep(remaining) + end + end + end + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if a ~= "restart" and love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + if name == "focus" and a then + PresentSync.onDisplayChange() + elseif name == "resize" then + PresentSync.onDisplayChange() + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + RefreshRate.sample(dt) + checkEmergencyQuit(dt) + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + else + local idleCap = idlePresentationCap(idleFor) + if idleCap then cap = idleCap end + end + if cap == FrameCap.DISPLAY and not VSync.isOn() then + cap = FrameCap.DEFAULT + elseif cap == FrameCap.DISPLAY and PresentSync.needsSoftwareCap() then + cap = FrameCap.DEFAULT + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + PresentSync.waitBeforePresent() + love.graphics.present() + PresentSync.notePresent() + end + PresentSync.applyFixedStepPeriod() + if love.timer then + if paced and cap ~= FrameCap.DISPLAY and not PresentSync.hardwarePacesCap(cap) then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + sleepUntilFrame(nextFrame) + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.1.24.lua b/tests/data/love_run/v0.1.24.lua new file mode 100644 index 00000000..f29d782f --- /dev/null +++ b/tests/data/love_run/v0.1.24.lua @@ -0,0 +1,46 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + return a or 0 + end + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + if love.update then love.update(dt) end + if love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + love.graphics.present() + end + if love.timer then + if paced then + local budget = 1 / FrameCap.current + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + while true do + local remaining = nextFrame - love.timer.getTime() + if remaining <= 0 then break end + love.timer.sleep(remaining < 0.001 and remaining or 0.001) + end + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.1.42.lua b/tests/data/love_run/v0.1.42.lua new file mode 100644 index 00000000..f80f540f --- /dev/null +++ b/tests/data/love_run/v0.1.42.lua @@ -0,0 +1,49 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + if love.update then love.update(dt) end + if love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + love.graphics.present() + end + if love.timer then + if paced then + local budget = 1 / FrameCap.current + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + while true do + local remaining = nextFrame - love.timer.getTime() + if remaining <= 0 then break end + love.timer.sleep(remaining < 0.001 and remaining or 0.001) + end + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.2.25.lua b/tests/data/love_run/v0.2.25.lua new file mode 100644 index 00000000..ff03634e --- /dev/null +++ b/tests/data/love_run/v0.2.25.lua @@ -0,0 +1,76 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + love.graphics.present() + end + if love.timer then + if paced then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + while true do + local remaining = nextFrame - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + love.timer.sleep(0.001) + end + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.2.27.lua b/tests/data/love_run/v0.2.27.lua new file mode 100644 index 00000000..7ec41a41 --- /dev/null +++ b/tests/data/love_run/v0.2.27.lua @@ -0,0 +1,77 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + checkEmergencyQuit(dt) + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + love.graphics.present() + end + if love.timer then + if paced then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + while true do + local remaining = nextFrame - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + love.timer.sleep(0.001) + end + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.2.37.lua b/tests/data/love_run/v0.2.37.lua new file mode 100644 index 00000000..1c3238c8 --- /dev/null +++ b/tests/data/love_run/v0.2.37.lua @@ -0,0 +1,85 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + local RefreshRate = require("src.core.RefreshRate") + local FixedStep = require("src.core.FixedStep") + local VSync = require("src.core.VSync") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + if RefreshRate.sample(dt) then + FixedStep.refreshPeriod = RefreshRate.period() + end + checkEmergencyQuit(dt) + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + elseif cap == FrameCap.DISPLAY and not VSync.isOn() then + cap = FrameCap.DEFAULT + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + love.graphics.present() + end + if love.timer then + if paced and cap ~= FrameCap.DISPLAY then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + while true do + local remaining = nextFrame - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + love.timer.sleep(0.001) + end + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.2.39.lua b/tests/data/love_run/v0.2.39.lua new file mode 100644 index 00000000..9b590aee --- /dev/null +++ b/tests/data/love_run/v0.2.39.lua @@ -0,0 +1,94 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + local RefreshRate = require("src.core.RefreshRate") + local FixedStep = require("src.core.FixedStep") + local VSync = require("src.core.VSync") + local PresentSync = require("src.core.PresentSync") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + if name == "focus" and a then + PresentSync.onDisplayChange() + elseif name == "resize" then + PresentSync.onDisplayChange() + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + RefreshRate.sample(dt) + checkEmergencyQuit(dt) + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + elseif cap == FrameCap.DISPLAY and not VSync.isOn() then + cap = FrameCap.DEFAULT + elseif cap == FrameCap.DISPLAY and PresentSync.needsSoftwareCap() then + cap = FrameCap.DEFAULT + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + PresentSync.waitBeforePresent() + love.graphics.present() + PresentSync.notePresent() + end + PresentSync.applyFixedStepPeriod() + if love.timer then + if paced and cap ~= FrameCap.DISPLAY then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + while true do + local remaining = nextFrame - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + love.timer.sleep(0.001) + end + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.2.43.lua b/tests/data/love_run/v0.2.43.lua new file mode 100644 index 00000000..57e10737 --- /dev/null +++ b/tests/data/love_run/v0.2.43.lua @@ -0,0 +1,106 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + FrameCap.bootHandheld() + local RefreshRate = require("src.core.RefreshRate") + local FixedStep = require("src.core.FixedStep") + local VSync = require("src.core.VSync") + local PresentSync = require("src.core.PresentSync") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local function sleepUntilFrame(deadline) + while true do + local remaining = deadline - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + if remaining > 0.004 then + love.timer.sleep(remaining - 0.002) + else + love.timer.sleep(remaining) + end + end + end + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + if name == "focus" and a then + PresentSync.onDisplayChange() + elseif name == "resize" then + PresentSync.onDisplayChange() + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + RefreshRate.sample(dt) + checkEmergencyQuit(dt) + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + else + local idleCap = idlePresentationCap(idleFor) + if idleCap then cap = idleCap end + end + if cap == FrameCap.DISPLAY and not VSync.isOn() then + cap = FrameCap.DEFAULT + elseif cap == FrameCap.DISPLAY and PresentSync.needsSoftwareCap() then + cap = FrameCap.DEFAULT + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + PresentSync.waitBeforePresent() + love.graphics.present() + PresentSync.notePresent() + end + PresentSync.applyFixedStepPeriod() + if love.timer then + if paced and cap ~= FrameCap.DISPLAY and not PresentSync.hardwarePacesCap(cap) then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + sleepUntilFrame(nextFrame) + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.2.46.lua b/tests/data/love_run/v0.2.46.lua new file mode 100644 index 00000000..350a0ac3 --- /dev/null +++ b/tests/data/love_run/v0.2.46.lua @@ -0,0 +1,106 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + FrameCap.bootPanelSync() + local RefreshRate = require("src.core.RefreshRate") + local FixedStep = require("src.core.FixedStep") + local VSync = require("src.core.VSync") + local PresentSync = require("src.core.PresentSync") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local function sleepUntilFrame(deadline) + while true do + local remaining = deadline - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + if remaining > 0.004 then + love.timer.sleep(remaining - 0.002) + else + love.timer.sleep(remaining) + end + end + end + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + if name == "focus" and a then + PresentSync.onDisplayChange() + elseif name == "resize" then + PresentSync.onDisplayChange() + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + RefreshRate.sample(dt) + checkEmergencyQuit(dt) + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + else + local idleCap = idlePresentationCap(idleFor) + if idleCap then cap = idleCap end + end + if cap == FrameCap.DISPLAY and not VSync.isOn() then + cap = FrameCap.DEFAULT + elseif cap == FrameCap.DISPLAY and PresentSync.needsSoftwareCap() then + cap = FrameCap.DEFAULT + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + PresentSync.waitBeforePresent() + love.graphics.present() + PresentSync.notePresent() + end + PresentSync.applyFixedStepPeriod() + if love.timer then + if paced and cap ~= FrameCap.DISPLAY and not PresentSync.hardwarePacesCap(cap) then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + sleepUntilFrame(nextFrame) + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.2.51.lua b/tests/data/love_run/v0.2.51.lua new file mode 100644 index 00000000..a1fd4b2d --- /dev/null +++ b/tests/data/love_run/v0.2.51.lua @@ -0,0 +1,107 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + _G.POKEPORT_LOOP_PANEL_SYNC = true + FrameCap.bootPanelSync() + local RefreshRate = require("src.core.RefreshRate") + local FixedStep = require("src.core.FixedStep") + local VSync = require("src.core.VSync") + local PresentSync = require("src.core.PresentSync") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local function sleepUntilFrame(deadline) + while true do + local remaining = deadline - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + if remaining > 0.004 then + love.timer.sleep(remaining - 0.002) + else + love.timer.sleep(remaining) + end + end + end + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + if name == "focus" and a then + PresentSync.onDisplayChange() + elseif name == "resize" then + PresentSync.onDisplayChange() + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + RefreshRate.sample(dt) + checkEmergencyQuit(dt) + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + else + local idleCap = idlePresentationCap(idleFor) + if idleCap then cap = idleCap end + end + if cap == FrameCap.DISPLAY and not VSync.isOn() then + cap = FrameCap.DEFAULT + elseif cap == FrameCap.DISPLAY and PresentSync.needsSoftwareCap() then + cap = FrameCap.DEFAULT + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + PresentSync.waitBeforePresent() + love.graphics.present() + PresentSync.notePresent() + end + PresentSync.applyFixedStepPeriod() + if love.timer then + if paced and cap ~= FrameCap.DISPLAY and not PresentSync.hardwarePacesCap(cap) then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + sleepUntilFrame(nextFrame) + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/data/love_run/v0.3.58.lua b/tests/data/love_run/v0.3.58.lua new file mode 100644 index 00000000..f1107e06 --- /dev/null +++ b/tests/data/love_run/v0.3.58.lua @@ -0,0 +1,107 @@ +function love.run() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local FrameCap = require("src.core.FrameCap") + _G.POKEPORT_LOOP_PANEL_SYNC = true + FrameCap.bootPanelSync() + local RefreshRate = require("src.core.RefreshRate") + local FixedStep = require("src.core.FixedStep") + local VSync = require("src.core.VSync") + local PresentSync = require("src.core.PresentSync") + local paced = pacingEnabled() + local nextFrame = love.timer and love.timer.getTime() or 0 + local dt = 0 + local idleFor = 0 + local SLEEP_FLOOR = 0.001 + local function sleepUntilFrame(deadline) + while true do + local remaining = deadline - love.timer.getTime() + if remaining <= SLEEP_FLOOR then break end + if remaining > 0.004 then + love.timer.sleep(remaining - 0.002) + else + love.timer.sleep(remaining) + end + end + end + local WAKE = { + keypressed = true, keyreleased = true, textinput = true, + mousepressed = true, mousereleased = true, mousemoved = true, + wheelmoved = true, touchpressed = true, touchreleased = true, + touchmoved = true, joystickpressed = true, joystickreleased = true, + joystickhat = true, gamepadpressed = true, gamepadreleased = true, + joystickadded = true, joystickremoved = true, filedropped = true, + directorydropped = true, focus = true, visible = true, resize = true, + } + return function() + if love.event then + love.event.pump() + for name, a, b, c, d, e, f in love.event.poll() do + if name == "quit" then + if not love.quit or not love.quit() then + if a ~= "restart" and love.system and love.system.getOS() == "Android" then + os.exit(a or 0) + end + return a or 0 + end + end + if WAKE[name] then + idleFor = 0 + elseif name == "joystickaxis" and type(c) == "number" and math.abs(c) > 0.5 then + idleFor = 0 + end + if name == "focus" and a then + PresentSync.onDisplayChange() + elseif name == "resize" then + PresentSync.onDisplayChange() + end + love.handlers[name](a, b, c, d, e, f) + end + end + if love.timer then dt = love.timer.step() end + idleFor = idleFor + dt + RefreshRate.sample(dt) + checkEmergencyQuit(dt) + if love.update then love.update(dt) end + local visible = not (love.window and love.window.isVisible) + or love.window.isVisible() + local focused = not (love.window and love.window.hasFocus) + or love.window.hasFocus() + local cap = FrameCap.current + if not visible then + cap = 10 + elseif Importer and (not focused or idleFor > 30) then + cap = 15 + else + local idleCap = idlePresentationCap(idleFor) + if idleCap then cap = idleCap end + end + if cap == FrameCap.DISPLAY and not VSync.isOn() then + cap = FrameCap.DEFAULT + elseif cap == FrameCap.DISPLAY and PresentSync.needsSoftwareCap() then + cap = FrameCap.DEFAULT + end + if visible and love.graphics and love.graphics.isActive() then + love.graphics.origin() + love.graphics.clear(love.graphics.getBackgroundColor()) + if love.draw then love.draw() end + PresentSync.waitBeforePresent() + love.graphics.present() + PresentSync.notePresent() + end + PresentSync.applyFixedStepPeriod() + if love.timer then + if paced and cap ~= FrameCap.DISPLAY and not PresentSync.hardwarePacesCap(cap) then + local budget = 1 / cap + nextFrame = nextFrame + budget + local now = love.timer.getTime() + if now - nextFrame > budget then + nextFrame = now + end + sleepUntilFrame(nextFrame) + else + love.timer.sleep(0.001) + end + end + end +end diff --git a/tests/drivers/em_battle_visuals.lua b/tests/drivers/em_battle_visuals.lua index fb4394fb..a2953fb9 100644 --- a/tests/drivers/em_battle_visuals.lua +++ b/tests/drivers/em_battle_visuals.lua @@ -117,7 +117,7 @@ return function(game) U.wait(60) end - result(BattleChrome.isRse(), "Emerald chrome manifest is the rse layout") + result(BattleChrome.layout() == "emerald", "Emerald chrome manifest is the emerald layout") load_map("EM_ROUTE117", 32, 15) set_party() diff --git a/tests/drivers/em_secret_base_poison_gate.lua b/tests/drivers/em_secret_base_poison_gate.lua new file mode 100644 index 00000000..9371436c --- /dev/null +++ b/tests/drivers/em_secret_base_poison_gate.lua @@ -0,0 +1,133 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_secret_base_poison_gate" + +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + print((failures == 0 and "PASS" or "FAIL") .. " em_secret_base_poison_gate failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) + U.wait(10) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not check(game.boot ~= nil, "boot reached") then return finish() end + game:_handleBootAction({ action = "new_game", name = "BRENDAN", gender = 0 }) + U.wait(60) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local MapIds = require("src.core.game3.map_ids") + local Player = require("src.core.game3.player") + local Message = require("src.ui.game3.message") + local Party = require("src.core.game3.party") + local Field = require("src.core.game3.field") + local Space = require("src.core.game3.scripting.space") + local Warp = require("src.core.game3.warp") + local StepEvents = require("src.core.game3.step_events") + local Sem = require("src.core.game3.field_semantics") + + local session = Runtime.getSession() + if not check(session ~= nil, "field session exists") then return finish() end + local C = require("src.core.game3.constants").active(session) + local id = require("src.core.game3.profile").forSession(session).id + local tag = id .. ": " + + for _ = 1, 600 do + if not (Space.vm and Space.vm:isRunning()) and not Message.isOpen() and not Warp.isBusy() then break end + if Message.isOpen() then U.tap(game, "a") end + U.wait(2) + end + + local function settle() + for _ = 1, 300 do + if not (Space.vm and Space.vm:isRunning()) and not Message.isOpen() and not Warp.isBusy() + and not StepEvents.busy() then return end + if Message.isOpen() then U.tap(game, "a") end + U.wait(2) + end + end + + local function poisonParty() + session.party = {} + Party.giveMon(session, C:require("species", "SPECIES_TORCHIC"), 30) + local mon = session.party[1] + mon.status = "PSN" + mon.hp = mon.maxHp or mon.hp + return mon + end + + local function setSteps(n) + session.vars[Sem.var(session, "poisonSteps")] = n + session.poisonSteps = n + end + + local function stepDir(dir) + local sx, sy = Player.cellX, Player.cellY + U.hold(game, dir, 20) + U.wait(10) + settle() + return Player.cellX ~= sx or Player.cellY ~= sy + end + + local okRun, err = pcall(function() + local base = MapIds.forConst("MAP_SECRET_BASE_BLUE_CAVE1", id) + local def = game.data.maps[base] + local w = def and def.warps and def.warps[1] + check(w ~= nil, tag .. "secret base map " .. tostring(base) .. " has an exit warp") + Map.load(nil, game, base, { x = w.x, y = w.y, facing = "up" }) + session.x, session.y, session.facing = w.x, w.y, "up" + Field.unlock() + U.wait(30) + settle() + local cur = Map.currentDef() + check(Map.current == base, tag .. "inside " .. tostring(base)) + check(cur and tonumber(cur.mapType) == 9, tag .. "map header mapType is MAP_TYPE_SECRET_BASE (" + .. tostring(cur and cur.mapType) .. ")") + + local mon = poisonParty() + local hp0 = mon.hp + setSteps(3) + local moved = 0 + if stepDir("up") then moved = moved + 1 end + for i = 1, 7 do + if stepDir(i % 2 == 1 and "up" or "down") then moved = moved + 1 end + end + print("INFO inside steps=" .. moved .. " hp=" .. tostring(mon.hp) .. "/" .. tostring(hp0)) + check(moved >= 6, tag .. "walked " .. moved .. " steps inside the base") + check(Map.current == base, tag .. "still inside the base") + check(mon.hp == hp0, tag .. "HP unchanged inside the secret base (" .. tostring(mon.hp) .. ")") + check(tonumber(session.vars[Sem.var(session, "poisonSteps")]) == 3, tag .. "poison counter held at 3") + U.still(game, DIR .. "/s1_01_" .. id .. "_secret_base_poisoned_full_hp.png") + + local route = MapIds.forConst("MAP_ROUTE103", id) + Map.load(nil, game, route, { x = 10, y = 10, facing = "down" }) + session.x, session.y, session.facing = 10, 10, "down" + Field.unlock() + U.wait(30) + settle() + mon.hp = hp0 + mon.status = "PSN" + setSteps(0) + local out = 0 + local dirs = { "down", "up", "left", "right" } + for i = 1, 16 do + if out >= 8 then break end + if stepDir(dirs[(i - 1) % 4 + 1]) then out = out + 1 end + end + print("INFO outside steps=" .. out .. " hp=" .. tostring(mon.hp) .. "/" .. tostring(hp0)) + check(out >= 8, tag .. "walked " .. out .. " steps on route 103") + check(mon.hp == hp0 - 2, tag .. "HP drops 2 after 8 steps outside (" .. tostring(mon.hp) .. ")") + U.still(game, DIR .. "/s1_02_" .. id .. "_route103_poison_hp_drop.png") + end) + check(okRun, tag .. "no crash " .. tostring(okRun and "" or err)) + return finish() +end diff --git a/tests/drivers/game3_field_poison_whiteout.lua b/tests/drivers/game3_field_poison_whiteout.lua index 2342b1b0..6ed88e06 100644 --- a/tests/drivers/game3_field_poison_whiteout.lua +++ b/tests/drivers/game3_field_poison_whiteout.lua @@ -48,7 +48,7 @@ return function(game) session.money = 3000 Field.setRespawn(2) local loss = math.min(BattleBridge.calcMoneyLossFrlg(session, game.save), 3000) - session.vars[0x4040] = 3 + session.vars[0x4040] = 4 -- pokefirered/src/field_control_avatar.c:718 local startX, startY = Player.cellX, Player.cellY for _, dir in ipairs({ "left", "right", "down", "up" }) do diff --git a/tests/drivers/game3_rs_move_menu_fit_2729.lua b/tests/drivers/game3_rs_move_menu_fit_2729.lua index 8f318cab..c136f084 100644 --- a/tests/drivers/game3_rs_move_menu_fit_2729.lua +++ b/tests/drivers/game3_rs_move_menu_fit_2729.lua @@ -75,9 +75,10 @@ return function(game) local F = require(P.font.module) local drawn local orig = BattleChrome.drawMenuFrames - BattleChrome.drawMenuFrames = function(mode, layout) - drawn = BattleChrome.menuFrameRects(mode, layout) - return orig(mode, layout) + result(BattleChrome.layout() == "rs", "battle chrome layout is rs") + BattleChrome.drawMenuFrames = function(mode) + drawn = BattleChrome.menuFrameRects(mode, BattleChrome.layout()) + return orig(mode) end for _ = 1, 2000 do if drawn then break end diff --git a/tests/drivers/refix2_rs_prize_multichoice.lua b/tests/drivers/refix2_rs_prize_multichoice.lua new file mode 100644 index 00000000..acbac1d4 --- /dev/null +++ b/tests/drivers/refix2_rs_prize_multichoice.lua @@ -0,0 +1,106 @@ +local U = require("tests.drivers.util") +local X = require("tests.drivers.em_xa_util") + +local d = X.new("refix2_rs_prize_multichoice", "/tmp/refix2_rs_prize_multichoice") + +return function(game) + local version = os.getenv("POKEPORT_VERSION") or "sapphire" + local rs = version == "ruby" or version == "sapphire" + local sess = X.newGame(d, game, 0) + if not sess then return d.finish() end + X.settle(game) + U.wait(30) + + local C = require("src.core.game3.constants").of(version) + local Bag = require("src.core.game3.bag") + local MapIds = require("src.core.game3.map_ids") + local Window = require("src.ui.game3.window") + local MenuCursor = require("src.ui.game3.rs.menu_cursor") + local Chrome = require("src.ui.game3.chrome") + local Choice = require("src.ui.game3.choice") + local FrlgFont = require("src.ui.game3.frlg_font") + Bag.add(sess.bag, C:require("items", "ITEM_COIN_CASE"), 1) + Bag.Coins.set(sess, 120) + + local arrows, bars, frames = {}, {}, {} + local origArrow, origBar, origStd = Window.cursorPx, MenuCursor.draw, Chrome.stdFrame + Window.cursorPx = function(x, y, ...) + arrows[#arrows + 1] = { x, y } + return origArrow(x, y, ...) + end + MenuCursor.draw = function(x, y, w, ...) + bars[#bars + 1] = { x, y, w } + return origBar(x, y, w, ...) + end + Chrome.stdFrame = function(l, t, w, h, ...) + frames[#frames + 1] = { l, t, w, h } + return origStd(l, t, w, h, ...) + end + + -- pokeruby/data/maps/MauvilleCity_GameCorner/scripts.inc:121 multichoice 12, 0, 48, 0 + local corner = MapIds.forConst("MAP_MAUVILLE_CITY_GAME_CORNER") + if not d.check(X.goTo(d, game, corner, 14, 3, "up"), "Mauville Game Corner loads") then return d.finish() end + U.wait(20) + U.tap(game, "a") + local opened = X.waitFor(function() + if Choice.isOpen() and Choice.kind == "multi" then return true end + local Message = require("src.ui.game3.message") + if Message.isOpen and Message.isOpen() and U.frame() % 20 == 0 then U.tap(game, "a") end + return false + end, 3000) + if not d.check(opened, "prize clerk script opens multichoice 48") then return d.finish() end + d.check(Choice.left == 13 and Choice.top == 1, string.format("multichoice 12,0 interior at %s,%s", tostring(Choice.left), tostring(Choice.top))) + local L, Tp = Choice.left, Choice.top + arrows, bars, frames = {}, {}, {} + local n = #(Choice.options or {}) + X.waitFor(function() + for _, f in ipairs(frames) do if f[4] == 2 * n then return true end end + return false + end, 600) + U.wait(10) + + local w = 0 + for _, lab in ipairs(Choice.options or {}) do + local tiles = math.floor((FrlgFont.measure(tostring(lab)) + 7) / 8) + if tiles > w then w = tiles end + end + local tx = L + w > 29 and 29 - w or L + d.note(string.format("%d options, widest %d tiles, frame left %d", n, w, tx)) + local fr + for _, f in ipairs(frames) do + d.note(string.format("frame %d,%d %dx%d", f[1], f[2], f[3], f[4])) + if f[4] == 2 * n then fr = f end + end + d.check(fr ~= nil, "multichoice drew a std frame 2*count tall") + if fr then + d.check(fr[1] == tx and fr[2] == Tp and fr[3] == w, + string.format("multichoice frame interior %d,%d %dx%d (pret left+1, top+1, widest label, 2*count)", fr[1], fr[2], fr[3], fr[4])) + end + if rs then + local bar = bars[#bars] + d.check(#arrows == 0, "RS multichoice draws no FRLG arrow glyph") + d.check(bar ~= nil and bar[1] == tx * 8 and bar[2] == Tp * 8 and bar[3] == w * 8, + string.format("RS multichoice highlight bar at (%d, %d) width %d on item 0", tx * 8, Tp * 8, w * 8)) + else + d.check(#bars == 0 and #arrows > 0, "FRLG/Emerald multichoice keeps the arrow glyph") + end + d.still(game, rs and "refix2_01_rs_prize_multichoice_bar_on_item0.png" or "refix2_03_em_prize_multichoice.png") + + U.tap(game, "down") + U.wait(10) + d.check(Choice.cursor == 2, "cursor moved to item 1") + if rs then + X.waitFor(function() return bars[#bars] and bars[#bars][2] == (Tp + 2) * 8 end, 600) + local bar = bars[#bars] + d.check(bar ~= nil and bar[2] == (Tp + 2) * 8, string.format("RS multichoice highlight bar at y %d on item 1", (Tp + 2) * 8)) + end + d.still(game, rs and "refix2_02_rs_prize_multichoice_bar_on_item1.png" or "refix2_04_em_prize_multichoice_item1.png") + + Window.cursorPx, MenuCursor.draw, Chrome.stdFrame = origArrow, origBar, origStd + for _ = 1, 60 do + if not (X.scriptRunning and X.scriptRunning()) then break end + U.tap(game, "b") + U.wait(15) + end + d.finish() +end diff --git a/tests/drivers/s2_coins_box.lua b/tests/drivers/s2_coins_box.lua new file mode 100644 index 00000000..88f9c9d2 --- /dev/null +++ b/tests/drivers/s2_coins_box.lua @@ -0,0 +1,63 @@ +local U = require("tests.drivers.util") +local X = require("tests.drivers.em_xa_util") + +local d = X.new("s2_coins_box", "/tmp/s2_coins_box") + +return function(game) + local version = os.getenv("POKEPORT_VERSION") or "sapphire" + local rs = version == "ruby" or version == "sapphire" + local sess = X.newGame(d, game, 0) + if not sess then return d.finish() end + X.settle(game) + U.wait(30) + + local C = require("src.core.game3.constants").of(version) + local Bag = require("src.core.game3.bag") + local MapIds = require("src.core.game3.map_ids") + local CoinsBox = require("src.ui.game3.coins_box") + local Chrome = require("src.ui.game3.chrome") + Bag.add(sess.bag, C:require("items", "ITEM_COIN_CASE"), 1) + Bag.Coins.set(sess, 120) + + local corner = MapIds.forConst("MAP_MAUVILLE_CITY_GAME_CORNER") + if not d.check(X.goTo(d, game, corner, 14, 3, "up"), "Mauville Game Corner loads") then return d.finish() end + U.wait(20) + U.tap(game, "a") + local shown = X.waitFor(function() + if CoinsBox.isVisible() then return true end + local Message = require("src.ui.game3.message") + if Message.isOpen and Message.isOpen() and U.frame() % 20 == 0 then U.tap(game, "a") end + return false + end, 3000) + if not d.check(shown, "doll prize clerk script runs showcoinsbox") then return d.finish() end + d.check(CoinsBox.x == (rs and 0 or 1) and CoinsBox.y == (rs and 0 or 1), + string.format("script origin is %d,%d", CoinsBox.x, CoinsBox.y)) + + local frames = {} + local origStd = Chrome.stdFrame + Chrome.stdFrame = function(l, t, w, h, ...) + frames[#frames + 1] = { l, t, w, h } + return origStd(l, t, w, h, ...) + end + local box + X.waitFor(function() + for _, f in ipairs(frames) do + if f[3] == 8 and f[4] == 2 then box = f return true end + end + return false + end, 2000) + Chrome.stdFrame = origStd + d.check(box ~= nil, "coins window drew an 8x2 std frame") + if box then + d.note(string.format("coins interior %d,%d", box[1], box[2])) + d.check(box[1] == 1 and box[2] == 1, "coins frame top-left is tile 0,0 (interior at 1,1)") + end + U.wait(30) + d.still(game, rs and "S2_02_rs_coins_box.png" or "S2_04_em_coins_box.png") + for _ = 1, 60 do + if not (X.scriptRunning and X.scriptRunning()) then break end + U.tap(game, "b") + U.wait(15) + end + d.finish() +end diff --git a/tests/drivers/s2_rs_field_yesno.lua b/tests/drivers/s2_rs_field_yesno.lua new file mode 100644 index 00000000..d56f510a --- /dev/null +++ b/tests/drivers/s2_rs_field_yesno.lua @@ -0,0 +1,110 @@ +local U = require("tests.drivers.util") +local X = require("tests.drivers.em_xa_util") + +local d = X.new("s2_rs_field_yesno", "/tmp/s2_rs_field_yesno") + +return function(game) + local version = os.getenv("POKEPORT_VERSION") or "sapphire" + local rs = version == "ruby" or version == "sapphire" + local sess = X.newGame(d, game, 0) + if not sess then return d.finish() end + X.settle(game) + U.wait(30) + + local Window = require("src.ui.game3.window") + local MenuCursor = require("src.ui.game3.rs.menu_cursor") + local Chrome = require("src.ui.game3.chrome") + local Choice = require("src.ui.game3.choice") + local Message = require("src.ui.game3.message") + local arrows, bars, frames = {}, {}, {} + local origArrow, origBar, origStd = Window.cursorPx, MenuCursor.draw, Chrome.stdFrame + Window.cursorPx = function(x, y, ...) + arrows[#arrows + 1] = { x, y } + return origArrow(x, y, ...) + end + MenuCursor.draw = function(x, y, w, ...) + bars[#bars + 1] = { x, y, w } + return origBar(x, y, w, ...) + end + Chrome.stdFrame = function(l, t, w, h, ...) + frames[#frames + 1] = { l, t, w, h } + return origStd(l, t, w, h, ...) + end + + local emerald = version == "emerald" + -- pokeruby/src/scrcmd.c:1277 yesnobox 19, 7 + Message.showStay("Would you like to save\nthe game?") + U.wait(10) + local answered + Choice.yesNo(function(yes) answered = yes end, { left = 19, top = 7 }) + d.check(Choice.fieldLayout() == (rs and "rs" or "frlg"), "field yes/no layout is " .. tostring(Choice.fieldLayout())) + arrows, bars, frames = {}, {}, {} + X.waitFor(function() return #frames > 0 end, 600) + U.wait(10) + local yn + for _, f in ipairs(frames) do if f[4] == 4 then yn = f end end + d.check(yn ~= nil, "script yes/no drew a std frame") + if yn then + d.note(string.format("yes/no interior %d,%d %dx%d", yn[1], yn[2], yn[3], yn[4])) + if rs then + -- pokeruby/src/script_menu.c:765 + d.check(yn[1] == 20 and yn[2] == 8, string.format("RS yesnobox 19,7 interior at tile %d,%d", yn[1], yn[2])) + d.check(yn[3] == 5, "RS yesnobox interior width " .. yn[3]) + else + -- pokeemerald/src/script_menu.c:198, pokefirered/src/script_menu.c:864 + d.check(yn[1] == 21 and yn[2] == 9, string.format("yesnobox 19,7 ignored: interior at tile %d,%d", yn[1], yn[2])) + d.check(yn[3] == (emerald and 5 or 6), "yesnobox interior width " .. yn[3]) + end + end + if rs then + local bar = bars[#bars] + d.check(#arrows == 0, "RS script yes/no draws no FRLG arrow glyph") + d.check(bar ~= nil and bar[1] == 160 and bar[2] == 64 and bar[3] == 40, + "RS script yes/no highlight bar at (160, 64) width 40 on YES") + else + d.check(#bars == 0 and #arrows > 0, "FRLG/Emerald script yes/no keeps the arrow glyph") + end + d.still(game, rs and "S2_05_rs_script_yesno_bar_on_yes.png" or "S2_07_em_script_yesno.png") + U.tap(game, "down") + U.wait(10) + d.check(Choice.cursor == 2, "cursor moved to NO") + if rs then + X.waitFor(function() return bars[#bars] and bars[#bars][2] == 80 end, 600) + local bar = bars[#bars] + d.check(bar ~= nil and bar[2] == 80, "RS script yes/no highlight bar at y 80 on NO") + end + d.still(game, rs and "S2_06_rs_script_yesno_bar_on_no.png" or "S2_08_em_script_yesno_no.png") + U.tap(game, "a") + U.wait(10) + d.check(answered == false, "NO answered false") + if Message.isOpen() then Message.close() end + U.wait(10) + + local StartMenu = require("src.ui.game3.screens").get("start_menu", sess) + StartMenu.show({ session = sess, game = game }) + U.wait(10) + StartMenu._confirmExit = true + StartMenu._confirmCursor = 2 + arrows, bars, frames = {}, {}, {} + X.waitFor(function() return #frames > 1 end, 600) + U.wait(10) + local ex + for _, f in ipairs(frames) do if f[2] == 9 and f[4] == 4 then ex = f end end + d.check(ex ~= nil, "exit confirm drew a yes/no frame") + if ex then d.check(ex[1] == 21 and ex[3] == (rs and 5 or 6), string.format("exit confirm interior %d,%d %dx%d", ex[1], ex[2], ex[3], ex[4])) end + if rs then + local bar = bars[#bars] + d.check(bar ~= nil and bar[1] == 168 and bar[2] == 88 and bar[3] == 40, + "RS exit confirm highlight bar at (168, 88) width 40 on NO") + local arrowInBox = false + for _, a in ipairs(arrows) do if a[2] >= 72 and a[2] <= 104 then arrowInBox = true end end + d.check(not arrowInBox, "RS exit confirm draws no FRLG arrow glyph") + else + d.check(#bars == 0, "Emerald exit confirm draws no RS bar") + end + d.still(game, rs and "S2_09_rs_exit_confirm_bar_on_no.png" or "S2_10_em_exit_confirm.png") + + Window.cursorPx, MenuCursor.draw, Chrome.stdFrame = origArrow, origBar, origStd + StartMenu.close(true) + d.finish() +end diff --git a/tests/drivers/s2_save_window.lua b/tests/drivers/s2_save_window.lua new file mode 100644 index 00000000..848fcc64 --- /dev/null +++ b/tests/drivers/s2_save_window.lua @@ -0,0 +1,69 @@ +local U = require("tests.drivers.util") +local X = require("tests.drivers.em_xa_util") + +local d = X.new("s2_save_window", "/tmp/s2_save_window") + +return function(game) + local version = os.getenv("POKEPORT_VERSION") or "sapphire" + local rs = version == "ruby" or version == "sapphire" + local sess = X.newGame(d, game, 0) + if not sess then return d.finish() end + X.settle(game) + U.wait(30) + + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local dexFlag = require("src.ui.game3.screens").flags(sess).IDS.SYS_POKEDEX_GET + Flags.setFlag(Space.store, nil, dexFlag, true) + + local Chrome = require("src.ui.game3.chrome") + local Window = require("src.ui.game3.window") + local MenuCursor = require("src.ui.game3.rs.menu_cursor") + local SaveMenu = require("src.ui.game3.save_menu") + local frames, arrows, bars = {}, {}, {} + local origStd = Chrome.stdFrame + Chrome.stdFrame = function(l, t, w, h, ...) + frames[#frames + 1] = { l, t, w, h } + return origStd(l, t, w, h, ...) + end + local origArrow, origBar = Window.cursorPx, MenuCursor.draw + Window.cursorPx = function(x, y, ...) + arrows[#arrows + 1] = { x, y } + return origArrow(x, y, ...) + end + MenuCursor.draw = function(x, y, w, ...) + bars[#bars + 1] = { x, y, w } + return origBar(x, y, w, ...) + end + + SaveMenu.show({ session = sess, game = game }) + U.wait(20) + frames, arrows, bars = {}, {}, {} + X.waitFor(function() return #frames > 0 end, 2000) + if rs then + -- pokeruby/src/start_menu.c:699, pokeruby/src/menu.c:721, :750 + local bar = bars[#bars] + d.check(#arrows == 0, "RS save Yes/No draws no FRLG arrow glyph") + d.check(bar ~= nil and bar[1] == 168 and bar[2] == 72 and bar[3] == 40, + "RS save Yes/No highlight bar at (168, 72) width 40 on YES") + else + d.check(#bars == 0 and #arrows > 0, "Emerald save Yes/No keeps the arrow glyph") + end + d.check(SaveMenu._layout == (rs and "rs" or "rse"), "save menu layout is " .. tostring(SaveMenu._layout)) + local stats = frames[1] + d.check(stats ~= nil, "save stats window drew a std frame") + if stats then + d.note(string.format("stats frame interior %d,%d %dx%d", stats[1], stats[2], stats[3], stats[4])) + d.check(stats[1] == 1 and stats[2] == 1, "stats window interior starts at tile 1,1") + local want = rs and 12 or 14 + d.check(stats[3] == want, string.format("stats window interior is %d tiles wide (frame right tile %d)", want, want + 1)) + d.check(stats[4] == 10, "stats window with the dex is 10 tiles tall") + end + d.still(game, (rs and "S2_01_rs_save_stats_window.png" or "S2_03_em_save_stats_window.png")) + Chrome.stdFrame = origStd + Window.cursorPx, MenuCursor.draw = origArrow, origBar + SaveMenu.cancel() + U.wait(20) + d.check(not SaveMenu.isOpen(), "save dialog closes on B") + d.finish() +end diff --git a/tests/drivers/s7_battle_yesno_frame.lua b/tests/drivers/s7_battle_yesno_frame.lua new file mode 100644 index 00000000..c9d85078 --- /dev/null +++ b/tests/drivers/s7_battle_yesno_frame.lua @@ -0,0 +1,115 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or ".bazinga/BSA/10-07-26-01-sidebugs/shots/s7_battle_yesno_frame" + +return function(game) + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + end + local function finish() love.event.quit(fails == 0 and 0 or 1) end + + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "TAI", gender = 0 }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local BattleText = require("src.core.game3.battle.battle_text") + local Choice = require("src.ui.game3.choice") + local BattleChrome = require("src.ui.game3.battle_chrome") + local version = os.getenv("POKEPORT_VERSION") or "emerald" + local C = require("src.core.game3.constants").of(version) + local session = Runtime.getSession() + result(session ~= nil, "field session exists") + if not session then finish() return end + local function sp(name) return C.species.byName["SPECIES_" .. name] end + + session.party = {} + Party.giveMon(session, sp("TREECKO"), 11, "TREECKO") + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = sp("WURMPLE"), level = 3 }, {}) + result(ok == true, "battle started " .. tostring(err or "")) + if not ok then finish() return end + local lastTap, f = 0, 0 + local function at_command() + return Battle.isActive() and Battle._phase == "command" and Ui._mode == "menu" + end + for _ = 1, 6000 do + if at_command() then break end + f = f + 1 + if Ui.dialogPending and Ui.dialogPending() and f - lastTap >= 14 then + lastTap = f + U.tap(game, "a") + else + U.wait(1) + end + end + result(at_command(), "reached command menu") + if not at_command() then finish() return end + U.wait(30) + + local layout = BattleChrome.layout() + local expect = ({ frlg = "frlg", emerald = "emerald", rs = "rs" })[layout] + print("layout " .. tostring(layout)) + result((version == "firered" or version == "leafgreen") == (layout == "frlg"), "battle chrome layout matches version (" .. tostring(layout) .. ")") + + Ui._mode = "none" + Ui.askYesNo(BattleText.get("STRINGID_GIVENICKNAMECAPTURED", { opponentMon1 = "WURMPLE" }), function() end) + for _ = 1, 600 do + if Choice.isOpen() and Choice.style == "battle" then break end + Ui.pump() + U.wait(1) + end + result(Choice.isOpen() and Choice.style == "battle", "battle yes/no open") + U.wait(20) + U.tap(game, "down") + U.wait(10) + result(Choice.cursor == 2, "cursor on No") + + local withBox = DIR .. "/s7_01_" .. version .. "_yesno_cursor_on_no.png" + local noBox = DIR .. "/s7_00_" .. version .. "_same_frame_without_yesno.png" + U.still(game, withBox) + local wasActive = Choice.active + Choice.active = false + U.still(game, noBox) + Choice.active = wasActive + + local a = love.image.newImageData(love.filesystem.newFileData(assert(io.open(withBox, "rb")):read("*a"), "a.png")) + local b = love.image.newImageData(love.filesystem.newFileData(assert(io.open(noBox, "rb")):read("*a"), "b.png")) + local sx = math.floor(math.min(a:getWidth() / 240, a:getHeight() / 160)) + local ox, oy = (a:getWidth() - 240 * sx) / 2, (a:getHeight() - 160 * sx) / 2 + local minX, maxX, minY, maxY = 999, -1, 999, -1 + for y = 56, 119 do + for x = 0, 239 do + local px, py = ox + math.floor((x + 0.5) * sx), oy + math.floor((y + 0.5) * sx) + local r1, g1, b1 = a:getPixel(px, py) + local r2, g2, b2 = b:getPixel(px, py) + if math.abs(r1 - r2) + math.abs(g1 - g2) + math.abs(b1 - b2) > 0.02 then + if x < minX then minX = x end + if x > maxX then maxX = x end + if y < minY then minY = y end + if y > maxY then maxY = y end + end + end + end + local wantLeft = (expect == "frlg") and 184 or 192 + result(math.floor(minX / 8) == wantLeft / 8, string.format("frame left edge in tile %d (x=%d) got x=%d", wantLeft / 8, wantLeft, minX)) + result(math.floor(maxX / 8) == 29, "frame right edge in tile 29 got x=" .. maxX) + result(math.floor(minY / 8) == 8 and math.floor(maxY / 8) == 13, string.format("frame rows 8..13 got y %d..%d", minY, maxY)) + if expect == "rs" then + local r, g, bb = a:getPixel(ox + math.floor(204 * sx), oy + math.floor(88 * sx)) + local r0, g0, b0 = a:getPixel(ox + math.floor(204 * sx), oy + math.floor(72 * sx)) + print(string.format("rs cursor sample no-row %.2f,%.2f,%.2f yes-row %.2f,%.2f,%.2f", r, g, bb, r0, g0, b0)) + end + + Choice.reset() + Battle.abort("run") + U.wait(30) + finish() +end diff --git a/tests/drivers/save_editor_map_points_s8.lua b/tests/drivers/save_editor_map_points_s8.lua new file mode 100644 index 00000000..b063c47e --- /dev/null +++ b/tests/drivers/save_editor_map_points_s8.lua @@ -0,0 +1,184 @@ +local U = require("tests.drivers.util") +return function(game) + U.wait(20) + package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" + local App, path, tmp + local dim, safe, osName, getTime = love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS, + love.timer.getTime + local failures = 0 + local function pass(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + end + local clock = 1000 + love.timer.getTime = function() return clock end + local ok, err = xpcall(function() + App = require("tools.save-editor.App") + local Gen, Kit = require("Gen"), require("Kit") + local MapBrowser = require("MapBrowser") + local Serializer = require("src.core.SaveSerializer") + local version = require("src.core.GameVersion").get() + local dir = assert(os.getenv("POKEPORT_SHOT_DIR")) + tmp = os.tmpname() + path = tmp .. "-map-s8.lua" + local file = assert(io.open(path, "wb")) + file:write(Serializer.encode(Gen.newGame(version))); file:close() + App.load(path, { version = version, embedded = true }) + local S = App.getState() + assert(S.save and not S.loadError and not S.missingCache, S.status) + local generation = Gen.ofState(S) + local maps = Gen.maps(S.data) + local mapId + for _, id in ipairs({ "PALLET_TOWN", "NEW_BARK_TOWN", "FR_PALLET_TOWN", "LITTLEROOT_TOWN" }) do + if maps[id] then mapId = id; break end + end + if not mapId then + local ids = {} + for id in pairs(maps) do if id:find("TOWN", 1, true) then ids[#ids + 1] = id end end + table.sort(ids) + mapId = assert(ids[1], "no town map") + end + + local W, H, platform = 390, 844, "Android" + local audit + local function render(name) + love.graphics.getDimensions = function() return W, H end + love.window.getSafeArea = function() return 0, 0, W, H end + love.system.getOS = function() return platform end + local canvas = love.graphics.newCanvas(W, H, { msaa = 0 }) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setShader() + require("src.render.GameViewport").reset() + love.graphics.clear(0, 0, 0, 1) + Kit.audit = {} + App.draw() + audit = Kit.audit + Kit.audit = nil + love.graphics.setCanvas() + love.graphics.pop() + if name then + local pixels = canvas:newImageData() + local bytes = pixels:encode("png"):getString() + pixels:release() + local out = assert(io.open(dir .. "/" .. version .. "-" .. name .. ".png", "wb")) + out:write(bytes); out:close() + end + canvas:release() + love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName + end + local function control(label) + for _, c in ipairs(audit or {}) do + if c.class == "control" and c.label == label then return c end + end + end + local function tap(x, y) + App.mousepressed(x, y, 1) + render() + end + local labels = { "Player", generation == 2 and "Spawn" or "Heal" } + if generation == 1 then labels[3] = "Outdoor" end + + local function pickCell(map) + local r = S._mapViewRect + local hc = map.heightCells or map.height * 2 + local wc = map.widthCells or map.width * 2 + for cy = math.floor(hc * 0.75), 1, -1 do + for cx = 2, wc - 2 do + local sx = r.x + ((cx + 0.5) * 16 - S.mapCamX) * S.mapZoom + local sy = r.y + ((cy + 0.5) * 16 - S.mapCamY) * S.mapZoom + if not map:warpAtCell(cx, cy) and sx > r.x + 4 and sx < r.x + r.w - 4 and sy > r.y + 4 + and sy < r.y + r.h - 4 and not MapBrowser.insideOverlay(S, sx, sy) then + return { cx = cx, cy = cy, x = sx, y = sy } + end + end + end + end + + local startMap, startX, startY = Gen.playerMap(S.save) + local function view(tag, shotPrefix) + Gen.setPlayerHere(S.save, startMap, startX, startY) + S.save.lastHeal, S.save.lastOutdoor, S.save.spawn = nil, nil, nil + S.save.healMap, S.save.healX, S.save.healY = nil, nil, nil + S.tab, S.navPopup, S.editPopup = "map", nil, nil + MapBrowser.select(S, mapId) + render(); render() + for _, label in ipairs(labels) do + local c = control(label) + local r = S._mapViewRect + pass(c ~= nil and r ~= nil and c.x >= r.x and c.y >= r.y and c.x + c.w <= r.x + r.w and c.y + c.h <= r.y + r.h + and c.h >= Kit.tapMin(), version .. " " .. tag .. " " .. label .. " button sits on the map at tap size") + end + pass(control("Set here") == nil, version .. " " .. tag .. " has no separate Set here card") + clock = clock + 10 + local first = control(labels[2]) + tap(first.x + first.w / 2, first.y + first.h / 2) + clock = clock + 1 + render(shotPrefix .. "_01_no_cell_reason") + pass(S.toast and S.toast.kind == "info" and S.toast.text:find("Tap a cell first", 1, true) ~= nil + and S.mapClickCell == nil, version .. " " .. tag .. " disabled button explains itself and selects nothing") + + local _, map = MapBrowser.preview(S) + local pick = assert(pickCell(map), "no free cell") + tap(pick.x, pick.y) + pass(S.mapClickCell and S.mapClickCell.cx == pick.cx and S.mapClickCell.cy == pick.cy, + version .. " " .. tag .. " tap selects a map cell") + for i, label in ipairs(labels) do + render() + local c = control(label) + clock = clock + 10 + tap(c.x + c.w / 2, c.y + c.h / 2) + clock = clock + 1 + render(("%s_%02d_%s_toast"):format(shotPrefix, i + 1, label:lower())) + pass(S.toast and S.toast.kind == "ok", version .. " " .. tag .. " " .. label .. " shows a green toast: " + .. tostring(S.toast and S.toast.text)) + pass(S.mapClickCell and S.mapClickCell.cx == pick.cx and S.mapClickCell.cy == pick.cy, + version .. " " .. tag .. " " .. label .. " tap does not fall through to the map") + end + return pick + end + + local function verify(tag, pick) + assert(App.save(), "save failed") + local f = assert(io.open(path, "rb")) + local saved = assert(Serializer.decode(f:read("*a"))) + f:close() + local pm, px, py = Gen.playerMap(saved) + pass(pm == mapId and px == pick.cx and py == pick.cy, + ("%s %s exported player at %s (%d,%d)"):format(version, tag, tostring(pm), px or -1, py or -1)) + if generation == 3 then + pass(saved.healMap == mapId and saved.healX == pick.cx and saved.healY == pick.cy, + ("%s %s exported heal location %s (%s,%s)"):format(version, tag, tostring(saved.healMap), + tostring(saved.healX), tostring(saved.healY))) + elseif generation == 2 then + pass(saved.spawn == mapId, ("%s %s exported spawn %s"):format(version, tag, tostring(saved.spawn))) + else + local heal, out = saved.lastHeal, saved.lastOutdoor + pass(heal and heal.map == mapId and heal.x == pick.cx and heal.y == pick.cy, + ("%s %s exported lastHeal %s"):format(version, tag, heal and heal.map or "nil")) + pass(out and out.id == mapId and out.x == pick.cx and out.y == pick.cy, + ("%s %s exported lastOutdoor %s"):format(version, tag, out and out.id or "nil")) + end + end + + S.mapSection, S.mapFocused = "view", false + verify("phone", view("phone", "s8_phone")) + S.mapFocused = true + view("phone focused", "s8_phone_focused") + S.mapFocused = false + W, H, platform = 1360, 860, "OS X" + verify("desktop", view("desktop", "s8_desktop")) + end, debug.traceback) + if App then App.unload() end + love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName + love.timer.getTime = getTime + if path then + os.remove(path) + for _, backup in ipairs(require("tests.fs_io").globPrefix(path .. ".bak-")) do os.remove(backup) end + end + if tmp then os.remove(tmp) end + if not ok then print("FAIL save_editor_map_points_s8: " .. tostring(err)) end + love.event.quit((ok and failures == 0) and 0 or 1) +end diff --git a/tests/drivers/save_editor_mobile_redesign.lua b/tests/drivers/save_editor_mobile_redesign.lua index 3a2c4e0c..41fb95e6 100644 --- a/tests/drivers/save_editor_mobile_redesign.lua +++ b/tests/drivers/save_editor_mobile_redesign.lua @@ -205,7 +205,7 @@ return function(game) shot("selected-events", 390, 844) end S.tab = "map" - for _, section in ipairs({ "maps", "view", "spawn" }) do + for _, section in ipairs({ "maps", "view" }) do S.mapSection = section shot("map-" .. section, 360, 640) shot("map-" .. section .. "-landscape", 640, 360) diff --git a/tests/drivers/save_editor_toast_s6.lua b/tests/drivers/save_editor_toast_s6.lua new file mode 100644 index 00000000..0baa292e --- /dev/null +++ b/tests/drivers/save_editor_toast_s6.lua @@ -0,0 +1,172 @@ +local U = require("tests.drivers.util") +return function(game) + U.wait(20) + package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" + local App, path, tmp + local dim, safe, osName, getTime = love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS, + love.timer.getTime + local failures = 0 + local function pass(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + end + local clock = 1000 + love.timer.getTime = function() return clock end + local ok, err = xpcall(function() + App = require("tools.save-editor.App") + local Gen, Ops, Kit = require("Gen"), require("Ops"), require("Kit") + local ItemPicker = require("ItemPicker") + local Serializer = require("src.core.SaveSerializer") + local version = require("src.core.GameVersion").get() + local dir = assert(os.getenv("POKEPORT_SHOT_DIR")) + tmp = os.tmpname() + path = tmp .. "-toast-s6.lua" + local file = assert(io.open(path, "wb")) + file:write(Serializer.encode(Gen.newGame(version))); file:close() + App.load(path, { version = version, embedded = true }) + local S = App.getState() + assert(S.save and not S.loadError and not S.missingCache, S.status) + Ops.partyAdd(S) + Ops.selectParty(S, 1) + local W, H, platform = 390, 844, "Android" + local function render(draw, name) + love.graphics.getDimensions = function() return W, H end + love.window.getSafeArea = function() return 0, 0, W, H end + love.system.getOS = function() return platform end + local canvas = love.graphics.newCanvas(W, H, { msaa = 0 }) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setShader() + require("src.render.GameViewport").reset() + love.graphics.clear(0, 0, 0, 1) + draw() + love.graphics.setCanvas() + love.graphics.pop() + if name then + local pixels = canvas:newImageData() + local bytes = pixels:encode("png"):getString() + pixels:release() + local out = assert(io.open(dir .. "/" .. version .. "-" .. name .. ".png", "wb")) + out:write(bytes); out:close() + end + canvas:release() + love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName + end + local function frame(name) render(App.draw, name) end + local function settle(name) + frame() + clock = clock + 1 + frame(name) + end + local function placed(label) + local r = S.toast and S.toast.rect + local statusH = (Kit.desktop and 28 or 38) * Kit.scale + pass(r ~= nil and r[1] >= 0 and r[1] + r[3] <= W and r[2] >= 0 and r[2] + r[4] <= H - statusH, + version .. " " .. label .. " toast is on screen above the status bar") + end + + S.tab, S.monSection, S.mobileInspector = "party", "main", false + Ops.setNickname(S, S.save.party[1], "TOASTY") + settle("s6_01_ok_phone") + pass(S.toast and S.toast.kind == "ok", version .. " phone nickname edit shows a green toast") + placed("phone ok") + + while #S.save.party < 6 do Ops.partyAdd(S) end + clock = clock + 10 + Ops.partyAdd(S) + settle("s6_02_error_phone") + pass(S.toast and S.toast.kind == "error" and S.toast.text == S.status, + version .. " phone party-full refusal shows a red toast") + placed("phone error") + + clock = clock + 10 + Ops.openItemPicker(S, Kit, "bag") + frame(); frame() + local item = assert(Ops.itemSearch(S, "potion")[1]) + ItemPicker.commit(S, Kit, item) + settle("s6_03_ok_over_item_picker_phone") + pass(S.itemPicker ~= nil and S.toast and S.toast.kind == "ok", + version .. " item add toasts over the open picker") + placed("phone picker") + Ops.closeItemPicker(S, Kit) + + clock = clock + 10 + Ops.say(S, "Party is full (6/6)") + S.mobileInspector, S.monSection, S.inspectorScroll = true, "main", 0 + Kit.focus = "mon-nickname" + frame() + clock = clock + 1 + Kit.focus = "mon-nickname" + frame("s6_04_error_clear_of_focused_inspector_phone") + local r, fr = S.toast and S.toast.rect, Kit.focusRect + local rects, n = Kit.trackedControls() + local covered = 0 + for i = 1, n do + local c = rects[i] + if r and c[1] < r[1] + r[3] and c[1] + c[3] > r[1] and c[2] < r[2] + r[4] and c[2] + c[4] > r[2] then + covered = covered + 1 + end + end + pass(r ~= nil and fr ~= nil and n > 0 and covered == 0 and r[2] >= 0 and r[2] + r[4] <= H, + version .. " focused phone field keeps the toast clear of every control (" .. covered .. " covered)") + Kit.blur() + S.mobileInspector = false + frame() + r = S.toast.rect + local selected = S.selectedParty + App.mousepressed(r[1] + r[3] / 2, r[2] + r[4] / 2, 1) + frame() + pass(S.toast == nil and S.selectedParty == selected, version .. " tapping the toast dismisses it and nothing under it") + + W, H, platform = 1360, 860, "OS X" + clock = clock + 10 + Ops.arm(S, "party-remove-1", "Click Remove again to release slot 1") + settle("s6_05_warn_arm_desktop") + pass(S.toast and S.toast.kind == "warn", version .. " desktop arm shows a yellow toast") + placed("desktop warn") + clock = clock + 10 + Ops.setNickname(S, S.save.party[1], "SHINY") + settle("s6_06_ok_desktop") + pass(S.toast and S.toast.kind == "ok", version .. " desktop edit shows a green toast") + placed("desktop ok") + clock = clock + 10 + Ops.partyAdd(S) + settle("s6_07_error_desktop") + pass(S.toast and S.toast.kind == "error", version .. " desktop refusal shows a red toast") + placed("desktop error") + + App.unload() + App = nil + + local Importer = require("src.import.RomImporter") + local Store = require("src.box.Store") + local View = require("src.import.LauncherView") + local imp = Importer.new(function() end, { launcher = true }) + local service = { state = Store.new() } + service.read = function(_, source) return { version = source.version, party = {}, boxes = {} }, nil, source.path end + imp.tab = "box" + imp._boxState = { service = service, sources = { + { version = "red", label = "Red", path = "first", slotId = "slot1" } }, + sourceIndex = 1, box = 1, pcBox = 1, query = "", sort = "slot", pcRows = {}, + selectedBox = {}, selectedPC = {}, pageBox = 1, pagePC = 1, view = "box", multi = true } + W, H, platform = 1024, 768, "OS X" + local function launcher(name) render(function() View.draw(imp) end, name) end + launcher(); launcher() + imp._boxState.notice, imp._boxState.noticeKind = "That bag pocket is full.", "error" + launcher(); clock = clock + 1; launcher("s6_08_box_error_toast_desktop") + local box = imp._toasts and imp._toasts.box and imp._toasts.box.toast + pass(box ~= nil and box.kind == "error" and box.rect ~= nil, "BOX error toast still draws through BoxUI") + end, debug.traceback) + if App then App.unload() end + love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName + love.timer.getTime = getTime + if path then + os.remove(path) + for _, backup in ipairs(require("tests.fs_io").globPrefix(path .. ".bak-")) do os.remove(backup) end + end + if tmp then os.remove(tmp) end + if not ok then print("FAIL save_editor_toast_s6: " .. tostring(err)) end + love.event.quit((ok and failures == 0) and 0 or 1) +end diff --git a/tests/drivers/save_editor_unown_form_2726.lua b/tests/drivers/save_editor_unown_form_2726.lua index b0782a31..0bd74fd8 100644 --- a/tests/drivers/save_editor_unown_form_2726.lua +++ b/tests/drivers/save_editor_unown_form_2726.lua @@ -3,7 +3,9 @@ return function(game) U.wait(20) package.path = package.path .. ";./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" local App, Paint, Kit, path, tmp, oldPaint - local dim, safe, osName + local dim, safe, osName, getTime = nil, nil, nil, love.timer.getTime + local clock = 1000 + love.timer.getTime = function() return clock end local failures = 0 local function pass(ok, label) print((ok and "PASS " or "FAIL ") .. label) @@ -38,7 +40,9 @@ return function(game) end S.save.party = { mon } Ops.selectParty(S, 1) - assert(Ops.setUnownForm(S, mon, g == 3 and 5 or 6), "seed form F") + local seed = g == 3 and 5 or 6 + if Ops.unownForm(S, mon) == seed then assert(Ops.setUnownForm(S, mon, seed + 1), "seed form G") end + assert(Ops.setUnownForm(S, mon, seed), "seed form F") S.dirty = false local W, H, platform = 390, 844, "Android" local function frame(name) @@ -84,7 +88,9 @@ return function(game) frame() end + local Toast = require("src.ui.kit.Toast") S.tab, S.monSection, S.mobileInspector = "party", "main", false + Toast.clear(S) frame(); frame("2726_01_roster_unown_f_phone") pass(letterName() == "F", version .. " roster seeded with UNOWN F") @@ -109,6 +115,7 @@ return function(game) pass(current() == target, version .. " chooser sets the form to " .. letterName()) pass(S.dirty, version .. " form edit marks the save dirty") frame() + clock = clock + 1 frame("2726_04_form_set_" .. letterName():gsub("%?", "qmark") .. "_phone") pass(History.undo(S) and letterName() == "F", version .. " undo restores F") pass(History.redo(S) and current() == target, version .. " redo reapplies") @@ -116,6 +123,7 @@ return function(game) W, H, platform = 1100, 720, "Linux" S.inspectorScroll = 0 + Toast.clear(S) frame(); frame() for _ = 1, 40 do r = controls["choice-form"] @@ -136,6 +144,7 @@ return function(game) if App then App.unload() end if Paint and oldPaint then Paint.draw = oldPaint end if dim then love.graphics.getDimensions, love.window.getSafeArea, love.system.getOS = dim, safe, osName end + love.timer.getTime = getTime if path then os.remove(path) for _, backup in ipairs(require("tests.fs_io").globPrefix(path .. ".bak-")) do os.remove(backup) end diff --git a/tests/emerald_battle_visuals_test.lua b/tests/emerald_battle_visuals_test.lua index 3ab566da..94d0eef6 100644 --- a/tests/emerald_battle_visuals_test.lua +++ b/tests/emerald_battle_visuals_test.lua @@ -38,7 +38,7 @@ check(differ > 100, "Emerald front rows differ from FRLG (" .. differ .. ")") local BattleChrome = require("src.ui.game3.battle_chrome") BattleChrome._manifest = manifest -check(BattleChrome.isRse(), "chrome layout is rse") +check(BattleChrome.layout() == "emerald", "chrome layout is emerald") local msg = BattleChrome.window(BattleChrome.WIN.MSG) eq(msg.x, 16, "B_WIN_MSG left") eq(msg.y, 120, "B_WIN_MSG top") diff --git a/tests/engine/box_ui_test.lua b/tests/engine/box_ui_test.lua index 3e2e0bdd..04fd262c 100644 --- a/tests/engine/box_ui_test.lua +++ b/tests/engine/box_ui_test.lua @@ -120,16 +120,21 @@ T.check(imp._boxPrompt ~= nil and not imp._boxPopup, "rename opens the existing keyboardChoice(imp, "escape") imp._boxState.notice, imp._boxState.noticeKind = "That bag pocket is full.", "error"; settle(imp) -T.eq(imp._boxToast and imp._boxToast.kind, "error", "a failed operation shows a red toast") +local function boxToast() return imp._toasts and imp._toasts.box and imp._toasts.box.toast end +T.eq(boxToast() and boxToast().kind, "error", "a failed operation shows a red toast") T.eq(imp._boxState.notice, nil, "notices no longer print inline") T.check(controls["box-toast"] ~= nil, "the toast is drawn and tappable") local toastRect = controls["box-toast"] draw(imp, { x = toastRect.x + toastRect.w / 2, y = toastRect.y + toastRect.h / 2 }); settle(imp) -T.eq(imp._boxToast, nil, "tapping the toast dismisses it") +T.eq(boxToast(), nil, "tapping the toast dismisses it") imp._boxState.notice, imp._boxState.noticeKind = "Pokémon deposited.", "ok"; settle(imp) -T.eq(imp._boxToast.kind, "ok", "a success shows a green toast") +T.eq(boxToast().kind, "ok", "a success shows a green toast") clock = clock + 10; settle(imp) -T.eq(imp._boxToast, nil, "a success toast times out") +T.eq(boxToast(), nil, "a success toast times out") +UI.toast(imp, "Export failed", "error", nil, "red"); settle(imp) +T.eq(controls["box-toast"], nil, "another tab's toast is not drawn on the box tab") +T.eq(UI.currentToast(imp), nil, "another tab's toast does not occlude the box tab") +imp._toasts.red = nil local changes = 0 UI.open(imp, "Pick", { { id = 1, label = "One" } }, nil, function() changes = changes + 1 end) diff --git a/tests/engine/game3_battle_yesno_frame_s7.lua b/tests/engine/game3_battle_yesno_frame_s7.lua new file mode 100644 index 00000000..f8cd3a12 --- /dev/null +++ b/tests/engine/game3_battle_yesno_frame_s7.lua @@ -0,0 +1,61 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +package.loaded["src.core.game3.rom_text"] = { + plain = function(key) if key == "gText_BattleYesNoChoice" then return "Yes\nNo" end return key end, +} + +local log +package.loaded["src.ui.game3.window"] = { + template = function(l, t, w, h) return { left = l, top = t, width = w, height = h } end, + userFrame = function(tpl) log.frame = { tpl.left - 1, tpl.top - 1, tpl.left + tpl.width, tpl.top + tpl.height } end, + stdFrame = function() end, + cursorPx = function(x, y) log.arrow = { x, y } end, + printPx = function(s, x, y) log.text[#log.text + 1] = { s, x, y } end, +} +package.loaded["src.ui.game3.rs.menu_cursor"] = { + draw = function(x, y, w) log.rsCursor = { x, y, w } end, +} +local layout = "frlg" +package.loaded["src.ui.game3.battle_chrome"] = { layout = function() return layout end } +package.loaded["src.core.game3.runtime"] = { getSession = function() return { options = { frameType = 0 } } end } + +local Choice = require("src.ui.game3.choice") + +local function run(l, cursor) + layout = l + log = { text = {} } + Choice.yesNo(function() end, { left = 24, top = 9, style = "battle" }) + Choice.cursor = cursor or 1 + Choice.draw() + return log +end + +local function rect(r) return table.concat(r or {}, ",") end + +local fr = run("frlg") +eq(rect(fr.frame), "23,8,29,13", "frlg frame tiles 23..29 x 8..13") +eq(rect(fr.arrow), "192,72", "frlg arrow at tile 24 row 9") +eq(fr.text[1] and fr.text[1][2], 200, "frlg labels at tile 25") +eq(fr.text[1] and fr.text[1][3], 74, "frlg label y +2") +eq(fr.rsCursor, nil, "frlg has no RS cursor") + +local em = run("emerald", 2) +eq(rect(em.frame), "24,8,29,13", "emerald frame tiles 24..29 x 8..13") +eq(rect(em.arrow), "200,88", "emerald arrow at tile 25 row 11") +eq(em.text[1] and em.text[1][2], 208, "emerald labels at tile 26") +eq(em.text[1] and em.text[1][3], 73, "emerald label y +1") +eq(em.rsCursor, nil, "emerald has no RS cursor") + +local rs = run("rs", 2) +eq(rect(rs.frame), "24,8,29,13", "rs frame tiles 24..29 x 8..13") +eq(rs.arrow, nil, "rs draws no arrow glyph") +eq(rect(rs.rsCursor), "200,88,32", "rs highlight cursor at (200, 72+16*pos) width 32") +eq(rs.text[1] and rs.text[1][2], 200, "rs labels at tile 25") +eq(rs.text[2] and rs.text[2][3], 88, "rs second label at y 88") + +T.finish("game3 battle yes/no frame per layout") diff --git a/tests/engine/game3_field_poison_step_gate_test.lua b/tests/engine/game3_field_poison_step_gate_test.lua new file mode 100644 index 00000000..a34f7f28 --- /dev/null +++ b/tests/engine/game3_field_poison_step_gate_test.lua @@ -0,0 +1,99 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local noop = function() end +local family = "rse" +local mapType = 1 +local forced = false + +package.loaded["src.core.game3.pokemon"] = { + FRIENDSHIP_EVENT_WALKING = 0, + FRIENDSHIP_EVENT_FAINT_OUTSIDE_BATTLE = 1, + adjustFriendship = noop, + currentMapSec = function() return 0 end, + displayMonName = function(mon) return mon.name or "MON" end, +} +package.loaded["src.core.game3.rom_text"] = { box = function(key) return key end } +package.loaded["src.core.game3.field_semantics"] = { + var = function(_, name) return name end, + getVar = function() return 0 end, + setVar = noop, +} +package.loaded["src.core.game3.field_modules"] = { enabled = function() return false end } +package.loaded["src.core.game3.profile"] = { + family = function() return family end, + forSession = function() return { field = {} } end, +} +package.loaded["src.core.game3.capabilities"] = { gate = function() return false end } +package.loaded["src.core.game3.rs.rematch"] = { enabled = function() return false end } +package.loaded["src.core.game3.faraway_island"] = { updateStepCounter = noop } +package.loaded["src.core.game3.mystery_gift"] = { incrementNewsStepCounter = noop } +package.loaded["src.core.game3.rse.init"] = { call = noop, isRse = function() return family == "rse" end } +package.loaded["src.core.game3.player"] = { cellX = 0, cellY = 0 } +package.loaded["src.core.game3.forced_movement"] = { + isForced = function() return forced end, + isForcedMovementTile = function() return false end, +} +package.loaded["src.core.game3.map"] = { + currentDef = function() return { mapType = mapType } end, +} +package.loaded["src.core.game3.se_ids"] = { SE_FIELD_POISON = 1 } +package.loaded["src.core.game3.audio"] = { playSe = noop, fadeOutBgm = noop } +package.loaded["src.core.game3.special_scene_rse"] = { countSSTidalStep = function() return false end } +package.loaded["src.core.game3.safari"] = { takeStep = function() return false end } +package.loaded["src.core.game3.daycare"] = { step = function() return nil end } + +local StepEvents = require("src.core.game3.step_events") + +local function session(steps) + return { + vars = { poisonSteps = steps }, + party = { { name = "TORCHIC", hp = 30, status = "PSN" } }, + } +end + +local function walk(s, n) + for _ = 1, n do + StepEvents.onStepTaken(s, {}) + StepEvents.flush() + end +end + +family, mapType, forced = "rse", 9, false +local s = session(3) +walk(s, 12) +eq(s.party[1].hp, 30, "rse secret base: poison never bites") +eq(s.vars.poisonSteps, 3, "rse secret base: counter is not advanced") + +mapType = 8 +s = session(3) +walk(s, 1) +eq(s.party[1].hp, 29, "rse indoor: poison bites on the step the counter wraps") +s = session(0) +walk(s, 4) +eq(s.party[1].hp, 29, "rse: one bite per 4 steps") +walk(s, 4) +eq(s.party[1].hp, 28, "rse: next bite 4 steps later") + +forced = true +s = session(3) +walk(s, 6) +eq(s.party[1].hp, 30, "rse forced step: poison skipped") +eq(s.vars.poisonSteps, 3, "rse forced step: counter is not advanced") +forced = false + +family, mapType = "frlg", 1 +s = session(0) +walk(s, 4) +eq(s.party[1].hp, 30, "frlg: no bite after 4 steps") +eq(s.vars.poisonSteps, 4, "frlg: counter at 4") +walk(s, 1) +eq(s.party[1].hp, 29, "frlg: bite on the 5th step") +walk(s, 5) +eq(s.party[1].hp, 28, "frlg: next bite 5 steps later") + +StepEvents.flush() +T.finish("game3_field_poison_step_gate") diff --git a/tests/engine/game3_rs_battle_menu_frames_2729.lua b/tests/engine/game3_rs_battle_menu_frames_2729.lua index 1a81c8a8..26e6542d 100644 --- a/tests/engine/game3_rs_battle_menu_frames_2729.lua +++ b/tests/engine/game3_rs_battle_menu_frames_2729.lua @@ -29,11 +29,8 @@ package.loaded["src.ui.game3.chrome"] = { local BattleChrome = require("src.ui.game3.battle_chrome") local Ui = require("src.core.game3.battle.ui") -local nativeLayout = "rs" -package.loaded["src.core.game3.profile"] = { - forSession = function() return { font = { nativeLayout = nativeLayout } } end, -} -BattleChrome._manifest = { layout = "rse" } +local RS = { layout = "rse", assetLayout = "rs" } +local EMERALD = { layout = "rse" } local function frames(mode) drawn = {} @@ -49,13 +46,13 @@ local function same(a, b) return true end -nativeLayout = "rs" +BattleChrome._manifest = RS local mv = frames("moves") eq(same(mv, { { 1, 15, 20, 4 }, { 23, 15, 6, 4 } }), true, "RS move menu frames follow menu_map.bin") eq((mv[1] and (mv[1][1] + mv[1][3]) * 8) or -1, 168, "RS left move box content ends at x=168") eq(same(frames("menu"), { { 18, 15, 11, 4 } }), true, "RS action menu frame follows menu_map.bin") -nativeLayout = nil +BattleChrome._manifest = EMERALD eq(same(frames("moves"), { { 1, 15, 18, 4 }, { 21, 15, 8, 4 } }), true, "Emerald move menu frames unchanged") eq(same(frames("menu"), { { 16, 15, 13, 4 } }), true, "Emerald action menu frame unchanged") diff --git a/tests/engine/game3_rs_coins_box_test.lua b/tests/engine/game3_rs_coins_box_test.lua new file mode 100644 index 00000000..ffe5f347 --- /dev/null +++ b/tests/engine/game3_rs_coins_box_test.lua @@ -0,0 +1,52 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +local gfx = setmetatable({}, { __index = function() return function() end end }) +_G.love = _G.love or { graphics = gfx } + +local ui = {} +package.loaded["src.core.game3.profile"] = { + forSession = function() return { ui = ui, family = "rse" } end, + family = function() return "rse" end, +} +local keys = {} +package.loaded["src.core.game3.rom_text"] = { + plain = function(key, ctx) + keys[#keys + 1] = key + return ctx.stringVars[1] .. " COINS" + end, +} + +local Window = require("src.ui.game3.window") +local FrlgFont = require("src.ui.game3.frlg_font") +local CoinsBox = require("src.ui.game3.coins_box") + +local tpl, printed +Window.stdFrame = function(t) tpl = t end +Window.printPx = function(text, x, y) printed = { text = text, x = x, y = y } end +FrlgFont.measure = function(s) return #tostring(s) * 6 end + +local RsUi = require("src.core.game3.profiles.rs.ui") +ui = RsUi +CoinsBox.show(0, 0, 50) +CoinsBox.draw() +eq(tpl and tpl.left, 1, "RS showcoinsbox 0,0 frame corner is tile 0 (interior at 1)") +eq(tpl and tpl.top, 1, "RS showcoinsbox 0,0 frame top is tile 0 (interior at 1)") +eq(tpl and tpl.width, 8, "RS coins interior is 8 tiles wide (frame x..x+9)") +eq(tpl and tpl.height, 2, "RS coins interior is 2 tiles tall (frame y..y+3)") +eq(keys[#keys], "gOtherText_Coins2", "RS coins text is gOtherText_Coins2") +eq(printed and printed.x, 8 + 7 + 2 * 6, "RS coins digits padded to four columns") +eq(printed and printed.y, 8, "RS coins text on the interior's first row") + +ui = {} +CoinsBox.show(1, 1, 50) +CoinsBox.draw() +eq(tpl and tpl.left, 1, "Emerald showcoinsbox 1,1 interior stays at the script origin") +eq(tpl and tpl.top, 1, "Emerald showcoinsbox 1,1 interior top stays at the script origin") +eq(keys[#keys], "gText_Coins", "Emerald coins text is gText_Coins") + +T.finish("game3 RS coins box") diff --git a/tests/engine/game3_rs_field_yesno_s2.lua b/tests/engine/game3_rs_field_yesno_s2.lua new file mode 100644 index 00000000..2f5c276b --- /dev/null +++ b/tests/engine/game3_rs_field_yesno_s2.lua @@ -0,0 +1,154 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +local function romTextPlain(key) return key end +package.loaded["src.core.game3.rom_text"] = { + plain = romTextPlain, box = romTextPlain, ascii = romTextPlain, has = function() return true end, + key = function(n) return n end, at = function(n) return n end, + count = function() return 0 end, list = function() return {} end, + lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end, +} + +local log +local saveMenu = "frlg" +package.loaded["src.ui.game3.window"] = { + OPTION_HEIGHT = 15, CURSOR_WIDTH = 8, + template = function(l, t, w, h) return { left = l, top = t, width = w, height = h, h = h } end, + stdFrame = function(tpl) log.std = { tpl.left, tpl.top, tpl.width, tpl.height } end, + userFrame = function() end, + cursorPx = function(x, y) log.arrow = { x, y } end, + printPx = function(s, x, y) log.text[#log.text + 1] = { s, x, y } end, + menuRowPx = function(top, i) return top + (i - 1) * 15 end, +} +package.loaded["src.ui.game3.rs.menu_cursor"] = { + draw = function(x, y, w) log.rsCursor = { x, y, w } end, +} +package.loaded["src.core.game3.runtime"] = { getSession = function() return { version = "sapphire" } end, isActive = function() return true end } +package.loaded["src.core.game3.profile"] = { + forSession = function() return { ui = { saveMenu = saveMenu } } end, + family = function() return "rse" end, +} + +local Choice = require("src.ui.game3.choice") + +local function run(layout, cursor, left, top) + saveMenu = layout + log = { text = {} } + Choice.yesNo(function() end, left and { left = left, top = top } or nil) + Choice.cursor = cursor or 1 + Choice.draw() + return log +end + +local function rect(r) return table.concat(r or {}, ",") end + +local fr = run("frlg", 2, 20, 8) +eq(rect(fr.std), "21,9,6,4", "frlg yesnobox 20,8 ignores left/top (ScriptMenu_YesNo fixed template)") +eq(rect(fr.arrow), "168,88", "frlg yesnobox arrow glyph on row 2 at tile 21") +eq(fr.rsCursor, nil, "frlg yesnobox draws no RS bar") + +local frd = run("frlg") +eq(rect(frd.std), "21,9,6,4", "frlg default Yes/No interior at (21,9)") + +local em = run("rse", 2, 20, 8) +eq(rect(em.std), "21,9,5,4", "emerald yesnobox 20,8 uses sYesNo_WindowTemplates 21,9 5x4") +eq(rect(em.arrow), "168,88", "emerald yesnobox arrow glyph at tile 21") + +local emd = run("rse", 1, 3, 2) +eq(rect(emd.std), "21,9,5,4", "emerald yesnobox 3,2 still at 21,9") + +local function multi(layout, cursor, opts, cols, left, top) + saveMenu = layout + log = { text = {} } + package.loaded["src.ui.game3.frlg_font"] = { measure = function(s) return #s * 6 end } + Choice.multi(opts, cursor - 1, function() end, { left = left, top = top, cols = cols, maxRight = 29 }) + Choice.draw() + package.loaded["src.ui.game3.frlg_font"] = nil + return log +end + +local fm = multi("frlg", 1, { "50 COINS", "CANCEL" }, 1, 16, 1) +eq(fm.rsCursor, nil, "frlg multichoice draws no RS bar") +eq(fm.arrow ~= nil, true, "frlg multichoice keeps the arrow glyph") + +local rm = multi("rs", 2, { "50 COINS", "500 COINS", "CANCEL" }, 1, 16, 1) +eq(rect(rm.std), "16,1,7,6", "rs multichoice 15,0: interior at 16,1, width = widest label in tiles, height 2*count") +eq(rm.arrow, nil, "rs multichoice draws no FRLG arrow glyph") +eq(rect(rm.rsCursor), "128,24,56", "rs multichoice highlight bar at ((left+1)*8, (top+1+2*pos)*8) width 8*tiles") +eq(rm.text[1] and rect(rm.text[1]), "50 COINS,128,8", "rs multichoice item 0 at tile left+1, top+1") +eq(rm.text[3] and rect(rm.text[3]), "CANCEL,128,40", "rs multichoice item 2 at top+5") + +local rc = multi("rs", 1, { "LILYCOVE", "BATTLE TOWER", "CANCEL" }, 1, 20, 5) +eq(rect(rc.std), "20,5,9,6", "rs multichoice right <= 29 keeps its left") +local rk = multi("rs", 1, { "ABCDEFGHIJKLMNOPQRSTUVWX" }, 1, 20, 5) +eq(rect(rk.std), "11,5,18,2", "rs multichoice right > 29 shifts left (left += 29 - right)") +eq(rect(rk.rsCursor), "88,40,144", "rs multichoice shifted bar follows the frame") + +local rg = multi("rs", 5, { "PSN", "PAR", "SLP", "BRN", "FRZ", "CANCEL" }, 3, 9, 2) +eq(rect(rg.std), "9,2,17,4", "rs multichoicegrid 8,1 x3: columns widest+1 apart, rows = count/cols") +eq(rg.arrow, nil, "rs multichoicegrid draws no FRLG arrow glyph") +eq(rect(rg.rsCursor), "120,32,40", "rs multichoicegrid bar at column 1 row 1, width = widest label") +eq(rg.text[5] and rect(rg.text[5]), "FRZ,120,32", "rs multichoicegrid item 4 at column 1 row 1") + +local rs = run("rs", 2, 20, 8) +eq(rect(rs.std), "21,9,5,4", "rs yesnobox 20,8 frames tiles 20..26 x 8..13 (interior 21,9 5x4)") +eq(rs.arrow, nil, "rs yesnobox draws no FRLG arrow glyph") +eq(rect(rs.rsCursor), "168,88,40", "rs yesnobox highlight bar at (168, 72+16*pos) width 40") +eq(rs.text[1] and rect(rs.text[1]), "gText_Yes,168,72", "rs YES at tile 21 row 9") +eq(rs.text[2] and rect(rs.text[2]), "gText_No,168,88", "rs NO at tile 21 row 11") + +local rsd = run("rs", 1) +eq(rect(rsd.std), "21,9,5,4", "rs default Yes/No is DisplayYesNoMenu(20, 8)") +eq(rect(rsd.rsCursor), "168,72,40", "rs default bar on YES") + +package.loaded["src.ui.game3.choice"] = nil + +local loveStub = require("tests.love_stub") +_G.love = _G.love or loveStub +local StartMenu = require("src.ui.game3.start_menu") +local FrlgFont = require("src.ui.game3.frlg_font") +local Chrome = require("src.ui.game3.chrome") +local RsData = require("src.ui.game3.rs.start_menu_data") +local RseData = require("src.ui.game3.rse.start_menu_data") +local ListMenu = require("src.ui.game3.list_menu") +FrlgFont.draw = function(s, x, y) log.text[#log.text + 1] = { s, x, y } end +Chrome.dialogueFrame = function() end +ListMenu.drawScrollArrows = function() end + +local function confirm(dataMod, cursor) + log = { text = {} } + StartMenu.ENTRIES = { { id = "exit", label = "EXIT" } } + StartMenu._data = dataMod + StartMenu._ctx = nil + StartMenu.open = true + StartMenu.cursor = 1 + StartMenu._scrollOffset = 0 + StartMenu._confirmExit = true + StartMenu._confirmCursor = cursor + StartMenu.draw() + return log +end + +local function lastText(l, s) + for _, t in ipairs(l.text) do if t[1] == s then return rect(t) end end +end + +local em = confirm(RseData, 2) +eq(rect(em.std), "21,9,6,4", "emerald exit confirm keeps the FRLG template") +eq(rect(em.arrow), "169,90", "emerald exit confirm arrow on NO") +eq(em.rsCursor, nil, "emerald exit confirm draws no RS bar") + +local rx = confirm(RsData, 2) +eq(rect(rx.std), "21,9,5,4", "rs exit confirm frames tiles 20..26 x 8..13") +eq(rx.arrow, nil, "rs exit confirm draws no FRLG arrow glyph") +eq(rect(rx.rsCursor), "168,88,40", "rs exit confirm highlight bar at (168, 88) width 40 on NO") +eq(lastText(rx, "gText_Yes"), "gText_Yes,168,72", "rs exit confirm YES at (168, 72)") +eq(lastText(rx, "gText_No"), "gText_No,168,88", "rs exit confirm NO at (168, 88)") +local ry = confirm(RsData, 1) +eq(rect(ry.rsCursor), "168,72,40", "rs exit confirm bar on YES") + +T.finish("game3 rs field yes/no boxes use the RS highlight bar") diff --git a/tests/engine/game3_rs_layout_keys_test.lua b/tests/engine/game3_rs_layout_keys_test.lua new file mode 100644 index 00000000..1f3a12c4 --- /dev/null +++ b/tests/engine/game3_rs_layout_keys_test.lua @@ -0,0 +1,62 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq, check = T.eq, T.check + +local drawn = {} +package.loaded["src.ui.game3.chrome"] = { + _frameType = 0, + userFrame = function(_, x, y, w, h) drawn[#drawn + 1] = { x, y, w, h } end, +} + +local BattleChrome = require("src.ui.game3.battle_chrome") + +eq(BattleChrome.isRse, nil, "BattleChrome.isRse is gone") + +BattleChrome._manifest = { layout = "rse", assetLayout = "rs" } +eq(BattleChrome.layout(), "rs", "RS battle manifest resolves to the rs layout") +eq(BattleChrome.isHoenn(), true, "RS is Hoenn") +local rects = BattleChrome.menuFrameRects("menu", BattleChrome.layout()) +eq(rects and rects[1][1], 18, "RS action menu frame starts at tile 18") +drawn = {} +BattleChrome.drawMenuFrames("moves") +eq(#drawn == 2 and drawn[1][3] == 20 and drawn[2][1] == 23, true, + "drawMenuFrames reads the RS layout without a caller hint") + +BattleChrome._manifest = { layout = "rse" } +eq(BattleChrome.layout(), "emerald", "Emerald battle manifest resolves to the emerald layout") +eq(BattleChrome.isHoenn(), true, "Emerald is Hoenn") +drawn = {} +BattleChrome.drawMenuFrames("menu") +eq(#drawn == 1 and drawn[1][1] == 16 and drawn[1][3] == 13, true, "Emerald action menu frame unchanged") + +BattleChrome._manifest = {} +eq(BattleChrome.layout(), "frlg", "manifest without a layout is frlg") +eq(BattleChrome.isHoenn(), false, "FRLG is not Hoenn") +drawn = {} +BattleChrome.drawMenuFrames("menu") +eq(#drawn, 0, "FRLG draws no battle menu frames") +BattleChrome._manifest = nil +eq(BattleChrome.layout(), "frlg", "no manifest is frlg") + +local RsUi = require("src.core.game3.profiles.rs.ui") +eq(RsUi.saveMenu, "rs", "RS save menu layout is rs") +eq(type(RsUi.coinsWindow) == "table" and RsUi.coinsWindow.frameOrigin, true, + "RS coins window draws its frame at the script origin") + +local seen = {} +local function walk(t, path) + if seen[t] then return end + seen[t] = true + for k, v in pairs(t) do + local p = path .. "." .. tostring(k) + if v == "rse" then check(false, "RS ui profile value " .. p .. " is the Emerald layout") end + if type(v) == "table" then walk(v, p) end + end +end +walk(RsUi, "ui") +check(true, "RS ui profile walked for rse layout values") + +T.finish("game3 RS layout keys") diff --git a/tests/engine/game3_rs_save_window_test.lua b/tests/engine/game3_rs_save_window_test.lua new file mode 100644 index 00000000..829d02b2 --- /dev/null +++ b/tests/engine/game3_rs_save_window_test.lua @@ -0,0 +1,107 @@ +#!/usr/bin/env luajit + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq + +local gfx = setmetatable({}, { __index = function() return function() end end }) +_G.love = _G.love or { graphics = gfx } + +local layout = "rs" +package.loaded["src.core.game3.profile"] = { + forSession = function() return { ui = { saveMenu = layout } } end, + family = function() return "rse" end, +} +package.loaded["src.core.game3.rom_text"] = { + plain = function(key) return key end, +} + +local Window = require("src.ui.game3.window") +local Chrome = require("src.ui.game3.chrome") +local FrlgFont = require("src.ui.game3.frlg_font") +local SaveMenu = require("src.ui.game3.save_menu") + +local frames, texts = {}, {} +local arrow, bar +Window.stdFrame = function(tpl) frames[#frames + 1] = tpl end +Chrome.dialogueFrame = function() end +Chrome.dialogueWindow = function() return 2, 15, 26 end +Window.cursorPx = function(x, y) arrow = { x, y } end +package.loaded["src.ui.game3.rs.menu_cursor"] = { draw = function(x, y, w) bar = { x, y, w } end } +package.loaded["src.core.game3.rse.init"] = { system = function() return nil end } +FrlgFont.measure = function(s) return #tostring(s) * 6 end +FrlgFont.wrap = function(s) return s end +FrlgFont.linePitch = function() return 16 end +FrlgFont.draw = function(s, x, y, opts) texts[#texts + 1] = { s = tostring(s), x = x, y = y, colors = opts and opts.colors } end + +local hasDex = true +SaveMenu.hasDex = function() return hasDex end +SaveMenu.locationName = function() return "LITTLEROOT TOWN" end +SaveMenu.countBadges = function() return 3 end +SaveMenu.countDex = function() return 42 end + +local phase = "saved" +local function render() + frames, texts, arrow, bar = {}, {}, nil, nil + SaveMenu.open = true + SaveMenu._phase = phase + SaveMenu._session = { name = "MAY", gender = 1, playtime = { hours = 5, minutes = 7 } } + SaveMenu._layout = SaveMenu.layout(SaveMenu._session) + SaveMenu._rse = SaveMenu.isHoenn(SaveMenu._session) + SaveMenu.draw() + local stats = frames[1] + return stats, texts +end + +local function find(s) + for _, t in ipairs(texts) do if t.s == s then return t end end +end + +layout = "rs" +local stats = render() +eq(stats and stats.left, 1, "RS stats window interior starts at tile 1") +eq(stats and stats.width, 12, "RS stats window is 12 tiles wide (frame tiles 0..13)") +eq(stats and stats.height, 10, "RS stats window with the dex is 10 tiles tall (frame tiles 0..11)") +local header = find("LITTLEROOT TOWN") +eq(header and header.x, 8, "RS map name at tile 1") +eq(header and header.colors, FrlgFont.COLOR.NORMAL, "RS map name in the default menu color") +local name = find("MAY") +eq(name and name.x + 18, 104, "RS player name right-aligned to tile 13") +eq(name and name.colors, FrlgFont.COLOR.NORMAL, "RS values are not gender tinted") +local time = find("5:07") +eq(time and time.y, 8 + 64, "RS play time row is the fifth two-tile row") +hasDex = false +stats = render() +eq(stats and stats.height, 8, "RS stats window without the dex is 8 tiles tall") +hasDex = true + +-- pokeruby/src/start_menu.c:699, pokeruby/src/menu.c:609, :721, :750 +phase = "confirm" +SaveMenu.cursor = 2 +render() +eq(arrow, nil, "RS save Yes/No draws no FRLG arrow glyph") +eq(bar and table.concat(bar, ","), "168,88,40", "RS save Yes/No highlight bar at (168, 72 + 16 * pos) width 40") +local yes = find("gText_Yes") +eq(yes and yes.x .. "," .. yes.y, "168,72", "RS YES label at tile (21, 9)") +local no = find("gText_No") +eq(no and no.x .. "," .. no.y, "168,88", "RS NO label at tile (21, 11)") +SaveMenu.cursor = 1 +render() +eq(bar and bar[2], 72, "RS highlight bar follows the cursor to YES") +SaveMenu.cursor = 1 +phase = "saved" + +layout = "rse" +stats = render() +eq(stats and stats.width, 14, "Emerald stats window keeps its 14-tile template") +local em = find("LITTLEROOT TOWN") +eq(em and em.colors ~= FrlgFont.COLOR.NORMAL, true, "Emerald map name keeps its green header") +phase = "confirm" +SaveMenu.cursor = 2 +render() +eq(bar, nil, "Emerald save Yes/No draws no RS highlight bar") +eq(arrow and table.concat(arrow, ","), "168,89", "Emerald save Yes/No keeps the arrow glyph on NO") +phase = "saved" + +T.finish("game3 RS save stats window") diff --git a/tests/engine/game3_rse_field_poison_script_2736.lua b/tests/engine/game3_rse_field_poison_script_2736.lua index 5edcd094..a5fed3fd 100644 --- a/tests/engine/game3_rse_field_poison_script_2736.lua +++ b/tests/engine/game3_rse_field_poison_script_2736.lua @@ -99,6 +99,7 @@ StepEvents.flush() started = {} profileField = nil local session = poisonedSession() +session.vars.poisonSteps = 4 StepEvents.onStepTaken(session, {}) eq(types(), "poison_faint,poison_white_out", "firered keeps the Lua faint + white out events") eq(session.party[1].status, nil, "firered clears status at faint time") diff --git a/tests/engine/launcher_save_toast_s6.lua b/tests/engine/launcher_save_toast_s6.lua new file mode 100644 index 00000000..8570f647 --- /dev/null +++ b/tests/engine/launcher_save_toast_s6.lua @@ -0,0 +1,75 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Kit = require("src.ui.kit.Kit") +local UI = require("src.import.BoxUI") +local View = require("src.import.LauncherView") +local Importer = require("src.import.RomImporter") +local Transition = require("src.ui.kit.Transition") +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +local clock = 100 +love.timer.getTime = function() return clock end +local controls, texts, original = {}, {}, View.btn +View.btn = function(imp, x, y, w, h, id, label, opts) + controls[id] = { x = x, y = y, w = w, h = h, label = label, opts = opts } + return original(imp, x, y, w, h, id, label, opts) +end +local printText = love.graphics.print +love.graphics.print = function(value, ...) + texts[#texts + 1] = tostring(value); return printText(value, ...) +end +love.graphics.getDimensions = function() return 1024, 768 end +love.graphics.getPixelDimensions = function() return 1024, 768 end +local imp = Importer.new(function() end, { launcher = true }) +imp.tab = "red" +Transition.reset(); Kit.blur() +local function draw(click) + clock = clock + .3; controls, texts = {}, {} + imp._clickPt = click; imp:draw() + local queue = imp._uiActions; imp._uiActions = {} + imp:runActions(queue or {}) +end +local function printed(text) + for _, t in ipairs(texts) do if t:find(text, 1, true) then return true end end + return false +end + +imp.saveNotice = imp.saveNotice or {} +imp.saveNotice.red = { ok = false, text = "Export failed" } +draw(); draw() +local store = imp._toasts and imp._toasts.red +T.eq(store and store.toast and store.toast.kind, "error", "a failed save-card result raises a red toast") +T.eq(imp.saveNotice.red, nil, "the save card no longer keeps the failure text inline") +T.check(controls["box-toast"] ~= nil, "the launcher save toast is drawn and tappable") +T.check(printed("Export failed"), "the toast prints the save-card text") +local r = controls["box-toast"] +draw({ x = r.x + r.w / 2, y = r.y + r.h / 2 }); draw() +T.eq(imp._toasts.red.toast, nil, "tapping the save toast dismisses it") + +imp.saveNotice.red = { ok = true, text = "Exported to /tmp/x/red.sav", dir = "/tmp/x" } +draw(); draw() +T.eq(imp._toasts.red.toast and imp._toasts.red.toast.kind, "ok", "an export success raises a green toast") +T.eq(imp.saveNotice.red and imp.saveNotice.red.dir, "/tmp/x", "the export keeps its folder for the inline Open folder button") +T.eq(imp.saveNotice.red and imp.saveNotice.red.text, "", "the export text moves out of the card") +T.check(printed("Open folder"), "the Open folder button stays on the save card") + +imp.saveNotice.red = { ok = true, kind = "info", text = "Pick where to save red.sav..." } +draw() +T.eq(imp._toasts.red.toast.kind, "info", "a neutral notice raises a blue toast") + +UI.toast(imp, nil, nil, nil, "red") +imp.saveNotice.red = { ok = true, persistent = true, text = "Copy your Red .sav into: sdmc:/x" } +draw(); draw() +T.eq(imp._toasts.red.toast, nil, "persistent instructions raise no toast") +T.eq(imp.saveNotice.red and imp.saveNotice.red.text, "Copy your Red .sav into: sdmc:/x", + "persistent instructions stay inline") + +imp.saveNotice.red = nil +imp.saveNotice.blue = { ok = false, text = "Could not open the save editor (boom)." } +draw() +T.eq(imp._toasts.red.toast and imp._toasts.red.toast.text, "Could not open the save editor (boom).", + "an editor refusal toasts on the tab the player is on") + +View.btn, love.graphics.print = original, printText +T.finish() diff --git a/tests/engine/love_run_shell_matrix.lua b/tests/engine/love_run_shell_matrix.lua new file mode 100644 index 00000000..33ae4ba8 --- /dev/null +++ b/tests/engine/love_run_shell_matrix.lua @@ -0,0 +1,323 @@ +-- luajit tests/engine/love_run_shell_matrix.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") + +local FsIo = require("tests.fs_io") +local Version = require("src.core.Version") +local HostShell = require("src.core.HostShell") + +local SHELLS = { + { file = "v0.1.24", last = "v0.1.41", shell = 1, legacy = true }, + { file = "v0.1.42", last = "v0.2.24", shell = 1, legacy = true }, + { file = "v0.2.25", last = "v0.2.26", shell = 1, legacy = true }, + { file = "v0.2.27", last = "v0.2.36", shell = 1, legacy = true }, + { file = "v0.2.37", last = "v0.2.38", shell = 1, legacy = true }, + { file = "v0.2.39", last = "v0.2.42", shell = 1, legacy = true }, + { file = "v0.2.43", last = "v0.2.45", shell = 1, legacy = true }, + { file = "v0.2.46", last = "v0.2.50", shell = 1, legacy = true }, + { file = "v0.2.51", last = "v0.3.57", shell = 2, legacy = true }, + { file = "v0.3.58", last = "v0.3.58", shell = 2, legacy = true }, + { file = "shell3", shell = 3, shipped = { { first = "v0.3.59", last = "v0.3.59", declared = 2 } } }, +} + +local function readFile(path) + local f = io.open(path, "rb") + if not f then return nil end + local s = f:read("*a") + f:close() + return s +end + +local function stripComments(src) + local out, i, n, q = {}, 1, #src, nil + while i <= n do + local c = src:sub(i, i) + if q then + out[#out + 1] = c + if c == "\\" then + out[#out + 1] = src:sub(i + 1, i + 1) + i = i + 2 + else + if c == q then q = nil end + i = i + 1 + end + elseif c == '"' or c == "'" then + q = c + out[#out + 1] = c + i = i + 1 + elseif src:sub(i, i + 1) == "--" then + local eqs = src:match("^%-%-%[(=*)%[", i) + if eqs then + local _, e = src:find("]" .. eqs .. "]", i, true) + i = (e or n) + 1 + else + local nl = src:find("\n", i, true) + i = nl or (n + 1) + end + else + out[#out + 1] = c + i = i + 1 + end + end + return table.concat(out) +end + +local function normalize(src) + return (stripComments(src):gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "")) +end + +local function loveRunOf(src) + return src and src:match("\nfunction love%.run%(%)\n.-\nend\n") +end + +local mainRun = loveRunOf(readFile("main.lua")) +check(mainRun ~= nil, "main.lua defines function love.run()") + +local current +local maxLegacy = 0 +for _, fx in ipairs(SHELLS) do + fx.src = readFile("tests/data/love_run/" .. fx.file .. ".lua") + check(fx.src ~= nil, "fixture " .. fx.file .. " exists") + fx.norm = normalize(fx.src or "") + if fx.legacy and fx.shell > maxLegacy then maxLegacy = fx.shell end + if mainRun and fx.norm == normalize(mainRun) then current = fx end +end + +check(current ~= nil, + "main.lua love.run matches a tests/data/love_run fixture (a changed love.run is a new shell)") +if current then + eq(Version.shell, current.shell, "Version.shell names the shell main.lua's love.run implements") +end + +local seen = {} +local prev = maxLegacy +for _, fx in ipairs(SHELLS) do + if not fx.legacy then + check(fx.shell > prev, "shell " .. fx.shell .. " (" .. fx.file .. ") is newer than every earlier shell") + check(not seen[fx.shell], "shell " .. fx.shell .. " names one love.run only") + seen[fx.shell] = true + prev = math.max(prev, fx.shell) + for _, rel in ipairs(fx.shipped or {}) do + check(rel.declared < fx.shell and rel.declared <= maxLegacy, + fx.file .. " shipped in " .. rel.first .. " under shell " .. rel.declared + .. ": a misdeclared release may only reuse a legacy shell number") + end + end +end +for _, fx in ipairs(SHELLS) do + if fx.legacy and current and fx ~= current then + check(fx.norm ~= current.norm or fx.shell == current.shell, + fx.file .. " has the same love.run as main.lua under a different shell number") + end +end + +local payloadFiles = FsIo.luaFilesUnder("src") +payloadFiles[#payloadFiles + 1] = "main.lua" +check(#payloadFiles > 100, "payload scan found the source tree") +local function callArg(src, open) + local depth, i, n, q = 0, open, #src, nil + while i <= n do + local c = src:sub(i, i) + if q then + if c == "\\" then i = i + 1 elseif c == q then q = nil end + elseif c == '"' or c == "'" then + q = c + elseif c == "(" then + depth = depth + 1 + elseif c == ")" then + depth = depth - 1 + if depth == 0 then return src:sub(open + 1, i - 1) end + end + i = i + 1 + end + return nil +end + +local quitArgs = { [""] = true } +local quitSites = 0 +for _, path in ipairs(payloadFiles) do + local src = stripComments(readFile(path) or "") + local isHostShell = path:match("src/core/HostShell%.lua$") ~= nil + local pos = 1 + while true do + local s, e = src:find("love%.event%.quit%(", pos) + if not s then break end + local raw = callArg(src, e) + check(raw ~= nil, path .. ": love.event.quit( call at offset " .. s .. " never closes") + pos = e + 1 + local arg = (raw or ""):gsub("^%s+", ""):gsub("%s+$", "") + quitSites = quitSites + 1 + if arg == "" or arg:match("^%d+$") then + quitArgs[arg] = true + else + check(isHostShell, path .. " sends love.event.quit(" .. arg .. ") outside HostShell.restart") + check(arg:match('^"[^"]*"$') ~= nil or arg:match("^'[^']*'$") ~= nil, + path .. " sends love.event.quit(" .. arg .. "): the shell contract needs a literal argument") + end + end +end +check(quitSites >= 4, "payload scan found the love.event.quit call sites (" .. quitSites .. ")") + +local realExit, realOpen = os.exit, io.open +local EXITED = {} + +local queue, exits = {}, {} +local sleeps = 0 +local osName = "Android" +local fused = false + +local function noop() end + +local function installLove() + love.event = { + pump = noop, + poll = function() + return function() + local ev = table.remove(queue, 1) + if ev then return unpack(ev, 1, 7) end + end + end, + quit = function(...) + local n = select("#", ...) + queue[#queue + 1] = n == 0 and { "quit" } or { "quit", ... } + end, + push = function(name, ...) queue[#queue + 1] = { name, ... } end, + } + love.handlers = setmetatable({}, { __index = function() return noop end }) + love.system = love.system or {} + love.system.getOS = function() return osName end + love.system.restartApp = nil + love.filesystem = love.filesystem or {} + love.filesystem.isFused = function() return fused end + local now = 0 + love.timer = { + step = function() now = now + 1 / 60 return 1 / 60 end, + getTime = function() return now end, + sleep = function(s) + sleeps = sleeps + 1 + if sleeps > 10000 then error("frame never ends: love.timer.sleep looped", 0) end + now = now + (s or 0) + end, + getDelta = function() return 1 / 60 end, + } + love.graphics = love.graphics or {} + love.graphics.isActive = function() return true end + love.graphics.origin = love.graphics.origin or noop + love.graphics.clear = love.graphics.clear or noop + love.graphics.getBackgroundColor = love.graphics.getBackgroundColor or function() return 0, 0, 0, 1 end + love.graphics.present = love.graphics.present or noop + love.window = love.window or {} + love.window.isVisible = function() return true end + love.window.hasFocus = function() return true end + love.load = noop + love.update = noop + love.draw = noop + love.quit = function() return false end + love.arg = love.arg or {} + love.arg.parseGameArguments = love.arg.parseGameArguments or function(a) return a or {} end +end + +local function loadShell(fx) + local chunk, err = loadstring(fx.src, "=love_run/" .. fx.file) + if not chunk then return nil, err end + local env = setmetatable({ + pacingEnabled = function() return true end, + checkEmergencyQuit = noop, + idlePresentationCap = function() return nil end, + }, { __index = _G, __newindex = _G }) + setfenv(chunk, env) + local saved = love.run + local ok, e = pcall(chunk) + local run = love.run + love.run = saved + if not ok then return nil, e end + return run +end + +local function step(stepper) + sleeps = 0 + local ok, ret = pcall(stepper) + if not ok and ret == EXITED then return true, nil, true end + return ok, ret, false +end + +local ACTIONS = { + { name = "HostShell.restart", fn = function() HostShell.restart() end }, +} +for arg in pairs(quitArgs) do + local n = tonumber(arg) + ACTIONS[#ACTIONS + 1] = { + name = "quit(" .. arg .. ")", + fn = function() if n then love.event.quit(n) else love.event.quit() end end, + } +end +table.sort(ACTIONS, function(a, b) return a.name < b.name end) + +local OSES = { "Android", "iOS", "OS X", "Windows", "Linux" } + +os.exit = function(code) + exits[#exits + 1] = code + if code ~= nil and type(code) ~= "number" and type(code) ~= "boolean" then + error("bad argument #1 to 'exit' (number expected, got " .. type(code) .. ")", 2) + end + error(EXITED, 0) +end +io.open = function(path, ...) + if path == "/proc/self/cmdline" then return nil end + return realOpen(path, ...) +end + +local FrameCap = require("src.core.FrameCap") +local capWas = FrameCap.current +local runs = 0 +for _, fx in ipairs(SHELLS) do + if fx.src and fx.shell >= (Version.minShell or 1) then + for _, os_ in ipairs(OSES) do + for _, act in ipairs(ACTIONS) do + local label = fx.file .. " shell on " .. os_ .. ", payload " .. act.name + osName, fused = os_, false + for i = #queue, 1, -1 do queue[i] = nil end + for i = #exits, 1, -1 do exits[i] = nil end + _G.POKEPORT_LOOP_RESTART = nil + _G.POKEPORT_LOOP_PANEL_SYNC = nil + HostShell.restarting = false + installLove() + FrameCap.current = capWas + local run, lerr = loadShell(fx) + check(run ~= nil, label .. ": fixture loads (" .. tostring(lerr) .. ")") + if run then + local okRun, stepper = pcall(run) + check(okRun and type(stepper) == "function", label .. ": love.run returns a stepper (" .. tostring(stepper) .. ")") + if okRun and type(stepper) == "function" then + local ok1, r1 = step(stepper) + check(ok1 and r1 == nil, label .. ": a default-cap frame runs on the payload's modules (" .. tostring(r1) .. ")") + FrameCap.applyOptions({ fpsCap = FrameCap.DISPLAY }) + local ok2, r2 = step(stepper) + check(ok2 and r2 == nil, label .. ": a DISPLAY-option frame runs on the payload's modules (" .. tostring(r2) .. ")") + FrameCap.current = capWas + local okA, aerr = pcall(act.fn) + check(okA, label .. ": payload exit path runs (" .. tostring(aerr) .. ")") + local ok, ret, exited = step(stepper) + check(ok, label .. ": shell survives the payload's quit (" .. tostring(ret) .. ")") + if ok then + check(exited or ret ~= nil, label .. ": shell ends the frame loop") + check(exited or type(ret) == "number" or ret == "restart", + label .. ": shell hands LOVE a number or \"restart\", got " .. tostring(ret)) + end + runs = runs + 1 + end + end + end + end + end +end +FrameCap.current = capWas +os.exit, io.open = realExit, realOpen + +check(runs >= #SHELLS * #OSES * 2, "every shell ran under every OS (" .. runs .. " runs)") + +T.finish("love_run_shell_matrix") diff --git a/tests/engine/save_editor_mail_maxdv_spinda.lua b/tests/engine/save_editor_mail_maxdv_spinda.lua new file mode 100644 index 00000000..7a62fbf1 --- /dev/null +++ b/tests/engine/save_editor_mail_maxdv_spinda.lua @@ -0,0 +1,285 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" +love = require("tests.love_stub") +local T = require("tests.harness") +local bit = require("bit") +local Version = require("src.core.GameVersion") +local Serializer = require("src.core.SaveSerializer") +local K = require("tests.save_compat._codec") +local D = require("tests.save_compat._gen3_decode") +local B = require("tests.fixtures.save.bytes") +local G3 = require("tests.fixtures.save.gen3_build") +local G2 = require("tests.fixtures.save.gen2_build") +local Gen2Save = require("src.save_convert.Gen2Save") +local PokemonG3 = require("src.core.game3.pokemon") +local Unown = require("src.core.gen2.Unown") +local Mon2 = require("src.battle.gen2.Mon") +local Gen, Ops, MonOps = require("Gen"), require("Ops"), require("MonOps") +local State = require("State") +require("tests.game3_cache").mount() + +local BEAD_MAIL = 127 +local UNOWN_F = 0x00000105 + +local function g3mailState(version, fam) + Version.set(version) + local w = G3.base(version) + G3.setParty(w, { G3.mon({ species = 201, pid = UNOWN_F, tid = 0x1234, sid = 0x5678, item = BEAD_MAIL, mail = 0 }) }) + local o = D.FULL[fam].mail + for k = 0, 8 do B.le(w.sb1, o + k * 2, 0xFFFF, 2) end + local name = G3.text("RED", 8) + for i = 1, 8 do w.sb1[o + 17 + i] = name[i] end + B.le(w.sb1, o + 26, 0x1234 + 0x5678 * 65536, 4) + B.le(w.sb1, o + 30, 30005, 2) + B.le(w.sb1, o + 32, BEAD_MAIL, 2) + local s = State.new() + s.save, s.version = assert(K.import(3, version, G3.emit(w))), version + s.save.pcItems = s.save.pcItems or {} + Gen.ensureBoxes(s.save) + return s +end + +local function export3(s, v, fam) + local out = assert(K.export(3, v, Serializer.decode(Serializer.encode(s.save)), nil)) + return out, assert(D.deep(out, fam)) +end + +for _, v in ipairs({ "firered", "emerald" }) do + local fam = v == "emerald" and "emerald" or "frlg" + local s = g3mailState(v, fam) + local mon = s.save.party[1] + T.eq(mon.mail, 0, v .. " fixture unown holds mail 0") + T.eq(s.save.mail[1].species, 30005, v .. " fixture mail shows UNOWN F") + T.eq(Ops.setUnownForm(s, mon, 25), true, v .. " form set to Z") + T.eq(s.save.mail[1].species, 30025, v .. " editor mail species follows the form") + local out, deep = export3(s, v, fam) + T.eq(deep.mail[1].species, 30025, v .. " exported mail species is UNOWN Z") + T.eq(deep.mail[1].itemId, BEAD_MAIL, v .. " exported mail keeps the item") + T.eq(deep.party[1].mail, 0, v .. " exported mon still points at mail 0") + T.eq(PokemonG3.unownLetter(deep.party[1].pid), 25, v .. " exported PID is Z") + local back = assert(K.import(3, v, out)) + T.eq(back.mail[1].species, 30025, v .. " re-import reads mail species UNOWN Z") + + s.data = s.data or {} + s.data.pokemon = s.data.pokemon or {} + s.data.pokemon[25] = { name = "PIKACHU", speciesId = 25 } + local ok = Ops.setSpecies(s, mon, 25) + if ok then + T.eq(s.save.mail[1].species, 25, v .. " species edit restamps mail species") + local _, deep2 = export3(s, v, fam) + T.eq(deep2.mail[1].species, 25, v .. " exported mail species follows a species edit") + else + print("[skip] " .. v .. " setSpecies needs species data: " .. tostring(s.status)) + end +end + +local UNOWN_DEF = { + id = "UNOWN", name = "UNOWN", dex = 201, index = 201, types = { "PSYCHIC" }, + baseStats = { hp = 48, attack = 72, defense = 48, speed = 48, specialAttack = 72, specialDefense = 48 }, + catchRate = 225, baseExp = 61, growthRate = "MEDIUM_FAST", genderRatio = 255, +} +local CYNDA_DEF = { + id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155, types = { "FIRE" }, + baseStats = { hp = 39, attack = 52, defense = 43, speed = 65, specialAttack = 60, specialDefense = 50 }, + catchRate = 45, baseExp = 65, growthRate = "MEDIUM_SLOW", genderRatio = 31, +} +local g2data = { + pokemon = { UNOWN = UNOWN_DEF, CYNDAQUIL = CYNDA_DEF }, + items = K.gen2Data.items, maps = K.gen2Data.maps, + moves = { HIDDEN_POWER = { id = "HIDDEN_POWER", name = "HIDDEN POWER", index = 237, pp = 15, power = 1, accuracy = 100, type = "NORMAL" } }, +} + +local function g2state(version, species, dvs) + Version.set(version) + local bytes = G2.build({ version = version, party = { G2.mon({ species = species, nick = "MON", dvs = dvs, + moves = { 237, 0, 0, 0 }, pp = { 15, 0, 0, 0 }, ppUps = { 0, 0, 0, 0 } }) } }) + local s = State.new() + s.data = g2data + s.save, s.version = assert(Gen2Save.decode(bytes, version, g2data)), version + Gen.ensureBoxes(s.save) + return s +end + +local function dvList(d) return { d.attack, d.defense, d.speed, d.special } end +local function dvsOf(t) return { attack = t[1], defense = t[2], speed = t[3], special = t[4] } end + +local MAX_UNOWN = { + { 9, 9, 11, 15 }, { 9, 9, 15, 15 }, { 9, 11, 13, 15 }, { 9, 11, 15, 15 }, { 9, 13, 15, 15 }, + { 9, 15, 13, 15 }, { 9, 15, 15, 15 }, { 11, 9, 15, 15 }, { 11, 11, 11, 15 }, { 11, 11, 15, 15 }, + { 11, 13, 13, 15 }, { 11, 13, 15, 15 }, { 11, 15, 15, 15 }, { 13, 9, 13, 15 }, { 13, 9, 15, 15 }, + { 13, 11, 15, 15 }, { 13, 13, 11, 15 }, { 13, 13, 15, 15 }, { 13, 15, 13, 15 }, { 13, 15, 15, 15 }, + { 15, 9, 15, 15 }, { 15, 11, 13, 15 }, { 15, 11, 15, 15 }, { 15, 13, 15, 15 }, { 15, 15, 11, 15 }, + { 15, 15, 15, 15 }, +} + +local function checkWritten(s, v, label, want) + local out = assert(Gen2Save.encode(s.save, v, nil, g2data)) + local L = Gen2Save.layoutFor(v) + T.eq(Gen2Save.checksumValid(out, L), true, label .. " checksum valid") + local dvHi, dvLo = out:byte(L.wPartyMons + 0x15 + 1), out:byte(L.wPartyMons + 0x16 + 1) + T.eq(("%d/%d/%d/%d"):format(math.floor(dvHi / 16), dvHi % 16, math.floor(dvLo / 16), dvLo % 16), + table.concat(want, "/"), label .. " DV bytes at +0x15") + return assert(Gen2Save.decode(out, v, g2data)) +end + +for _, v in ipairs({ "gold", "crystal" }) do + for letter = 1, 26 do + for _, how in ipairs({ "maxDvs", "maxMon" }) do + local s = g2state(v, 201, dvList(Unown.dvsForLetter(letter) or MonOps.unownDvs(dvsOf({ 4, 6, 8, 2 }), letter))) + local mon = s.save.party[1] + T.eq(Unown.monLetter(mon), letter, v .. " fixture letter " .. letter) + if how == "maxMon" then + mon.statExp = mon.statExp or {} + Ops.maxMon(s, mon) + mon = s.save.party[1] + else + Ops.maxDvs(s, mon) + end + local label = ("%s %s letter %d"):format(v, how, letter) + T.eq(Unown.letterFromDVs(mon.dvs), letter, label .. " keeps the letter") + T.eq(table.concat(dvList(mon.dvs), "/"), table.concat(MAX_UNOWN[letter], "/"), label .. " max DVs for the letter") + T.eq(mon.dvs.hp, 15, label .. " HP DV 15") + local back = checkWritten(s, v, label, MAX_UNOWN[letter]) + T.eq(Unown.monLetter(back.party[1]), letter, label .. " re-read letter") + end + end + + for _, case in ipairs({ { 2, 10, 10, 10, 9, { 11, 10, 10, 10 } }, { 14, 10, 10, 10, 22, { 15, 10, 10, 10 } } }) do + for _, how in ipairs({ "maxDvs", "maxMon" }) do + local s = g2state(v, 201, { case[1], case[2], case[3], case[4] }) + local mon = s.save.party[1] + T.eq(Unown.monLetter(mon), case[5], v .. " shiny fixture letter " .. case[5]) + if how == "maxMon" then Ops.maxMon(s, mon) mon = s.save.party[1] else Ops.maxDvs(s, mon) end + local label = ("%s %s shiny unown %d"):format(v, how, case[5]) + T.eq(Unown.letterFromDVs(mon.dvs), case[5], label .. " keeps the letter") + T.eq(mon.shiny, true, label .. " stays shiny") + T.eq(table.concat(dvList(mon.dvs), "/"), table.concat(case[6], "/"), label .. " shiny max DVs") + T.check(s.status:find("kept shiny", 1, true) ~= nil, label .. " status says kept shiny") + checkWritten(s, v, label, case[6]) + end + end + + for _, how in ipairs({ "maxDvs", "maxMon" }) do + local s = g2state(v, 155, { 6, 10, 10, 10 }) + local mon = s.save.party[1] + T.eq(Mon2.vanillaShiny(mon.dvs), true, v .. " shiny cyndaquil fixture") + if how == "maxMon" then Ops.maxMon(s, mon) mon = s.save.party[1] else Ops.maxDvs(s, mon) end + local label = v .. " " .. how .. " shiny cyndaquil" + T.eq(mon.shiny, true, label .. " stays shiny") + T.eq(table.concat(dvList(mon.dvs), "/"), "15/10/10/10", label .. " highest shiny DVs") + T.check(s.status:find("kept shiny", 1, true) ~= nil, label .. " status says kept shiny") + local back = checkWritten(s, v, label, { 15, 10, 10, 10 }) + T.eq(Mon2.vanillaShiny(back.party[1].dvs), true, label .. " re-read DVs are shiny") + + local s2 = g2state(v, 155, { 3, 7, 1, 12 }) + local m2 = s2.save.party[1] + if how == "maxMon" then Ops.maxMon(s2, m2) m2 = s2.save.party[1] else Ops.maxDvs(s2, m2) end + T.eq(table.concat(dvList(m2.dvs), "/"), "15/15/15/15", v .. " " .. how .. " plain cyndaquil gets 15s") + T.eq(m2.shiny, false, v .. " " .. how .. " plain cyndaquil not shiny") + T.check(s2.status:find("kept shiny", 1, true) == nil, v .. " " .. how .. " plain status has no shiny note") + end +end + +local SPINDA = PokemonG3.SPECIES_SPINDA +local function bytesMoved(a, b) + local n = 0 + for i = 0, 3 do + if bit.band(bit.rshift(a, 8 * i), 0xFF) ~= bit.band(bit.rshift(b, 8 * i), 0xFF) then n = n + 1 end + end + return n +end +local function isShiny(p, tsv) return bit.bxor(tsv, bit.bxor(math.floor(p / 65536), p % 65536)) < 8 end +local function genderOf(p) return (p % 256) < 127 and "F" or "M" end + +do + local meta = PokemonG3.speciesMeta and PokemonG3.speciesMeta(SPINDA) + T.eq((meta and meta.genderRatio) or 127, 127, "spinda gender ratio is 127") + local rng = 308 + local function rand(n) + rng = (rng * 1103515245 + 12345) % 2147483648 + return math.floor(rng / 32768) % n + end + local one, plain, shinyTwo, shinyBases = 0, 0, 0, 0 + local onTwo, onFour, broken, kept, offOne = 0, 0, 0, 0, 0 + for _ = 1, 300 do + local tid, sid = rand(65536), rand(65536) + local tsv = bit.bxor(tid, sid) + local pid = rand(65536) * 65536 + rand(65536) + if pid == 0 then pid = 1 end + local nature, shiny, gender, ab = pid % 25, isShiny(pid, tsv), genderOf(pid), pid % 2 + local function run(reqs) + reqs.basePid = pid + reqs.ability = reqs.ability or ab + return MonOps.generatePid(SPINDA, tid, sid, reqs) + end + local function ok(p, n, g, sh) + return p and p ~= 0 and p % 25 == n and genderOf(p) == g and isShiny(p, tsv) == sh and p % 2 == ab + end + if run({ nature = nature, gender = gender, shiny = shiny }) == pid then kept = kept + 1 end + local n2 = (nature + 1 + rand(24)) % 25 + local p = run({ nature = n2, gender = gender, shiny = shiny }) + if not ok(p, n2, gender, shiny) then broken = broken + 1 end + local function tally(q) + if not q then return end + if shiny then + if bytesMoved(pid, q) <= 2 then shinyTwo = shinyTwo + 1 end + elseif bytesMoved(pid, q) == 1 then + one = one + 1 + end + end + if shiny then shinyBases = shinyBases + 1 else plain = plain + 1 end + tally(p) + local g2 = gender == "F" and "M" or "F" + p = run({ nature = nature, gender = g2, shiny = shiny }) + if not ok(p, nature, g2, shiny) then broken = broken + 1 end + tally(p) + p = run({ nature = nature, gender = gender, shiny = not shiny }) + if not ok(p, nature, gender, not shiny) then broken = broken + 1 end + if p then + local m = bytesMoved(pid, p) + if not shiny then + if m <= 2 then onTwo = onTwo + 1 end + if m == 4 then onFour = onFour + 1 end + end + end + local spid = bit.bxor(pid % 65536, tsv, rand(8)) * 65536 + pid % 65536 + local sp = MonOps.generatePid(SPINDA, tid, sid, { nature = spid % 25, gender = genderOf(spid), shiny = false, + ability = spid % 2, basePid = spid }) + if not (sp and sp % 25 == spid % 25 and genderOf(sp) == genderOf(spid) and not isShiny(sp, tsv)) then + broken = broken + 1 + end + if sp and bytesMoved(spid, sp) == 1 then offOne = offOne + 1 end + end + T.eq(broken, 0, "spinda generatePid always meets nature, gender, ability and shininess") + T.eq(kept, 300, "spinda PID that already fits is returned unchanged") + T.check(plain >= 250, "spinda sample is mostly non-shiny") + T.eq(one, plain * 2, "spinda nature or gender edit moves exactly one spot") + T.eq(shinyTwo, shinyBases * 2, "spinda nature or gender edit on a shiny moves at most two spots") + T.eq(offOne, 300, "spinda shiny off moves exactly one spot") + T.eq(onFour, 0, "spinda shiny on never moves all four spots") + T.check(onTwo >= 150, ("spinda shiny on moves at most two spots in most cases (%d)"):format(onTwo)) +end + +do + Version.set("firered") + local w = G3.base("firered") + G3.setParty(w, { G3.mon({ species = SPINDA, pid = 0x8A3C5E71, tid = 0x1234, sid = 0x5678 }) }) + local s = State.new() + s.save, s.version = assert(K.import(3, "firered", G3.emit(w))), "firered" + Gen.ensureBoxes(s.save) + local mon = s.save.party[1] + local before = mon.personality + MonOps.setNature(s.data, mon, (before % 25 + 5) % 25, 3) + T.eq(mon.personality % 25, (before % 25 + 5) % 25, "spinda setNature sets the nature") + T.eq(bytesMoved(before, mon.personality), 1, "spinda setNature moves one spot") + local out = assert(K.export(3, "firered", Serializer.decode(Serializer.encode(s.save)), nil)) + local deep = assert(D.deep(out, "frlg")) + T.eq(deep.party[1].pid, mon.personality, "spinda PID round trips") + T.check(deep.party[1].checksumOk, "spinda substruct checksum valid") + local MonEditor = require("MonEditor") + local k1 = MonEditor.spriteKey(s, SPINDA, mon) + MonOps.setNature(s.data, mon, (mon.personality % 25 + 3) % 25, 3) + T.check(k1 ~= MonEditor.spriteKey(s, SPINDA, mon), "editor sprite key follows the spinda PID") + T.eq(MonEditor.spriteKey(s, 4, { personality = 1 }), 4, "non-spinda sprite key unchanged") +end + +T.finish() diff --git a/tests/engine/save_editor_map_point_buttons_s8.lua b/tests/engine/save_editor_map_point_buttons_s8.lua new file mode 100644 index 00000000..c4faa683 --- /dev/null +++ b/tests/engine/save_editor_map_point_buttons_s8.lua @@ -0,0 +1,191 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" +love = require("tests.love_stub") +local T = require("tests.harness") +local clock = 300 +love.timer.getTime = function() return clock end +local App = require("tools.save-editor.App") +local Ops, Gen, Kit = require("Ops"), require("Gen"), require("Kit") +local MapBrowser = require("MapBrowser") +local SD = require("src.core.SaveData") + +T.check(type(MapBrowser.pointActions) == "function", "the map browser builds its own point buttons") +if type(MapBrowser.pointActions) == "function" then + local function labels(actions) + local out = {} + for _, action in ipairs(actions) do out[#out + 1] = action.label end + return table.concat(out, ",") + end + local g2 = { save = { generation = 2, spawn = "SPAWN_HOME", position = { map = "NEW_BARK_TOWN", x = 5, y = 5 } }, + mapId = "NEW_BARK_TOWN" } + local a2 = MapBrowser.pointActions(g2, nil) + T.eq(labels(a2), "Player,Spawn", "Gen 2 shows Player and Spawn") + T.eq(a2[2].run, Ops.setLastHeal, "Gen 2 Spawn writes through Ops.setLastHeal") + T.eq(a2[1].run, Ops.setPlayerHere, "Player writes through Ops.setPlayerHere") + local g3 = { save = { generation = 3, healMap = "FR_PALLET_TOWN", healX = 1, healY = 2 }, mapId = "FR_PALLET_TOWN" } + local a3 = MapBrowser.pointActions(g3, nil) + T.eq(labels(a3), "Player,Heal", "Gen 3 shows Player and Heal") + T.eq(a3[2].run, Ops.setLastHeal, "Gen 3 Heal writes through Ops.setLastHeal") + T.check(not a3[2].enabled and a3[2].reason:find("FR_PALLET_TOWN (1,2)", 1, true), + "with no cell the Gen 3 Heal button is off and says the current heal spot") +end + +local path = os.tmpname() .. "-s8.lua" +local f = assert(io.open(path, "wb")) +f:write(SD.encode(Gen.newGame("red"))) +f:close() +local loaded = pcall(App.load, path, { version = "red", embedded = true }) +local S = App.getState() +if not loaded or not S or not S.save or S.missingCache or not (S.data.maps and S.data.maps.PALLET_TOWN) then + os.remove(path) + print("[skip] save_editor_map_point_buttons_s8: no red data") + T.finish("save_editor_map_point_buttons_s8") + return +end + +local oldOS = love.system.getOS +local function frame(w, h, os, audit) + love.graphics.getDimensions = function() return w, h end + love.window.getSafeArea = function() return 0, 0, w, h end + love.system.getOS = function() return os end + clock = clock + .3 + Kit.audit = audit and {} or nil + App.draw() + local got = Kit.audit + Kit.audit = nil + return got +end +local function controls(audit, label) + local out = {} + for _, c in ipairs(audit or {}) do + if c.class == "control" and c.label == label then out[#out + 1] = c end + end + return out +end +local function tap(x, y, shape) + App.mousepressed(x, y, 1) + frame(shape[1], shape[2], shape[3]) +end + +for _, shape in ipairs({ { 390, 844, "Android" }, { 1360, 860, "OS X" } }) do + local name = shape[3] + S.tab, S.mapFocused, S.navPopup, S.editPopup = "map", false, nil, nil + S.mapSection = "spawn" + S.save.lastHeal, S.save.lastOutdoor = nil, nil + MapBrowser.select(S, "PALLET_TOWN") + frame(shape[1], shape[2], shape[3]) + if S._mapStacked then + T.eq(S.mapSection, "view", "a stale Spawn page falls back to the map view at " .. name) + end + local audit = frame(shape[1], shape[2], shape[3], true) + T.eq(#controls(audit, "Set here"), 0, "no separate Set here card at " .. name) + local r = S._mapViewRect + local buttons = {} + for _, label in ipairs({ "Player", "Heal", "Outdoor" }) do + local c = controls(audit, label)[1] + buttons[label] = c + T.check(c ~= nil, label .. " button is on the map at " .. name) + if c then + T.check(r and c.x >= r.x and c.y >= r.y and c.x + c.w <= r.x + r.w and c.y + c.h <= r.y + r.h, + label .. " sits inside the map viewport at " .. name) + T.check(c.h >= Kit.tapMin() and c.w >= Kit.tapMin(), label .. " meets the tap minimum at " .. name) + end + end + if buttons.Heal and r then + local before = SD.encode(S.save) + clock = clock + 10 + S.mapClickCell = nil + tap(buttons.Heal.x + buttons.Heal.w / 2, buttons.Heal.y + buttons.Heal.h / 2, shape) + T.eq(S.toast and S.toast.kind, "info", "Heal with no cell explains itself at " .. name) + T.check(S.toast and S.toast.text:find("Tap a cell first", 1, true), "the reason names the missing cell at " .. name) + T.eq(S.mapClickCell, nil, "a tap on a disabled button does not select the cell under it at " .. name) + T.eq(SD.encode(S.save), before, "a disabled button writes nothing at " .. name) + + local map = require("src.world.MapLoader").load(S.data, "PALLET_TOWN") + local pick + for cy = map.heightCells - 2, 1, -1 do + for cx = 1, map.widthCells - 2 do + local sx = r.x + ((cx + 0.5) * 16 - S.mapCamX) * S.mapZoom + local sy = r.y + ((cy + 0.5) * 16 - S.mapCamY) * S.mapZoom + if not pick and not map:warpAtCell(cx, cy) and sx > r.x + 2 and sx < r.x + r.w - 2 + and sy > r.y + 2 and sy < r.y + r.h - 2 and not MapBrowser.insideOverlay(S, sx, sy) then + pick = { cx = cx, cy = cy, x = sx, y = sy } + end + end + end + T.check(pick ~= nil, "a free cell is visible at " .. name) + tap(pick.x, pick.y, shape) + T.check(S.mapClickCell and S.mapClickCell.cx == pick.cx and S.mapClickCell.cy == pick.cy, + "tapping the map selects a cell at " .. name) + + local function press(label) + audit = frame(shape[1], shape[2], shape[3], true) + local c = controls(audit, label)[1] + clock = clock + 10 + tap(c.x + c.w / 2, c.y + c.h / 2, shape) + T.check(S.mapClickCell and S.mapClickCell.cx == pick.cx and S.mapClickCell.cy == pick.cy, + label .. " tap does not reselect the cell under the button at " .. name) + T.eq(S.toast and S.toast.kind, "ok", label .. " raises a green toast at " .. name) + end + press("Heal") + local heal = S.save.lastHeal + T.check(heal and heal.map == "PALLET_TOWN" and heal.x == pick.cx and heal.y == pick.cy, + "Heal writes lastHeal for the selected cell at " .. name) + press("Player") + local pm, px, py = Gen.playerMap(S.save) + T.check(pm == "PALLET_TOWN" and px == pick.cx and py == pick.cy, "Player moves the player at " .. name) + press("Outdoor") + local out = S.save.lastOutdoor + T.check(out and out.id == "PALLET_TOWN" and out.x == pick.cx and out.y == pick.cy, + "Outdoor writes lastOutdoor for the selected cell at " .. name) + end +end + +do + local shape = { 320, 568, "Android" } + S.tab, S.mapFocused, S.mapSection = "map", false, "view" + MapBrowser.select(S, "PALLET_TOWN") + frame(shape[1], shape[2], shape[3]) + local audit = frame(shape[1], shape[2], shape[3], true) + T.eq(S._mapActionsCompact, true, "a viewport too narrow for the labels falls back to icon-only buttons") + local r = S._mapViewRect + for _, label in ipairs({ "Player", "Heal", "Outdoor" }) do + local c = controls(audit, label)[1] + T.check(c ~= nil, label .. " stays reachable as a compact button") + if c then + T.check(r and c.x >= r.x and c.y >= r.y and c.x + c.w <= r.x + r.w and c.y + c.h <= r.y + r.h, + label .. " compact button sits inside the map viewport") + T.check(c.h >= Kit.tapMin() and c.w >= Kit.tapMin() and c.w == c.h, label .. " compact button is a tap-size square") + end + end + local heal = controls(audit, "Heal")[1] + if heal and r then + S.mapClickCell = { cx = 3, cy = 3 } + clock = clock + 10 + tap(heal.x + heal.w / 2, heal.y + heal.h / 2, shape) + T.eq(S.toast and S.toast.kind, "ok", "the compact Heal button writes the heal spot") + T.check(S.save.lastHeal and S.save.lastHeal.x == 3 and S.save.lastHeal.y == 3, "compact Heal writes the selected cell") + T.check(S.mapClickCell and S.mapClickCell.cx == 3, "the compact tap does not fall through to the map") + end +end + +if S.data.maps.REDS_HOUSE_1F then + local shape = { 390, 844, "Android" } + MapBrowser.select(S, "REDS_HOUSE_1F") + S.save.visited = {} + S.mapClickCell = { cx = 2, cy = 2 } + local audit = frame(shape[1], shape[2], shape[3], true) + local c = controls(audit, "Outdoor")[1] + T.check(c ~= nil, "Outdoor is shown on an indoor map") + if c then + local before = SD.encode(S.save) + clock = clock + 10 + tap(c.x + c.w / 2, c.y + c.h / 2, shape) + T.eq(S.toast and S.toast.kind, "error", "Outdoor on an indoor map is refused with a toast") + T.eq(SD.encode(S.save), before, "the refused Outdoor writes nothing") + end +end + +love.system.getOS = oldOS +App.unload() +os.remove(path) +T.finish("save_editor_map_point_buttons_s8") diff --git a/tests/engine/save_editor_status_words_s6.lua b/tests/engine/save_editor_status_words_s6.lua new file mode 100644 index 00000000..56cb31ab --- /dev/null +++ b/tests/engine/save_editor_status_words_s6.lua @@ -0,0 +1,58 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" +love = require("tests.love_stub") +local T = require("tests.harness") +local Ops = require("Ops") + +local unown = { id = "UNOWN", name = "UNOWN", species = 201, speciesId = 201 } +local abra = { id = "ABRA", name = "ABRA", species = 63, speciesId = 63 } +local function state(generation, party) + return { + version = generation == 3 and "firered" or generation == 2 and "gold" or "red", + save = { generation = generation, party = party, player = { map = "X", x = 0, y = 0 } }, + data = { pokemon = { [201] = unown, UNOWN = unown, [63] = abra, ABRA = abra, + MR_MIME = { id = "MR_MIME", name = "MR.MIME" }, NIDORAN_F = { id = "NIDORAN_F", name = "NIDORAN♀" } } }, + mapId = "FR_PALLET_TOWN", + mapClickCell = { cx = 2, cy = 15 }, + } +end + +local S3 = state(3, { { species = 201, level = 25 }, { species = 63, level = 5 } }) +Ops.selectParty(S3, 1) +T.eq(S3.note, "Selected party slot 1 (UNOWN)", "Gen 3 selection narrates the species name, not its number") +Ops.selectParty(S3, 2) +T.eq(S3.note, "Selected party slot 2 (ABRA)", "Gen 3 selection names ABRA, not 63") +T.eq(Ops.speciesName(S3, 201), "UNOWN", "speciesName resolves a Gen 3 species number") +T.eq(Ops.speciesName(S3, "MR_MIME"), "MR.MIME", "speciesName resolves a Gen 1 id to its display name") +T.eq(Ops.speciesName(S3, "NIDORAN_F"), "NIDORAN♀", "speciesName resolves the gendered Nidoran id") +T.eq(Ops.speciesName(S3, "NOT_A_MON"), "NOT_A_MON", "an unknown id falls back to itself") + +Ops.partyMove(S3, -1) +T.eq(S3.status, "Moved ABRA to slot 1", "partyMove names the species") +T.eq(S3.toast and S3.toast.text, S3.status, "the move toast carries the same text") + +local S1 = state(1, { { species = "MR_MIME", level = 5 } }) +Ops.selectParty(S1, 1) +T.eq(S1.note, "Selected party slot 1 (MR.MIME)", "Gen 1 selection shows the display name") + +T.check(Ops.setLastHeal(S3), "Gen 3 heal writes") +T.eq(S3.status, "Heal location set to FR_PALLET_TOWN (2,15)", "Gen 3 heal toast is plain words") +T.check(Ops.setPlayerHere(S3), "Gen 3 player writes") +T.eq(S3.status, "Player set to FR_PALLET_TOWN (2,15)", "player toast is plain words") + +local S2 = state(2, {}) +S2.mapId = "NEW_BARK_TOWN" +T.check(Ops.setLastHeal(S2), "Gen 2 spawn writes") +T.eq(S2.status, "Spawn point set to NEW_BARK_TOWN", "Gen 2 spawn toast is plain words") + +S1.mapId, S1.save.visited = "PALLET_TOWN", { PALLET_TOWN = true } +T.check(Ops.setLastHeal(S1), "Gen 1 heal writes") +T.eq(S1.status, "Last heal set to PALLET_TOWN (2,15)", "Gen 1 heal toast is plain words") +local map = { id = "PALLET_TOWN", def = { tileset = "OVERWORLD", connections = {} } } +T.check(Ops.setLastOutdoor(S1, map), "Gen 1 outdoor writes") +T.eq(S1.status, "Last outdoor map set to PALLET_TOWN (2,15)", "Gen 1 outdoor toast is plain words") + +for _, s in ipairs({ S1.status, S2.status, S3.status }) do + T.check(not s:find("%l%u"), "no internal field name leaks into: " .. s) +end + +T.finish("save_editor_status_words_s6") diff --git a/tests/engine/save_editor_toast_s6.lua b/tests/engine/save_editor_toast_s6.lua new file mode 100644 index 00000000..1c429518 --- /dev/null +++ b/tests/engine/save_editor_toast_s6.lua @@ -0,0 +1,222 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" +love = require("tests.love_stub") +local T = require("tests.harness") +local clock = 200 +love.timer.getTime = function() return clock end +local App = require("tools.save-editor.App") +local Ops, Gen, Kit = require("Ops"), require("Gen"), require("Kit") +local SD = require("src.core.SaveData") + +local path = os.tmpname() .. "-toast.lua" +local f = assert(io.open(path, "wb")) +f:write(SD.encode(Gen.newGame("red"))) +f:close() +local loaded = pcall(App.load, path, { version = "red", embedded = true }) +local S = App.getState() +if not loaded or not S or not S.save or S.missingCache then + os.remove(path) + print("[skip] save_editor_toast_s6: no red data") + os.exit(0) +end + +T.check(S.toast and (S.toast.kind == "ok" or S.toast.kind == "warn" and S.status:find("quarantine", 1, true)), + "loading a save raises a green toast, yellow when the game would quarantine") +T.eq(S.toast and S.toast.text, S.status, "the load toast says what the status says") + +while #S.save.party < 6 do Ops.partyAdd(S) end +clock = clock + 1 +Ops.partyAdd(S) +T.eq(S.toast and S.toast.kind, "error", "a refusal raises a red toast") +T.eq(S.toast and S.toast.text, S.status, "the refusal toast carries the status text") + +local mon = S.save.party[1] +clock = clock + 1 +Ops.setNickname(S, mon, "TOASTY") +T.eq(S.toast and S.toast.kind, "ok", "a successful edit raises a green toast") +clock = clock + 1 +Ops.setNickname(S, mon, "TOASTY") +T.eq(S.toast and S.toast.kind, "info", "a no-op edit raises a blue toast") + +clock = clock + 1 +Ops.arm(S, "party-remove-1", "Click Remove again to release slot 1") +T.eq(S.toast and S.toast.kind, "warn", "an armed confirm raises a yellow toast") +T.eq(S.toast and S.toast.hold, Ops.ARM_SECONDS, "the confirm toast lasts the arm window") +Ops.disarm(S) +T.eq(S.toast, nil, "disarming drops the confirm toast") + +clock = clock + 1 +Ops.say(S, "Party is full (6/6)") +local before = S.toast +Ops.selectParty(S, 2) +T.eq(S.toast, before, "selecting a slot does not toast") +T.check(S.note:find("Selected party slot 2", 1, true) ~= nil, "selection narration lands in the status bar note") + +clock = clock + 1 +Ops.mark(S, "Repeat edit") +local at = S.toast.at +clock = clock + .05 +Ops.mark(S, "Repeat edit") +T.eq(S.toast.at, at, "a repeated toast does not slide in again") + +clock = clock + 1 +T.check(App.save(), "scratch save writes") +T.eq(S.toast and S.toast.kind, "ok", "saving raises a green toast") +T.check(S.toast and S.toast.text:find("Saved ", 1, true) == 1, "the save toast names the save") + +T.eq(require("ItemPicker").feedback, nil, "the item picker has no private toast") + +local printed = {} +local oldPrint, oldOS = love.graphics.print, love.system.getOS +love.graphics.print = function(text, ...) + printed[#printed + 1] = tostring(text) + return oldPrint(text, ...) +end +local function frame(w, h, os) + love.graphics.getDimensions = function() return w, h end + love.window.getSafeArea = function() return 0, 0, w, h end + love.system.getOS = function() return os end + printed = {} + clock = clock + .3 + App.draw() +end +local function count(text) + local n = 0 + for _, line in ipairs(printed) do if line == text then n = n + 1 end end + return n +end +for _, shape in ipairs({ { 390, 844, "Android" }, { 1360, 860, "OS X" } }) do + S.tab, S.mobileInspector = "party", false + clock = clock + 10 + Ops.say(S, "Party is full (6/6)") + frame(shape[1], shape[2], shape[3]) + frame(shape[1], shape[2], shape[3]) + T.eq(count("Party is full (6/6)"), 1, "the result prints once, in the toast, at " .. shape[3]) + local r = S.toast and S.toast.rect + T.check(r and r[2] + r[4] <= shape[2] and r[1] >= 0 and r[1] + r[3] <= shape[1], + "the toast sits on screen at " .. shape[3]) + local selected, revision = S.selectedParty, S.revision + App.mousepressed(r[1] + r[3] / 2, r[2] + r[4] / 2, 1) + frame(shape[1], shape[2], shape[3]) + T.eq(S.toast, nil, "tapping the toast dismisses it at " .. shape[3]) + T.eq(S.selectedParty, selected, "the tap does not reach the row under the toast at " .. shape[3]) + T.eq(S.revision, revision, "the tap does not edit anything at " .. shape[3]) +end + +clock = clock + 10 +Ops.say(S, "Party is full (6/6)") +frame(390, 844, "Android") +local low = S.toast.rect[2] +Kit.focus = "mon-nickname" +frame(390, 844, "Android") +T.check(S.toast.rect[2] < 844 / 3 and S.toast.rect[2] < low, "a focused field on a phone moves the toast under the tab rail") +Kit.blur() + +local MapBrowser, ItemPicker = require("MapBrowser"), require("ItemPicker") +local function auditFrame(shape) + love.graphics.getDimensions = function() return shape[1], shape[2] end + love.window.getSafeArea = function() return 0, 0, shape[1], shape[2] end + love.system.getOS = function() return shape[3] end + clock = clock + .3 + Kit.audit = {} + App.draw() + local got = Kit.audit + Kit.audit = nil + return got +end +local function covered(audit, r) + local out = {} + for _, c in ipairs(audit) do + local x1, y1, x2, y2 = c.x, c.y, c.x + c.w, c.y + c.h + if c.clip then + x1, y1 = math.max(x1, c.clip.x), math.max(y1, c.clip.y) + x2, y2 = math.min(x2, c.clip.x + c.clip.w), math.min(y2, c.clip.y + c.clip.h) + end + if c.class == "control" and x1 < x2 and y1 < y2 and x1 < r[1] + r[3] and x2 > r[1] + and y1 < r[2] + r[4] and y2 > r[2] then + out[#out + 1] = c.label + end + end + return out +end +local function clear(name, shape) + local audit = auditFrame(shape) + local r = S.toast and S.toast.rect + T.check(r ~= nil, name .. ": the toast is drawn") + if not r then return end + local hits = covered(audit, r) + T.eq(#hits, 0, name .. ": no control sits under the toast [" .. table.concat(hits, ", ") .. "]") + T.check(r[1] >= 0 and r[1] + r[3] <= shape[1] and r[2] >= 0 and r[2] + r[4] <= shape[2], + name .. ": the toast stays on screen") +end +local phone, desktop = { 390, 844, "Android" }, { 1360, 860, "OS X" } + +S.tab = "items" +Ops.openItemPicker(S, Kit, "bag") +auditFrame(phone); auditFrame(phone) +local item = assert(Ops.itemSearch(S, "potion")[1]) +clock = clock + 10 +ItemPicker.commit(S, Kit, item) +T.eq(S.toast and S.toast.kind, "ok", "the picker add toasts") +clear("phone item picker after add", phone) +local picker = {} +for _, c in ipairs(auditFrame(phone)) do picker[c.label] = c end +T.check(picker["item-picker"] and picker.Bag and picker.PC, "the picker field and chips are still drawn") +local hdr = S.toastHeader +T.check(hdr and S.toast.rect[2] < picker.Bag.y and hdr.y <= S.toast.rect[2] + S.toast.rect[4], + "the picker toast sits on the popup header row above the chips") +clear("desktop item picker after add", desktop) +Ops.closeItemPicker(S, Kit) + +S.tab, S.mapSection, S.mapFocused = "map", "view", false +MapBrowser.select(S, "PALLET_TOWN") +auditFrame(phone); auditFrame(phone) +S.mapClickCell = { cx = 5, cy = 5 } +clock = clock + 10 +Ops.setLastHeal(S) +T.eq(S.toast and S.toast.kind, "ok", "Heal toasts on the phone map") +clear("phone map after Heal", phone) +auditFrame(desktop); auditFrame(desktop) +clock = clock + 10 +Ops.setLastHeal(S) +clear("desktop map after Heal", desktop) + +S.tab, S.mapSection = "map", "maps" +auditFrame(phone); auditFrame(phone) +clock = clock + 10 +Ops.say(S, "Jumped to PALLET_TOWN (5,5)", "ok") +clear("phone map list after a jump", phone) + +S.tab, S.mobileInspector = "party", false +Ops.selectParty(S, 1) +auditFrame(phone); auditFrame(phone) +clock = clock + 10 +Ops.setNickname(S, S.save.party[1], "TOASTIER") +clear("phone party after nickname edit", phone) + +S.mobileInspector, S.monSection, S.inspectorScroll = true, "main", 0 +auditFrame(phone); auditFrame(phone) +clock = clock + 10 +Ops.say(S, "Party is full (6/6)") +Kit.focus = "mon-nickname" +clear("phone focused nickname field", phone) +T.check(Kit.focusRect ~= nil, "the nickname field is drawn while focused") +Kit.blur() +S.mobileInspector = false + +auditFrame(desktop); auditFrame(desktop) +clock = clock + 10 +Ops.arm(S, "party-remove", "Click Remove again to release slot 1") +T.eq(S.toast and S.toast.kind, "warn", "arming Remove toasts on the desktop") +local remove +for _, c in ipairs(auditFrame(desktop)) do if c.label == "Confirm?" or c.label == "Remove" then remove = c end end +T.check(remove ~= nil, "the Remove button is drawn while armed") +if remove then + local r = S.toast.rect + T.check(r[2] + r[4] <= remove.y or r[2] >= remove.y + remove.h or r[1] >= remove.x + remove.w + or r[1] + r[3] <= remove.x, "the arm toast does not overlap the Remove button it names") +end +Ops.disarm(S) + +love.graphics.print, love.system.getOS = oldPrint, oldOS +os.remove(path) +T.finish("save_editor_toast_s6") diff --git a/tests/game3_fullscreen_display_test.lua b/tests/game3_fullscreen_display_test.lua index 42c9e9d3..75086c49 100644 --- a/tests/game3_fullscreen_display_test.lua +++ b/tests/game3_fullscreen_display_test.lua @@ -147,7 +147,7 @@ c = drawYesNo({ left = 21, top = 9 }, true) for _, e in ipairs(c) do if e[1] == "cursor" then cur = e end end check(cur and cur[3] == 88, "cursor on NO sits one 14 px row down") c = drawYesNo({ left = 20, top = 8 }) -check(c[1][2] == 20 and c[1][3] == 8 and c[1][4] == 6, "explicit left/top still honored with the 6-tile width") +check(c[1][2] == 21 and c[1][3] == 9 and c[1][4] == 6, "explicit left/top ignored: ScriptMenu_YesNo fixed template 21,9 6x4") print(("game3_fullscreen_display_test: %s (%d failed)"):format(failed == 0 and "PASS" or "FAIL", failed)) if failed > 0 then os.exit(1) end diff --git a/tests/game3_multichoice_window_test.lua b/tests/game3_multichoice_window_test.lua index b8bb8e3d..64d94538 100644 --- a/tests/game3_multichoice_window_test.lua +++ b/tests/game3_multichoice_window_test.lua @@ -89,7 +89,7 @@ Choice.yesNo(function() end, { left = 22, top = 8 }) Choice.draw() Choice.active = false local yn = frames[#frames] -check(yn ~= nil and yn[1] == 22, "yes/no after multichoice keeps its own left (22)") +check(yn ~= nil and yn[1] == 21, "yes/no after multichoice ignores left 22 (fixed template 21,9)") for i = #frames, 1, -1 do frames[i] = nil end Choice.multi({ "A", "B" }, 0, function() end, { left = 14, top = 2 }) diff --git a/tests/quick.list b/tests/quick.list index 6764250e..cd6d2023 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -858,3 +858,14 @@ tests/save_compat/gen3_cache_selection_test.lua tests/save_compat/regions_test.lua tests/save_editor_split_stack_bug2671_test.lua tests/save_editor_task6_tests.lua +tests/engine/game3_field_poison_step_gate_test.lua +tests/engine/game3_rs_layout_keys_test.lua +tests/engine/game3_rs_save_window_test.lua +tests/engine/game3_rs_coins_box_test.lua +tests/engine/game3_battle_yesno_frame_s7.lua +tests/engine/game3_rs_field_yesno_s2.lua +tests/engine/save_editor_mail_maxdv_spinda.lua +tests/engine/save_editor_toast_s6.lua +tests/engine/launcher_save_toast_s6.lua +tests/engine/save_editor_map_point_buttons_s8.lua +tests/engine/love_run_shell_matrix.lua diff --git a/tests/save_editor_gen3_tests.lua b/tests/save_editor_gen3_tests.lua index 180f8cd8..eae7a964 100644 --- a/tests/save_editor_gen3_tests.lua +++ b/tests/save_editor_gen3_tests.lua @@ -478,6 +478,7 @@ do -- Add to bag Ops.addToBag(S, "POTION") checkEq(save.inventory["13"], 1, "POTION added to bag x1") + checkEq(S.status, "Added POTION x1 to the bag", "the Gen 3 add toast says where the item went") check(save.bag ~= nil and save.bag.stacks ~= nil, "save.bag updated") -- Adjust bag quantity diff --git a/tests/save_editor_mobile_properties_tests.lua b/tests/save_editor_mobile_properties_tests.lua index 9ea6d3e3..0e92c70d 100644 --- a/tests/save_editor_mobile_properties_tests.lua +++ b/tests/save_editor_mobile_properties_tests.lua @@ -293,11 +293,12 @@ for _, size in ipairs({ end end S.tab = "map" - for _, section in ipairs({ "maps", "view", "spawn" }) do + for _, section in ipairs({ "maps", "view" }) do S.mapSection = section frame("map " .. section, W, H) - S.mapSpawnScroll = 10000 - frame("map scrolled " .. section, W, H) + S.mapClickCell = { cx = 1, cy = 1 } + frame("map selected " .. section, W, H) + S.mapClickCell = nil end S.mapSection, S.mapFocused = "view", true frame("focused landscape map", W, H) @@ -357,7 +358,7 @@ for _, size in ipairs({ { "items", "itemView", 4 }, { "boxes", "boxView", 2 }, { "events", "eventsTab", 4 }, - { "map", "mapSection", 3 }, + { "map", "mapSection", 2 }, }) do S.tab, S.monSection, S.itemView, S.boxView, S.mapSection = case[1], "main", "bag", "storage", "view" @@ -810,13 +811,10 @@ App.touchpressed("map-cell", x, y) App.touchreleased("map-cell", x, y) App.draw() check(S.mapClickCell and S.mapClickCell.cx == cx and S.mapClickCell.cy == cy, "tap selects the intended map cell") -S.mapSection = "spawn" -App.draw() -tapMapControl("Set here") +tapMapControl("Player") local playerMap, px, py = require("Gen").playerMap(S.save) -check(playerMap == mapId and px == cx and py == cy, "mobile spawn control uses the selected cell") -S.mapSection = "view" -App.draw() +check(playerMap == mapId and px == cx and py == cy, "mobile Player button on the map uses the selected cell") +check(S.mapClickCell and S.mapClickCell.cx == cx and S.mapClickCell.cy == cy, "tapping the map button does not reselect the cell under it") tapMapControl("Back to editor") check(not S.mapFocused and S._mapViewH == ordinaryHeight, "return restores the regular editor layout") tapMapControl("Focus map") diff --git a/tests/save_editor_task8_tests.lua b/tests/save_editor_task8_tests.lua index dc824e2d..9f055fc7 100644 --- a/tests/save_editor_task8_tests.lua +++ b/tests/save_editor_task8_tests.lua @@ -129,7 +129,7 @@ do eq(S.mapClickCell, nil, "select drops the previous cell selection") eq(S._mapCenteredFor, nil, "select defers centring to the next draw, which knows the viewport size") - check(S.status:match("VIRIDIAN_CITY") ~= nil, "select says what it is showing") + check(S.note:match("VIRIDIAN_CITY") ~= nil, "select says what it is showing") end do diff --git a/tests/save_editor_touch_controls_test.lua b/tests/save_editor_touch_controls_test.lua index 9fa9c823..22383913 100644 --- a/tests/save_editor_touch_controls_test.lua +++ b/tests/save_editor_touch_controls_test.lua @@ -316,8 +316,6 @@ check(App.save(), "bulk-edited save writes") check(App.reload(), "bulk-edited save reloads") S = App.getState() check(require("Legality").save(S).errors == 0, "bulk edits survive save round trip") --- Result clicks and keyboard adds report inside the open picker, where the --- dimmed status bar cannot provide visible confirmation. local picker = require("ItemPicker") local item = assert(Ops.itemSearch(S, "potion")[1]) local oldClock, oldPrint = love.timer.getTime, love.graphics.print @@ -331,27 +329,37 @@ local function pickerFrame() printed = {} App.draw() end +local function shown(text) + for line in pairs(printed) do + if #line >= 8 and text:sub(1, #line) == line then return true end + end + return false +end S.tab = "items" Ops.openItemPicker(S, Kit, "bag") pickerFrame() pickerFrame() +check(picker.feedback == nil and picker.drawFeedback == nil, "picker has no private toast") check(picker.commit(S, Kit, item), "bag add succeeds") pickerFrame() -check(printed["Added to Bag"] and S.itemPicker ~= nil, "bag toast renders without closing picker") -toastClock = toastClock + 2.6 +local added = S.toast and S.toast.text +check(S.toast and S.toast.kind == "ok", "bag add raises a green toast") +check(added and shown(added) and S.itemPicker ~= nil, "bag toast renders over the open picker") +toastClock = toastClock + 3.5 + 0.25 + 0.01 pickerFrame() -check(not printed["Added to Bag"], "toast disappears automatically") +check(not shown(added) and S.toast == nil, "toast disappears automatically") S.itemPicker.dest = "pc" App.textinput("potion") App.keypressed("return") pickerFrame() -check(printed["Added to PC"], "keyboard add renders PC toast") +check(S.toast and S.toast.kind == "ok" and S.toast.text:find("PC", 1, true) and shown(S.toast.text), + "keyboard add renders PC toast") local realAdd = Ops.addToBag Ops.addToBag = function(state) return Ops.say(state, "Bag is full") end S.itemPicker.dest = "bag" check(not picker.commit(S, Kit, item), "refused add does not report success") pickerFrame() -check(printed["Item not added"] and not printed["Added to Bag"], "refused add has visible failure feedback") +check(S.toast and S.toast.kind == "error" and printed["Bag is full"], "refused add has visible failure feedback") Ops.addToBag = realAdd Ops.closeItemPicker(S, Kit) love.timer.getTime, love.graphics.print = oldClock, oldPrint diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 5c8ec520..ae9771a4 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -26,7 +26,32 @@ local Gen = require("Gen") local PadInput = require("PadInput") local Motion = require("Motion") local Chooser = require("Chooser") +local Toast = require("src.ui.kit.Toast") local PAL = Theme.PAL +local toastArea = {} +local toastSlots = {} + +local function placeClearOfControls(x, w, h) + local rects, n = Kit.trackedControls() + local gap = 12 * toastSlots.s + local best, bestHits, bestAbove + local function try(y, fromAbove, limit) + if y < toastSlots.minY or y + h > (limit or toastSlots.maxY) then return end + local hits = 0 + for i = 1, n do + local r = rects[i] + if r[1] < x + w and r[1] + r[3] > x and r[2] < y + h and r[2] + r[4] > y then hits = hits + 1 end + end + if not best or hits < bestHits then best, bestHits, bestAbove = y, hits, fromAbove end + end + local fr = toastSlots.field + try(fr.y + fr.h + gap, true) + try(fr.y - h - gap, false) + try(toastSlots.contentY + gap, true) + try(toastSlots.maxY - h - gap, false) + try(toastSlots.screenBottom - h - 4 * toastSlots.s, false, toastSlots.screenBottom) + return best, bestAbove +end local Party = require("Party") local Boxes = require("Boxes") @@ -100,6 +125,7 @@ local function applyLoaded(path, statusVerb) S.path = path local existed = fileExists(path) local save, err = SaveIO.load(path) + local kind = "ok" if save then S.save = save S.status = statusVerb .. " " .. path @@ -112,11 +138,13 @@ local function applyLoaded(path, statusVerb) .. " (" .. tostring(err) .. "), Save disabled, use Reload after fixing the file" + kind = "error" S.loadError = true S.allowSave = false else S.save = Gen.newGame(S.version) S.status = "No save at " .. path .. " (" .. tostring(err) .. "), editing new game stub" + kind = "info" S.loadError = false S.allowSave = true end @@ -150,6 +178,7 @@ local function applyLoaded(path, statusVerb) if not prepared then S.loadError, S.allowSave = true, false S.status = "Save disabled: " .. tostring(prepareError) + Toast.show(S, S.status, "error", { sticky = true }) return end local probe = require("src.mods.Merge").deepCopy(S.save) @@ -172,7 +201,9 @@ local function applyLoaded(path, statusVerb) #S.validation.remappedMaps ) end + if kind == "ok" and S.status:find("game would quarantine", 1, true) then kind = "warn" end end + Toast.show(S, S.status, kind, { sticky = kind == "error" }) end -- pathOverride lets tests point App.load at a scratch file instead of the @@ -262,7 +293,7 @@ function App.openPath(path, force) end if S.dirty and not force and not S._openArmed then S._openArmed = true - S.status = "Unsaved changes, open again to discard and load " .. path + Ops.say(S, "Unsaved changes, open again to discard and load " .. path, "warn") return false end applyLoaded(path, "Opened") @@ -276,7 +307,7 @@ function App.chooseAndOpen() else local osName = love and love.system and love.system.getOS and love.system.getOS() if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then - S.status = "File picker unavailable, drop a save.lua onto the window" + Ops.say(S, "File picker unavailable, drop a save.lua onto the window") end end end @@ -287,7 +318,7 @@ function App.filedropped(file) end local path = file.getFilename and file:getFilename() or nil if not path or path == "" then - S.status = "Could not read dropped file path" + Ops.say(S, "Could not read dropped file path") return end App.openPath(path) @@ -336,7 +367,7 @@ local function cycleTab(delta) end idx = ((idx - 1 + delta) % #TABS) + 1 Motion.change(S, "tab", TABS[idx].id, delta) - Ops.say(S, "Tab: " .. TABS[idx].label) + Ops.note(S, "Tab: " .. TABS[idx].label) end -- Pad / Joy-Con actions from PadInput.gamepadpressed (A/B via GamepadMap so @@ -386,7 +417,7 @@ function App.save() if Gen.ofState(S) == 3 then local prepared, result = pcall(require("Game3Adapter").export, S.save) if not prepared then - S.status = "Save failed: " .. tostring(result) + Ops.say(S, "Save failed: " .. tostring(result)) return false end output = result @@ -397,10 +428,10 @@ function App.save() S.historySavedToken = S.historyToken or 0 S._quitArmed = false Ops.disarm(S) - S.status = "Saved " .. S.path + Ops.say(S, "Saved " .. S.path, "ok") return true end - S.status = "Save failed: " .. tostring(err) + Ops.say(S, "Save failed: " .. tostring(err)) return false end @@ -413,7 +444,7 @@ function App.reload() applyLoaded(S.path, "Reloaded") return not S.loadError end - S.status = "Reload failed: " .. tostring(err) + Ops.say(S, "Reload failed: " .. tostring(err)) return false end @@ -430,7 +461,7 @@ function App.close() end if S.dirty and not S._quitArmed then S._quitArmed = true - S.status = "Unsaved changes, Save first or click Close again to discard" + Ops.say(S, "Unsaved changes, Save first or click Close again to discard", "warn") return false end S._closeRequested = true @@ -800,11 +831,6 @@ local function drawStatusBar(x, y, w, h) .. ctrl .. "+R reload . Esc clear selection . arrows pan map . wheel scrolls lists" ) - -- The status message is the load-bearing half of this bar (every Ops verb - -- narrates through it); the keyboard map is decoration. On a phone the - -- two used to overlap because the hint was drawn unconditionally and the - -- status ellipsized against a negative budget (#715), so now the hint only - -- draws when the status still keeps a readable share of the bar. local hintW = Kit.textWidth("tiny", hint) local avail = w - 2 * pad - hintW - 14 * s if avail >= 120 * s then @@ -814,7 +840,7 @@ local function drawStatusBar(x, y, w, h) end Kit.text( "mono", - Kit.ellipsize("mono", S.status or "", avail), + Kit.ellipsize("mono", S.note or "", avail), x + pad, y + (h - Kit.textHeight("mono")) / 2, PAL.detail @@ -860,8 +886,13 @@ function App.draw() elseif padOn then mx, my = padX, padY end + if mouseClicked and clickX ~= nil and Toast.hit(S, clickX, clickY) then + Toast.clear(S) + mouseClicked = false + end Motion.update() Kit.beginFrame(mx, my, mouseClicked, wheelY) + Kit.trackControls = S.toast ~= nil and Kit.focus ~= nil and not Kit.desktop mouseClicked = false clickX, clickY = nil, nil wheelY = 0 @@ -931,6 +962,7 @@ function App.draw() local contentY = oy + railH + titleH + tabH local contentH = sh - railH - titleH - tabH - statusH + S.toastBottom, S.toastHeader = nil, nil local px, py = ox + 10 * s, contentY + 8 * s local pw, ph = sw - 20 * s, math.max(1, contentH - 16 * s) local ok, err = xpcall(function() @@ -978,6 +1010,28 @@ function App.draw() MovePicker.draw(S, Kit, width, height) ItemPicker.draw(S, Kit, width, height) end + if S.toast then + local a = toastArea + a.x, a.w, a.s, a.font = ox, sw, s, Kit.fonts.small + a.top, a.bottom, a.maxY, a.centerY, a.width, a.maxLines, a.place = nil, nil, nil, nil, nil, nil, nil + local hdr, fr = S.toastHeader, Kit.focusRect + if hdr then + a.x, a.w, a.width, a.maxLines = hdr.x, hdr.w, hdr.w, 2 + a.centerY = hdr.y + hdr.h / 2 + elseif Kit.focus and not Kit.desktop then + if fr then + a.top, a.bottom, a.maxY = fr.y + fr.h, fr.y, oy + sh - statusH + toastSlots.s, toastSlots.field, toastSlots.contentY = s, fr, contentY + toastSlots.minY, toastSlots.maxY, toastSlots.screenBottom = oy, oy + sh - statusH, oy + sh + a.place = placeClearOfControls + else + a.top = contentY + end + else + a.bottom = math.min(S.toastBottom or math.huge, oy + sh - statusH) + end + Toast.draw(S, a) + end Kit.endFrame() PadInput.draw() @@ -1075,7 +1129,7 @@ function App.keypressed(key) and (love.keyboard.isDown("lgui", "rgui") or love.keyboard.isDown("lctrl", "rctrl")) if key == "escape" and (S.itemMenu or S.chromeMenu) then S.itemMenu, S.chromeMenu = nil, false - Ops.say(S, "Menu closed") + Ops.note(S, "Menu closed") return end if key == "escape" and S.tab == "map" and S.mapFocused then @@ -1087,7 +1141,7 @@ function App.keypressed(key) if key == "escape" then S.editingMon = nil Ops.disarm(S) - Ops.say(S, "Selection cleared") + Ops.note(S, "Selection cleared") elseif key == "z" and mod then if love.keyboard.isDown("lshift", "rshift") then require("History").redo(S) @@ -1114,8 +1168,8 @@ function App.wheelmoved(x, y) wheelY = wheelY + (y or 0) return end - -- Only the map viewport spends the wheel on zoom. Search results and - -- spawn cards keep the launcher's normal scrolling under the pointer. + -- Only the map viewport spends the wheel on zoom. Search results keep the + -- launcher's normal scrolling under the pointer. local mx, my = love.mouse.getPosition() if S.tab == "map" @@ -1138,7 +1192,7 @@ function App.quit() -- simple: block quit once and set status; user saves or force-quits again if not S._quitArmed then S._quitArmed = true - S.status = "Unsaved changes, save or press quit again" + Ops.say(S, "Unsaved changes, save or press quit again", "warn") return true end end diff --git a/tools/save-editor/History.lua b/tools/save-editor/History.lua index 6f0000d1..8e75df27 100644 --- a/tools/save-editor/History.lua +++ b/tools/save-editor/History.lua @@ -1,6 +1,7 @@ -- A bounded, session-only undo stack. Snapshots are made on user mutations, -- never on draw. Deep copies preserve metadata the editor does not expose. local Copy = require("src.mods.Merge").deepCopy +local Toast = require("src.ui.kit.Toast") local H = { LIMIT = 8 } function H.capture(S) return { save = Copy(S.save), dirty = S.dirty, token = S.historyToken or 0 } @@ -30,23 +31,27 @@ end function H.undo(S) if not S.undoStack or #S.undoStack == 0 then S.status = "Nothing to undo" + Toast.show(S, S.status, "info") return false end S.redoStack = S.redoStack or {} S.redoStack[#S.redoStack + 1] = H.capture(S) restore(S, table.remove(S.undoStack)) S.status = "Undid the last edit" + Toast.show(S, S.status, "ok") return true end function H.redo(S) if not S.redoStack or #S.redoStack == 0 then S.status = "Nothing to redo" + Toast.show(S, S.status, "info") return false end S.undoStack = S.undoStack or {} S.undoStack[#S.undoStack + 1] = H.capture(S) restore(S, table.remove(S.redoStack)) S.status = "Redid the last edit" + Toast.show(S, S.status, "ok") return true end return H diff --git a/tools/save-editor/InspectorBody.lua b/tools/save-editor/InspectorBody.lua index cfa0f2cc..ab90c56f 100644 --- a/tools/save-editor/InspectorBody.lua +++ b/tools/save-editor/InspectorBody.lua @@ -346,7 +346,7 @@ local function drawSection(S, Kit, x, y, w, h, report, issues) Ops.maxMon(S, mon) end, "good", - "Level 100, max IVs or DVs, friendship and PP. Spare EVs go to the strongest stats. Fully heals." + "Level 100, max IVs or DVs, friendship and PP. Spare EVs go to the strongest stats. Fully heals. In Gen 2 a shiny stays shiny, an Unown keeps its letter, and gender can change." ) button("Full heal", function() Ops.healMon(S, mon) @@ -400,7 +400,7 @@ local function drawSection(S, Kit, x, y, w, h, report, issues) else button("Max all DVs", function() Ops.maxDvs(S, mon) - end, "good", "Sets DVs to 15. In Gen 2 this can change gender and shininess.") + end, "good", "Sets DVs to 15. In Gen 2 a shiny keeps its shiny DVs (Atk 15, others 10), an Unown keeps its letter, and gender can change.") button("Max stat training", function() Ops.maxStatExp(S, mon) end, "good", "Fills stat experience for every stat.") diff --git a/tools/save-editor/Kit.lua b/tools/save-editor/Kit.lua index f14dc546..55cfc590 100644 --- a/tools/save-editor/Kit.lua +++ b/tools/save-editor/Kit.lua @@ -21,6 +21,10 @@ Kit.mouseX, Kit.mouseY = 0, 0 Kit.mouseClicked = false -- left button pressed this frame Kit.wheelY = 0 -- wheel notches queued since the last frame (#595) Kit.focus = nil -- id of the text field receiving keystrokes +Kit.focusRect = nil +local focusRect = {} +Kit.trackControls = false +local tracked, trackedN = {}, 0 Kit.time = 0 Kit.fonts = {} Kit.scale = 1 @@ -72,6 +76,7 @@ function Kit.beginFrame(mx, my, clicked, wheel) Kit.mouseClicked = clicked Kit._fieldHit = false Kit._focusDrawn = false + trackedN = 0 Kit.wheelY = wheel or 0 -- Mouse taps dispatch on release, so dragging never activates a row. -- The real touch stream supplies its own precise drag deltas. SDL @@ -214,6 +219,7 @@ end function Kit.blur() Kit.focus = nil + Kit.focusRect = nil syncSoftKeyboard(nil) -- the soft keyboard follows focus down too (#529) end @@ -245,9 +251,36 @@ end -- a modal they cannot take the tap, and the modal legitimately covers them. Kit.audit = nil +local function track(x, y, w, h) + local c = Kit._clipRect + if c then + local x2, y2 = math.min(x + w, c.x + c.w), math.min(y + h, c.y + c.h) + x, y = math.max(x, c.x), math.max(y, c.y) + w, h = x2 - x, y2 - y + if w <= 0 or h <= 0 then return end + end + trackedN = trackedN + 1 + local r = tracked[trackedN] + if not r then + r = {} + tracked[trackedN] = r + end + r[1], r[2], r[3], r[4] = x, y, w, h +end + +function Kit.trackedControls() + return tracked, trackedN +end + local function audit(class, x, y, w, h, label) + if Kit.blockClicks then + return + end + if class == "control" and Kit.trackControls then + track(x, y, w, h) + end local a = Kit.audit - if not a or Kit.blockClicks then + if not a then return end local c = Kit._clipRect @@ -685,6 +718,8 @@ function Kit.textfield(id, x, y, w, h, value, placeholder, opts) end if focused then Kit._focusDrawn = true + focusRect.x, focusRect.y, focusRect.w, focusRect.h = x, y, w, h + Kit.focusRect = focusRect -- raise (or hand off) the soft keyboard while this field owns focus (#529) syncSoftKeyboard(id, x, y, w, h) for _, edit in ipairs(edits) do diff --git a/tools/save-editor/MonActions.lua b/tools/save-editor/MonActions.lua index a4180757..1e3eefdb 100644 --- a/tools/save-editor/MonActions.lua +++ b/tools/save-editor/MonActions.lua @@ -178,7 +178,7 @@ end function A.fixMon(S, mon) local Ops, L = require("Ops"), require("Legality") if L.mon(S, mon).errors == 0 then - return Ops.say(S, "No property errors to fix") + return Ops.say(S, "No property errors to fix", "info") end local staged, err = A.repair(S, mon) if not staged then @@ -205,7 +205,7 @@ function A.fixAll(S) msg = msg .. "; " .. left .. " need a manual choice" end if fixed == 0 then - return require("Ops").say(S, left > 0 and msg or "No property errors to fix") + return require("Ops").say(S, left > 0 and msg or "No property errors to fix", left > 0 and "error" or "info") end return require("Ops").mark(S, msg .. ". Origin warnings stay for review.") end @@ -218,6 +218,7 @@ function A.maxMon(S, mon) if staged.egg or staged.isEgg then return Ops.say(S, "Hatch the egg before maxing it out") end + local keptShiny M.setLevel(S.data, staged, 100, g) M.setHappiness(S.data, staged, 255, g) if g == 3 then @@ -240,6 +241,11 @@ function A.maxMon(S, mon) for _, k in ipairs(keys) do M.setEv(S.data, staged, k, math.max(staged.evs[k] or 0, 252), g) end + elseif g == 2 then + keptShiny = M.maxGen2Dvs(S.data, staged, g) + for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do + staged.statExp[k] = 65535 + end else for _, k in ipairs({ "attack", "defense", "speed", "special" }) do M.setDv(S.data, staged, k, 15, g) @@ -261,6 +267,9 @@ function A.maxMon(S, mon) end end replace(mon, staged) + if keptShiny then + return Ops.mark(S, "Maxed level, stats, friendship and PP; fully healed (kept shiny: Def/Spd/Spc DVs stay 10)") + end return Ops.mark(S, "Maxed level, stats, friendship and PP; fully healed") end -- pokefirered/src/wild_encounter.c:49 sUnownLetterSlots, by chamber. diff --git a/tools/save-editor/MonOps.lua b/tools/save-editor/MonOps.lua index 1e4c5ec4..bfe5ad09 100644 --- a/tools/save-editor/MonOps.lua +++ b/tools/save-editor/MonOps.lua @@ -191,6 +191,58 @@ local function unownReq(mon) return require("src.core.game3.pokemon").unownLetter(mon.personality) end +-- src/pokemon.c:5751 DRAW_SPINDA_SPOTS +local function spindaPid(base, tsv, accept, shinyOnly) + local bit = require("bit") + base = (tonumber(base) or 0) % 4294967296 + local ob = {} + for i = 0, 3 do ob[i] = bit.band(bit.rshift(base, 8 * i), 0xFF) end + local best, bestCost + local function try(p) + if not accept(p) then return end + local cost = 0 + for i = 0, 3 do + local b = bit.band(bit.rshift(p, 8 * i), 0xFF) + if b ~= ob[i] then + cost = cost + 1000 + math.abs(b % 16 - ob[i] % 16) + + math.abs(bit.rshift(b, 4) - bit.rshift(ob[i], 4)) + end + end + if not bestCost or cost < bestCost then best, bestCost = p, cost end + end + if shinyOnly then + for low = 0, 65535 do + local h0 = bit.bxor(low, tsv) + for s = 0, 7 do try(bit.bxor(h0, s) * 65536 + low) end + end + return best + end + try(base) + if best then return best end + local function with(p, i, v) + return p + (v - bit.band(bit.rshift(p, 8 * i), 0xFF)) * 256 ^ i + end + for i = 0, 3 do + for v = 0, 255 do + if v ~= ob[i] then try(with(base, i, v)) end + end + end + if best then return best end + for i = 0, 2 do + for j = i + 1, 3 do + for v = 0, 255 do + if v ~= ob[i] then + local pi = with(base, i, v) + for w = 0, 255 do + if w ~= ob[j] then try(with(pi, j, w)) end + end + end + end + end + end + return best +end + function MonOps.generatePid(species, otId, otSecretId, reqs) reqs = reqs or {} local PokemonG3 = require("src.core.game3.pokemon") @@ -213,6 +265,30 @@ function MonOps.generatePid(species, otId, otSecretId, reqs) local targetGender = reqs.gender local targetShiny = reqs.shiny + if spId == PokemonG3.SPECIES_SPINDA and reqs.basePid ~= nil then + local p = spindaPid(reqs.basePid, trainerXor, function(p) + if p == 0 then return false end + if targetNature ~= nil and p % 25 ~= targetNature then return false end + local b0 = p % 256 + if targetAbility ~= nil and b0 % 2 ~= targetAbility then return false end + if targetGender ~= nil and targetGender ~= "" and targetGender ~= "U" then + local g + if ratio == PokemonG3.GENDER_MALE then g = "M" + elseif ratio == PokemonG3.GENDER_FEMALE then g = "F" + elseif ratio == PokemonG3.GENDER_GENDERLESS then g = "U" + elseif ratio > b0 then g = "F" + else g = "M" end + if g ~= targetGender then return false end + end + if targetShiny ~= nil then + local isShiny = bit.bxor(trainerXor, bit.bxor(math.floor(p / 65536), p % 65536)) < 8 + if isShiny ~= (targetShiny and true or false) then return false end + end + return true + end, targetShiny == true) + if p then return p end + end + -- 1. Determine valid low-byte (b0) for gender & ability slot local validB0 = {} for b = 0, 255 do @@ -672,6 +748,24 @@ function MonOps.unownDvs(dvs, letter, keepShiny) return best end +-- pokecrystal/engine/gfx/color.asm:8 CheckShininess, pokecrystal/engine/battle/hidden_power.asm:13 +function MonOps.maxGen2Dvs(data, mon, gen) + if type(mon) ~= "table" then return end + local Mon = require("src.battle.gen2.Mon") + local Unown = require("src.core.gen2.Unown") + mon.dvs = mon.dvs or {} + local keepShiny = mon.shiny == true or Mon.vanillaShiny(mon.dvs) + local out = keepShiny and { attack = 15, defense = 10, speed = 10, special = 10 } + or { attack = 15, defense = 15, speed = 15, special = 15 } + if mon.species == Unown.SPECIES then + out = MonOps.unownDvs(out, Unown.monLetter(mon), keepShiny) or out + end + for k, v in pairs(out) do mon.dvs[k] = v end + deriveGen2(data, mon) + MonOps.recalc(data, mon, gen) + return keepShiny and mon.shiny == true +end + function MonOps.setUnownForm(data, mon, letter, gen) if type(mon) ~= "table" then return nil end if gen == 3 then diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index e1994a6f..fca0c94b 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -18,6 +18,7 @@ local Bag = require("src.inventory.Bag") local MonOps = require("MonOps") local Charmap = require("src.save_convert.data.charmap") local Gen = require("Gen") +local Toast = require("src.ui.kit.Toast") local Ops = {} local G3 = require("Game3Adapter") @@ -46,6 +47,12 @@ local function clamp(n, lo, hi) end Ops.clamp = clamp +local function speciesName(S, species) + local def = species and S.data and S.data.pokemon and S.data.pokemon[species] + return (def and def.name) or tostring(species or "") +end +Ops.speciesName = speciesName + local function stampNewMon(S, mon) if Gen.ofState(S) == 3 then mon.ot = (S.save.player and S.save.player.name) or S.save.name or "RED" @@ -83,6 +90,15 @@ local function partyMailEntry(S, slot) end local function syncPartyMailSpecies(S, mon) + if Gen.ofState(S) == 3 then + local idx = tonumber(mon and mon.mail) + local rec = idx and partySlot(S, mon) and type(S.save.mail) == "table" and S.save.mail[idx + 1] + if type(rec) == "table" and (tonumber(rec.itemId) or 0) ~= 0 then + -- pokeemerald/src/mail_data.c:72, pokefirered/src/mail_data.c:65 + rec.species = require("src.core.game3.mail").speciesToMailSpecies(mon.speciesId or mon.species, mon.personality) + end + return + end if Gen.ofState(S) ~= 2 then return end local slot = partySlot(S, mon) if not slot then return end @@ -121,12 +137,13 @@ end -- ------------------------------------------------------------ the funnel -- Mark the save dirty and say what changed. Also disarms any pending -- destructive confirmation: doing something else is an implicit "no". -function Ops.mark(S, msg) +function Ops.mark(S, msg, kind) S.revision = (S.revision or 0) + 1 S.historyToken = S.revision S.dirty = true S.status = msg or S.status S.armed = nil + if msg then Toast.show(S, msg, kind or "ok") end -- A fresh edit invalidates any prior "leave anyway" arming: quitting, -- closing or opening another file has to be confirmed again, so a stale -- confirmation from an earlier round of edits cannot discard these. @@ -137,8 +154,14 @@ end -- Status-only: used by the branches that refuse (party full, box full, no -- cell selected). A refusal must still speak, it must just not dirty. -function Ops.say(S, msg) +function Ops.say(S, msg, kind, opts) S.status = msg + Toast.show(S, msg, kind or "error", opts) + return false +end + +function Ops.note(S, msg) + S.note = msg or "" return false end @@ -156,6 +179,7 @@ function Ops.arm(S, id, msg) end S.armed, S.armedAt = id, t S.status = msg + Toast.show(S, msg, "warn", { hold = Ops.ARM_SECONDS }) return false end @@ -171,6 +195,7 @@ function Ops.armLabel(S, id, label) end function Ops.disarm(S) + if S.armed and S.toast and S.toast.kind == "warn" and S.toast.text == S.status then Toast.clear(S) end S.armed, S.armedAt = nil, nil end @@ -180,7 +205,7 @@ function Ops.selectParty(S, index) if not mon then return false end S.selectedParty = index S.editingMon = mon - S.status = ("Selected party slot %d (%s)"):format(index, mon.species) + Ops.note(S, ("Selected party slot %d (%s)"):format(index, speciesName(S, mon.species))) return true end @@ -193,7 +218,7 @@ function Ops.partyAdd(S) table.insert(S.save.party, mon) S.selectedParty = #S.save.party S.editingMon = mon - return Ops.mark(S, ("Added %s Lv5 to party slot %d"):format(species, #S.save.party)) + return Ops.mark(S, ("Added %s Lv5 to party slot %d"):format(speciesName(S, species), #S.save.party)) end function Ops.partyRemove(S) @@ -201,7 +226,7 @@ function Ops.partyRemove(S) local mon = S.save.party[index] if not mon then return Ops.say(S, "No party slot selected") end if not Ops.arm(S, "party-remove", - ("Remove %s from slot %d? Click again to confirm"):format(mon.species, index)) then + ("Remove %s from slot %d? Click again to confirm"):format(speciesName(S, mon.species), index)) then return false end table.remove(S.save.party, index) @@ -213,7 +238,7 @@ function Ops.partyRemove(S) if S.editingMon == mon then S.editingMon = nil end S.selectedParty = clamp(index, 1, math.max(#S.save.party, 1)) S.editingMon = S.save.party[S.selectedParty] - return Ops.mark(S, ("Removed %s from the party"):format(mon.species)) + return Ops.mark(S, ("Removed %s from the party"):format(speciesName(S, mon.species))) end -- delta is -1 (up) or +1 (down); the selection follows the mon. @@ -222,14 +247,14 @@ function Ops.partyMove(S, delta) local j = i + delta local party = S.save.party if not (party[i] and party[j]) then - return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon") + return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon", "info") 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)) + return Ops.mark(S, ("Moved %s to slot %d"):format(speciesName(S, party[j].species), j)) end -- ------------------------------------------------------- selected mon edits @@ -239,10 +264,10 @@ function Ops.setLevel(S, mon, level) if not mon then return false end local want = clamp(math.floor(level), 1, 100) if want == mon.level then - return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100") + return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100", "info") end MonOps.setLevel(S.data, mon, want, Gen.ofState(S)) - return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level)) + return Ops.mark(S, ("%s is now Lv%d"):format(speciesName(S, mon.species), mon.level)) end -- A catalog id is only usable as a real mon when its record carries what the @@ -293,7 +318,7 @@ end function Ops.setSpecies(S, mon, id) if not mon then return false end if id == mon.species then - return Ops.say(S, ("Already a %s"):format(tostring(id))) + return Ops.say(S, ("Already a %s"):format(speciesName(S, id)), "info") end if not Ops.speciesUsable(S, id) then return Ops.say(S, ("%s has no usable base stats, cannot assign it") @@ -314,7 +339,7 @@ function Ops.setSpecies(S, mon, id) 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)) + return Ops.mark(S, ("Species set to %s"):format(speciesName(S, id))) end -- Kept for the keyboard and test path; the inspector opens the searchable @@ -493,14 +518,14 @@ function Ops.boxAddSpecies(S, id) S.selectedBoxSlot = boxInsert(S, box, mon) S.editingMon = mon return Ops.mark(S, ("Added %s Lv5 to box %d slot %d") - :format(id, S.selectedBox, S.selectedBoxSlot)) + :format(speciesName(S, id), S.selectedBox, S.selectedBoxSlot)) end function Ops.setDv(S, mon, key, value) if not mon then return false end local want = clamp(math.floor(value), 0, 15) if want == mon.dvs[key] then - return Ops.say(S, ("%s DV is already %d"):format(key, want)) + return Ops.say(S, ("%s DV is already %d"):format(key, want), "info") end 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)) @@ -512,7 +537,7 @@ function Ops.setEv(S, mon, key, value) local cur = tonumber(mon.evs[key]) or 0 local applied = MonOps.setEv(S.data, mon, key, value, Gen.ofState(S)) if applied == cur then - return Ops.say(S, ("%s EV is already %d"):format(key:upper(), cur)) + return Ops.say(S, ("%s EV is already %d"):format(key:upper(), cur), "info") end return Ops.mark(S, ("%s EV set to %d"):format(key:upper(), applied or 0)) end @@ -520,7 +545,7 @@ end function Ops.clearEvs(S, mon) if not mon then return false end MonOps.clearEvs(S.data, mon, Gen.ofState(S)) - return Ops.mark(S, ("Cleared EVs for %s"):format(mon.species or "Pokémon")) + return Ops.mark(S, ("Cleared EVs for %s"):format(speciesName(S, mon.species or "Pokémon"))) end function Ops.setIv(S, mon, key, value) @@ -529,7 +554,7 @@ function Ops.setIv(S, mon, key, value) local cur = tonumber(mon.ivs[key]) or 0 local applied = MonOps.setIv(S.data, mon, key, value, Gen.ofState(S)) if applied == cur then - return Ops.say(S, ("%s IV is already %d"):format(key:upper(), cur)) + return Ops.say(S, ("%s IV is already %d"):format(key:upper(), cur), "info") end return Ops.mark(S, ("%s IV set to %d"):format(key:upper(), applied or 0)) end @@ -537,7 +562,7 @@ end function Ops.maxIvs(S, mon) if not mon then return false end MonOps.maxIvs(S.data, mon, Gen.ofState(S)) - return Ops.mark(S, ("Maxed IVs (31) for %s"):format(mon.species or "Pokémon")) + return Ops.mark(S, ("Maxed IVs (31) for %s"):format(speciesName(S, mon.species or "Pokémon"))) end function Ops.setPpUps(S, mon, slot, value) @@ -549,7 +574,7 @@ function Ops.setPpUps(S, mon, slot, value) return Ops.say(S, "Eggs and this move cannot use PP Ups") end if want == cur then - return Ops.say(S, ("Slot %d PP Up is already %d"):format(slot, want)) + return Ops.say(S, ("Slot %d PP Up is already %d"):format(slot, want), "info") end local applied, maxPp = MonOps.setPpUps(S.data, mon, slot, want, Gen.ofState(S)) return Ops.mark(S, ("Slot %d PP Up set to %d (Max PP %d)"):format(slot, applied, maxPp)) @@ -565,7 +590,7 @@ end function Ops.maxAllPpUps(S, mon) if not mon then return false end MonOps.maxAllPpUps(S.data, mon, Gen.ofState(S)) - return Ops.mark(S, ("Restored PP and maximized supported PP Ups for %s"):format(mon.species or "Pokémon")) + return Ops.mark(S, ("Restored PP and maximized supported PP Ups for %s"):format(speciesName(S, mon.species or "Pokémon"))) end -- Kept for tests and any keyboard path; the inspector opens the searchable @@ -673,7 +698,7 @@ function Ops.setMove(S, mon, slot, id) local candCanon = canonicalMoveId(S.data, id) if (curCanon and candCanon and curCanon == candCanon) or (current and tostring(current):upper() == tostring(id):upper()) then - return Ops.say(S, ("Move %d is already %s"):format(slot, tostring(id))) + return Ops.say(S, ("Move %d is already %s"):format(slot, tostring(id)), "info") end if not Ops.moveUsable(S, id) then return Ops.say(S, ("%s is not a usable move, cannot assign it") @@ -711,7 +736,7 @@ end function Ops.clearMove(S, mon, slot) if not (mon and mon.moves and mon.moves[slot]) then - return Ops.say(S, ("Move slot %d is already empty"):format(slot)) + return Ops.say(S, ("Move slot %d is already empty"):format(slot), "info") end local id = moveId(mon, slot) if Gen.ofState(S) == 3 then MonOps.clearMove(mon, slot) else mon.moves[slot] = nil end @@ -736,7 +761,7 @@ function Ops.setExperience(S, mon, value) return require("src.pokemon.Growth").expForLevel(def and def.growthRate or 0, level, S.data.growth_rates) end if exp > threshold(100) then return Ops.say(S, "Experience exceeds this species' level 100 maximum") end - if exp == Gen.exp(mon) then return Ops.say(S, "Experience is unchanged") end + if exp == Gen.exp(mon) then return Ops.say(S, "Experience is unchanged", "info") end local level = 1 while level < 100 and exp >= threshold(level + 1) do level = level + 1 end MonOps.setLevel(S.data, mon, level, g) @@ -753,7 +778,7 @@ function Ops.setCurrentHp(S, mon, value) if not mon or not require("Legality").integer(hp, 0, max) then return Ops.say(S, "HP must be a whole number from 0 to " .. max) end - if hp == mon.hp then return Ops.say(S, "HP is unchanged") end + if hp == mon.hp then return Ops.say(S, "HP is unchanged", "info") end mon.hp = hp return Ops.mark(S, "Current HP updated") end @@ -764,7 +789,7 @@ function Ops.setMonStatus(S, mon, status) if status ~= nil and (not allowed[status] or (Gen.ofState(S) == 1 and status == "TOX")) then return Ops.say(S, "Choose a status supported by this generation") end - if mon.status == status then return Ops.say(S, "Status is unchanged") end + if mon.status == status then return Ops.say(S, "Status is unchanged", "info") end mon.status = status mon.sleep = status == "SLP" and 1 or nil mon.toxicCounter = nil @@ -813,7 +838,7 @@ function Ops.setTrainerProperty(S, key, value) elseif key=="money" then Gen.setMoney(S.save,n) elseif key=="coins" then Gen.setCoins(S.save,n) end end - return Ops.mark(S,"Trainer "..key.." updated") + return Ops.mark(S, "Trainer " .. (({ id = "ID", secretId = "secret ID" })[key] or key) .. " updated") end function Ops.setMonProperty(S, mon, key, value) @@ -826,7 +851,7 @@ function Ops.setMonProperty(S, mon, key, value) if d.text and (Ops.nicknameLength(parsed) > d.max or not Ops.nicknameUsable(S, parsed)) then return Ops.say(S, "Trainer name must use game characters and fit in " .. d.max .. " characters") end - if P.get(mon, d) == parsed then return Ops.say(S, d.label .. " is unchanged") end + if P.get(mon, d) == parsed then return Ops.say(S, d.label .. " is unchanged", "info") end P.write(mon, d, parsed) if key == "personality" then mon.isShiny = nil @@ -875,13 +900,13 @@ function Ops.resetMoves(S, mon) MonOps.setMove(S.data, mon, slot, id) end return Ops.mark(S, ("Reset %s to its Lv%d learnset (%d moves)") - :format(mon.species, mon.level, #learned)) + :format(speciesName(S, mon.species), mon.level, #learned)) end function Ops.healMon(S, mon) if not mon then return false end if mon.hp == mon.stats.hp and not mon.status then - return Ops.say(S, ("%s is already at full HP"):format(mon.species)) + return Ops.say(S, ("%s is already at full HP"):format(speciesName(S, mon.species)), "info") end mon.hp = mon.stats.hp if mon.maxHp then mon.maxHp = mon.stats.hp end @@ -897,7 +922,7 @@ function Ops.healMon(S, mon) if def then mv.pp = def.pp + ((mv.ppUps or 0) * math.floor(def.pp / 5)) end end end - return Ops.mark(S, ("Healed %s to %d/%d HP"):format(mon.species, mon.hp, mon.stats.hp)) + return Ops.mark(S, ("Healed %s to %d/%d HP"):format(speciesName(S, mon.species), mon.hp, mon.stats.hp)) end -- ----------------------------------------------------------------- nicknames @@ -979,12 +1004,6 @@ function Ops.nicknameUsable(S, name) end) end --- The species' display name, what an un-nicknamed mon reads as. -local function speciesName(S, species) - local def = species and S.data.pokemon[species] - return (def and def.name) or tostring(species or "") -end - -- The input gate for the inspector's nickname field. Given the whole draft -- (existing text plus this frame's keystrokes and any paste), return the -- version the game can actually hold: every glyph kept draws in the game @@ -1018,11 +1037,11 @@ function Ops.setNickname(S, mon, name) return Ops.clearNickname(S, mon) end if name == mon.nickname then - return Ops.say(S, ("Already nicknamed %s"):format(name)) + return Ops.say(S, ("Already nicknamed %s"):format(name), "info") end if name == speciesName(S, mon.species) then if mon.nickname == nil then - return Ops.say(S, ("%s is already un-nicknamed"):format(mon.species)) + return Ops.say(S, ("%s is already un-nicknamed"):format(speciesName(S, mon.species)), "info") end mon.nickname = nil return Ops.mark(S, ("%s matches its standard name; nickname cleared") @@ -1036,16 +1055,16 @@ function Ops.setNickname(S, mon, name) "That name has characters the game cannot render or export cleanly") end mon.nickname = name - return Ops.mark(S, ("Nicknamed %s \"%s\""):format(mon.species, name)) + return Ops.mark(S, ("Nicknamed %s \"%s\""):format(speciesName(S, mon.species), name)) end function Ops.clearNickname(S, mon) if not mon then return Ops.say(S, "Pick a slot first") end if mon.nickname == nil then - return Ops.say(S, ("%s has no nickname to clear"):format(mon.species)) + return Ops.say(S, ("%s has no nickname to clear"):format(speciesName(S, mon.species))) end mon.nickname = nil - return Ops.mark(S, ("Cleared %s's nickname"):format(mon.species)) + return Ops.mark(S, ("Cleared %s's nickname"):format(speciesName(S, mon.species))) end -- ------------------------------------------------------------------ boxes @@ -1085,7 +1104,7 @@ function Ops.selectBox(S, index) S.save.currentBox = S.selectedBox if Gen.ofState(S) == 3 then S.save.storage.currentBox = S.selectedBox end local box = Ops.boxes(S)[S.selectedBox] - S.status = ("Box %d (%d/%d)"):format(S.selectedBox, Ops.boxSize(S, box), Ops.boxCapacity(S)) + Ops.note(S, ("Box %d (%d/%d)"):format(S.selectedBox, Ops.boxSize(S, box), Ops.boxCapacity(S))) return true end @@ -1099,10 +1118,10 @@ function Ops.selectBoxSlot(S, index) S.selectedBoxSlot = clamp(index, 1, Ops.boxCapacity(S)) local mon = box[S.selectedBoxSlot] S.editingMon = mon - S.status = mon + Ops.note(S, mon and ("Selected %s Lv%d in box %d slot %d") - :format(mon.species, mon.level, S.selectedBox, S.selectedBoxSlot) - or ("Box %d slot %d is empty"):format(S.selectedBox, S.selectedBoxSlot) + :format(speciesName(S, mon.species), mon.level, S.selectedBox, S.selectedBoxSlot) + or ("Box %d slot %d is empty"):format(S.selectedBox, S.selectedBoxSlot)) return true end @@ -1120,7 +1139,7 @@ function Ops.boxAdd(S) S.selectedBoxSlot = boxInsert(S, box, mon) S.editingMon = mon return Ops.mark(S, ("Added %s Lv5 to box %d slot %d") - :format(species, S.selectedBox, S.selectedBoxSlot)) + :format(speciesName(S, species), S.selectedBox, S.selectedBoxSlot)) end function Ops.withdraw(S) @@ -1142,7 +1161,7 @@ function Ops.withdraw(S) 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)) + return Ops.mark(S, ("Withdrew %s to party slot %d"):format(speciesName(S, mon.species), #S.save.party)) end function Ops.release(S) @@ -1150,13 +1169,13 @@ function Ops.release(S) local mon = box[S.selectedBoxSlot] if not mon then return Ops.say(S, "No box slot selected") end if not Ops.arm(S, "box-release", - ("Release %s permanently? Click again to confirm"):format(mon.species)) then + ("Release %s permanently? Click again to confirm"):format(speciesName(S, mon.species))) then return false end boxRemove(S, box, S.selectedBoxSlot) if S.editingMon == mon then S.editingMon = nil end S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1)) - return Ops.mark(S, ("Released %s"):format(mon.species)) + return Ops.mark(S, ("Released %s"):format(speciesName(S, mon.species))) end -- Follows BoxesMod.deposit: fills the current box first, then the next box @@ -1174,7 +1193,7 @@ function Ops.deposit(S) 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)) + return Ops.mark(S, ("Deposited %s into box %d"):format(speciesName(S, mon.species), boxIndex)) end if Gen.ofState(S) == 3 then local boxes = Ops.boxes(S) @@ -1187,7 +1206,7 @@ function Ops.deposit(S) S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1)) S.selectedBox, S.selectedBoxSlot = b, slot if S.editingMon == mon then S.editingMon = nil end - return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, b)) + return Ops.mark(S, ("Deposited %s into box %d"):format(speciesName(S, mon.species), b)) end end return Ops.say(S, "Every box is full") @@ -1200,7 +1219,7 @@ function Ops.deposit(S) S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1)) S.selectedBox = boxNum if S.editingMon == mon then S.editingMon = nil end - return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxNum)) + return Ops.mark(S, ("Deposited %s into box %d"):format(speciesName(S, mon.species), boxNum)) end -- ------------------------------------------------------------------ items @@ -1209,7 +1228,7 @@ function Ops.addMoney(S, delta) 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)) + or ("Money is already capped at $%d"):format(Ops.MONEY_MAX), "info") end Gen.setMoney(S.save, want) return Ops.mark(S, ("Money set to $%d"):format(want)) @@ -1228,7 +1247,7 @@ function Ops.addCoins(S, delta) 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)) + or ("Coins are already capped at %d"):format(Ops.COIN_MAX), "info") end Gen.setCoins(S.save, want) return Ops.mark(S, ("Coins set to %d"):format(want)) @@ -1268,12 +1287,14 @@ local function rowCount(S, pc, id, slot) end end -local function changeG3(S, pc, id, quantity) +local function changeG3(S, pc, id, quantity, msg) if not id then return Ops.say(S, "Pick an item first") end if not G3.change(S.data, S.save, pc, { { id = id, qty = quantity } }) then return Ops.say(S, "Item change refused: quantity or storage capacity") end - return Ops.mark(S, ("%s x%d%s"):format(tostring(id), quantity, pc and " in PC storage" or "")) + local where = pc and "PC storage" or "the bag" + if quantity <= 0 then return Ops.mark(S, ("Removed %s from %s"):format(tostring(id), where)) end + return Ops.mark(S, (msg or "%s x%d in %s"):format(tostring(id), quantity, where)) end local function g3Max(S, id, pc) @@ -1290,7 +1311,7 @@ local function maxG3(S, pc) top = math.max(top, cap) end end - if #changes == 0 then return Ops.say(S, "Every stack is already maxed") end + if #changes == 0 then return Ops.say(S, "Every stack is already maxed", "info") end if not G3.change(S.data, S.save, pc, changes) then return Ops.say(S, "Item changes refused") end return Ops.mark(S, ("Maxed %d stacks to x%d"):format(#changes, top)) end @@ -1298,7 +1319,7 @@ end function Ops.addToBag(S, id) if not id then return Ops.say(S, "Pick an item first") end S.save.inventory = S.save.inventory or {} - if Gen.ofState(S) == 3 then return changeG3(S, false, id, G3.quantity(S.data, S.save, false, id) + 1) end + if Gen.ofState(S) == 3 then return changeG3(S, false, id, G3.quantity(S.data, S.save, false, id) + 1, "Added %s x%d to %s") end local pocket = Bag.pocketOf(id, S.data) local capacity = Bag.capacity(S.data, pocket) if Bag.add(S.save, id, 1, S.data) then @@ -1316,7 +1337,7 @@ function Ops.bagAdjust(S, id, delta, slot) if Gen.ofState(S) == 3 then return changeG3(S, false, id, math.max(0, G3.quantity(S.data, S.save, false, id) + delta)) end if delta > 0 then if have >= Ops.stackMax(S) and not splits(S, id) then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S)), "info") end if not Bag.add(S.save, id, delta, S.data) then local pocket = Bag.pocketOf(id, S.data) @@ -1379,7 +1400,7 @@ function Ops.bagMax(S, id) end local target = stackTarget(S, id, have) if have >= target then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S)), "info") end Bag.add(S.save, id, target - have, S.data) return Ops.mark(S, ("%s x%d"):format(tostring(id), target)) @@ -1411,7 +1432,7 @@ function Ops.bagMaxAll(S) end end if n == 0 then - return Ops.say(S, ("Every bag stack is already at x%d"):format(Ops.stackMax(S))) + return Ops.say(S, ("Every bag stack is already at x%d"):format(Ops.stackMax(S)), "info") end return Ops.mark(S, ("Maxed %d bag stack%s to x%d") :format(n, n == 1 and "" or "s", Ops.stackMax(S))) @@ -1456,7 +1477,7 @@ end function Ops.bagSort(S, mode) if not ITEM_SORT_KEYS[mode] then return false end local order = Bag.order(S.save, S.data) - if #order < 2 then return Ops.say(S, "Nothing to sort in the bag") end + if #order < 2 then return Ops.say(S, "Nothing to sort in the bag", "info") end local sorted = itemRows(S, order, mode) for i = 1, #sorted do order[i] = sorted[i] end S.bagOffset = 0 @@ -1532,7 +1553,7 @@ local function slotMax(S, id) end function Ops.addToPc(S, id) - if Gen.ofState(S) == 3 then return changeG3(S, true, id, G3.quantity(S.data, S.save, true, id) + 1) end + if Gen.ofState(S) == 3 then return changeG3(S, true, id, G3.quantity(S.data, S.save, true, id) + 1, "Added %s x%d to %s") end if not id then return Ops.say(S, "Pick an item first") end local pc = Ops.pcItems(S) if not pc[id] and not pcStacksFree(S, id, 1) then @@ -1540,7 +1561,7 @@ function Ops.addToPc(S, id) end local cur = itemQty(pc, id) if cur >= slotMax(S, id) then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), slotMax(S, id))) + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), slotMax(S, id)), "info") end pc[id] = math.min(slotMax(S, id), cur + 1) return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), pc[id])) @@ -1561,7 +1582,7 @@ function Ops.pcAdjust(S, id, delta, slot) end local split = delta > 0 and splits(S, id) if delta > 0 and cur >= slotMax(S, id) and not split then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), slotMax(S, id))) + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), slotMax(S, id)), "info") end if split and not pcStacksFree(S, id, delta) then return Ops.say(S, ("PC item storage is full (%d stacks)"):format(pcCapacity(S))) @@ -1603,7 +1624,7 @@ function Ops.pcMax(S, id) end local target = stackTarget(S, id, cur) if cur >= target then - return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S))) + return Ops.say(S, ("%s is already at x%d"):format(tostring(id), Ops.stackMax(S)), "info") end pc[id] = target return Ops.mark(S, ("%s x%d in PC storage"):format(tostring(id), target)) @@ -1637,7 +1658,7 @@ function Ops.pcMaxAll(S) end end if n == 0 then - return Ops.say(S, ("Every PC stack is already at x%d"):format(Ops.stackMax(S))) + return Ops.say(S, ("Every PC stack is already at x%d"):format(Ops.stackMax(S)), "info") end return Ops.mark(S, ("Maxed %d PC stack%s to x%d") :format(n, n == 1 and "" or "s", Ops.stackMax(S))) @@ -1780,7 +1801,7 @@ function Ops.clearTrainers(S) for _, id in ipairs(ids) do if Gen.getFlag(S.save, id) then count = count + 1 end end - if count == 0 then return Ops.say(S, "Trainers are already empty") end + if count == 0 then return Ops.say(S, "Trainers are already empty", "info") end if not Ops.arm(S, "clear-trainers", ("Clear all %d defeated trainers? Click again to confirm"):format(count)) then return false @@ -1808,7 +1829,7 @@ function Ops.clearItems(S) for _, id in ipairs(ids) do if Gen.getFlag(S.save, id) then count = count + 1 end end - if count == 0 then return Ops.say(S, "Items taken are already empty") end + if count == 0 then return Ops.say(S, "Items taken are already empty", "info") end if not Ops.arm(S, "clear-items", ("Clear all %d items taken? Click again to confirm"):format(count)) then return false @@ -1831,7 +1852,7 @@ function Ops.clearToggles(S) for _, id in ipairs(ids) do if Gen.getFlag(S.save, id) then count = count + 1 end end - if count == 0 then return Ops.say(S, "Object toggles are already empty") end + if count == 0 then return Ops.say(S, "Object toggles are already empty", "info") end if not Ops.arm(S, "clear-toggles", ("Clear all %d object toggles? Click again to confirm"):format(count)) then return false @@ -1844,7 +1865,7 @@ function Ops.clearToggles(S) end local count = 0 for _ in pairs(S.save.objectToggles or {}) do count = count + 1 end - if count == 0 then return Ops.say(S, "Object toggles are already empty") end + if count == 0 then return Ops.say(S, "Object toggles are already empty", "info") end if not Ops.arm(S, "clear-toggles", ("Clear all %d map object toggles? Click again to confirm"):format(count)) then return false @@ -1856,7 +1877,7 @@ end function Ops.clearTable(S, tableKey, label) local count = 0 for _ in pairs(S.save[tableKey] or {}) do count = count + 1 end - if count == 0 then return Ops.say(S, ("%s is already empty"):format(label)) end + if count == 0 then return Ops.say(S, ("%s is already empty"):format(label), "info") end if not Ops.arm(S, "clear-" .. tableKey, ("Clear all %d %s entries? Click again to confirm"):format(count, label)) then return false @@ -1976,7 +1997,7 @@ function Ops.dexStamp(S) if type(m) == "table" then stamp(m) end end end - if n == 0 then return Ops.say(S, "Party and boxes are already all in the dex") end + if n == 0 then return Ops.say(S, "Party and boxes are already all in the dex", "info") end return Ops.mark(S, ("Owned %d more species from party + boxes"):format(n)) end @@ -2134,16 +2155,16 @@ function Ops.setLastOutdoor(S, map) S.save.healMap = S.mapId S.save.healX = cell.cx S.save.healY = cell.cy - return Ops.mark(S, ("heal location set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) + return Ops.mark(S, ("Heal location set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) elseif Gen.ofState(S) == 2 then S.save.spawn = S.mapId - return Ops.mark(S, ("spawn set to %s"):format(S.mapId)) + return Ops.mark(S, ("Spawn point 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 S.save.lastOutdoor = { id = S.mapId, x = cell.cx, y = cell.cy } - return Ops.mark(S, ("lastOutdoor set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) + return Ops.mark(S, ("Last outdoor map set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) end function Ops.setLastHeal(S) @@ -2153,13 +2174,13 @@ function Ops.setLastHeal(S) S.save.healMap = S.mapId S.save.healX = cell.cx S.save.healY = cell.cy - return Ops.mark(S, ("heal location set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) + return Ops.mark(S, ("Heal location set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) elseif Gen.ofState(S) == 2 then S.save.spawn = S.mapId - return Ops.mark(S, ("spawn set to %s"):format(S.mapId)) + return Ops.mark(S, ("Spawn point 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)) + return Ops.mark(S, ("Last heal set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) end function Ops.itemHoldable(S, id) @@ -2190,17 +2211,17 @@ function Ops.setHeldItem(S, mon, id) local was = mon.item or mon.heldItem MonOps.setHeldItem(S.data, mon, def.itemId or def.id or id, Gen.ofState(S)) syncPartyMailHeldItem(S, mon, was, id) - return Ops.mark(S, ("%s now holds %s"):format(mon.species, id)) + return Ops.mark(S, ("%s now holds %s"):format(speciesName(S, mon.species), itemName(S, 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 mon.friendship or 0) then - return Ops.say(S, ("Happiness is already %d"):format(want)) + return Ops.say(S, ("Happiness is already %d"):format(want), "info") end MonOps.setHappiness(S.data, mon, want, Gen.ofState(S)) - return Ops.mark(S, ("%s happiness %d"):format(mon.species, want)) + return Ops.mark(S, ("%s happiness %d"):format(speciesName(S, mon.species), want)) end function Ops.setNature(S, mon, natureId) @@ -2209,7 +2230,7 @@ function Ops.setNature(S, mon, natureId) MonOps.setNature(S.data, mon, natureId, Gen.ofState(S)) local SummaryData = require("src.core.game3.summary_data") local name = SummaryData.NATURES[natureId] - return Ops.mark(S, ("%s nature set to %s"):format(mon.species, name)) + return Ops.mark(S, ("%s nature set to %s"):format(speciesName(S, mon.species), name)) end function Ops.setAbility(S, mon, abilitySlot) @@ -2220,7 +2241,7 @@ function Ops.setAbility(S, mon, abilitySlot) if abilitySlot==1 and (not pair[2] or pair[2]==0) then return Ops.say(S,"This species has only one ability") end end MonOps.setAbility(S.data, mon, abilitySlot, Gen.ofState(S)) - return Ops.mark(S, ("%s ability set to slot %d"):format(mon.species, abilitySlot + 1)) + return Ops.mark(S, ("%s ability set to slot %d"):format(speciesName(S, mon.species), abilitySlot + 1)) end function Ops.setMonGender(S, mon, gender) @@ -2232,13 +2253,13 @@ function Ops.setMonGender(S, mon, gender) if ratio==255 or (ratio==0 and gender~="M") or (ratio==254 and gender~="F") then return Ops.say(S,"This species has a fixed gender") end end MonOps.setGender(S.data, mon, gender, Gen.ofState(S)) - return Ops.mark(S, ("%s gender set to %s"):format(mon.species, tostring(gender))) + return Ops.mark(S, ("%s gender set to %s"):format(speciesName(S, mon.species), tostring(gender))) end function Ops.setShiny(S, mon, shiny) if not mon then return false end MonOps.setShiny(S.data, mon, shiny, Gen.ofState(S)) - return Ops.mark(S, ("%s %s"):format(mon.species, shiny and "is now shiny!" or "is no longer shiny")) + return Ops.mark(S, ("%s %s"):format(speciesName(S, mon.species), shiny and "is now shiny!" or "is no longer shiny")) end Ops.UNOWN_FORM_NAMES_G3 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?" @@ -2279,10 +2300,11 @@ function Ops.setUnownForm(S, mon, letter) letter = math.floor(tonumber(letter) or -1) local name = Ops.unownFormName(S, letter) if not name or name == "" then return Ops.say(S, "Unknown Unown form") end - if letter == current then return Ops.say(S, ("UNOWN is already %s"):format(name)) end + if letter == current then return Ops.say(S, ("UNOWN is already %s"):format(name), "info") end local g = Gen.ofState(S) local ok, lostShiny = MonOps.setUnownForm(S.data, mon, letter, g) if not ok then return Ops.say(S, ("UNOWN %s cannot keep this nature and shininess"):format(name)) end + syncPartyMailSpecies(S, mon) if lostShiny then return Ops.mark(S, ("UNOWN is now %s (no longer shiny: only I and V can be shiny)"):format(name)) end @@ -2293,10 +2315,10 @@ 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)) + return Ops.say(S, ("Pokerus is already %d"):format(want), "info") end mon.pokerus = want - return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want)) + return Ops.mark(S, ("%s Pokérus byte %d"):format(speciesName(S, mon.species), want)) end -- engine/pokemon/caught_data.asm:169-172 @@ -2314,11 +2336,11 @@ function Ops.setCaughtTime(S, mon, value) if not caughtGuard(S, mon) then return false end local want = clamp(math.floor(tonumber(value) or 0), 0, 3) if want == (mon.caughtTime or 0) then - return Ops.say(S, ("Caught time is already %s"):format(Ops.CAUGHT_TIMES[want + 1])) + return Ops.say(S, ("Caught time is already %s"):format(Ops.CAUGHT_TIMES[want + 1]), "info") end mon.caughtTime = want return Ops.mark(S, ("%s caught time %s") - :format(mon.species, Ops.CAUGHT_TIMES[want + 1])) + :format(speciesName(S, mon.species), Ops.CAUGHT_TIMES[want + 1])) end -- constants/pokemon_data_constants.asm:120-121 @@ -2327,10 +2349,10 @@ function Ops.setCaughtLevel(S, mon, value) local Mon = require("src.battle.gen2.Mon") local want = clamp(math.floor(tonumber(value) or 0), 0, Mon.CAUGHT_LEVEL_MASK) if want == (mon.caughtLevel or 0) then - return Ops.say(S, ("Caught level is already %d"):format(want)) + return Ops.say(S, ("Caught level is already %d"):format(want), "info") end mon.caughtLevel = want - return Ops.mark(S, ("%s caught level %d"):format(mon.species, want)) + return Ops.mark(S, ("%s caught level %d"):format(speciesName(S, mon.species), want)) end -- constants/landmark_constants.asm:111-113 @@ -2341,11 +2363,11 @@ function Ops.setCaughtLocation(S, mon, value) local want = math.floor(tonumber(value) or 0) % span if want == (mon.caughtLocation or 0) then return Ops.say(S, ("Caught location is already %s") - :format(Gen.landmarkName(S.data, want))) + :format(Gen.landmarkName(S.data, want)), "info") end mon.caughtLocation = want return Ops.mark(S, ("%s caught at %s") - :format(mon.species, Gen.landmarkName(S.data, want))) + :format(speciesName(S, mon.species), Gen.landmarkName(S.data, want))) end function Ops.setCaughtByGender(S, mon, gender) @@ -2353,10 +2375,10 @@ function Ops.setCaughtByGender(S, mon, gender) local Mon = require("src.battle.gen2.Mon") local want = Mon.caughtGenderOf(gender) if want == mon.caughtByGender then - return Ops.say(S, ("Caught by is already %s"):format(tostring(want or "none"))) + return Ops.say(S, ("Caught by is already %s"):format(tostring(want or "none")), "info") end mon.caughtByGender = want - return Ops.mark(S, ("%s caught by %s"):format(mon.species, tostring(want or "none"))) + return Ops.mark(S, ("%s caught by %s"):format(speciesName(S, mon.species), tostring(want or "none"))) end function Ops.setPlayerGender(S, gender) @@ -2364,7 +2386,7 @@ function Ops.setPlayerGender(S, gender) return Ops.say(S, "This game has no player gender") end if Gen.playerGender(S.save) == gender then - return Ops.say(S, ("Player is already %s"):format(tostring(gender))) + return Ops.say(S, ("Player is already %s"):format(tostring(gender)), "info") end return Ops.mark(S, ("Player gender %s"):format(Gen.setPlayerGender(S.save, gender))) end @@ -2418,6 +2440,12 @@ function Ops.maxMon(S, mon) return require("MonActions").maxMon(S, mon) end function Ops.maxDvs(S, mon) + if Gen.ofState(S) == 2 then + if MonOps.maxGen2Dvs(S.data, mon, 2) then + return Ops.mark(S, "Maxed DVs (kept shiny: Def/Spd/Spc stay 10)") + end + return Ops.mark(S, "Maxed DVs") + end for _, k in ipairs({ "attack", "defense", "speed", "special" }) do MonOps.setDv(S.data, mon, k, 15, Gen.ofState(S)) end diff --git a/tools/save-editor/PickerChrome.lua b/tools/save-editor/PickerChrome.lua index 709934c9..b153bd00 100644 --- a/tools/save-editor/PickerChrome.lua +++ b/tools/save-editor/PickerChrome.lua @@ -78,4 +78,13 @@ function PickerChrome.closeSize(Kit) return math.max(PickerChrome.tapMin(Kit), math.floor(30 * ((Kit and Kit.scale) or 1))) end +local header = {} +function PickerChrome.toastHeader(S, Kit, cx, cy, inner, headH) + local s = (Kit and Kit.scale) or 1 + header.x, header.y, header.h = cx, cy, headH + header.w = math.max(1, inner - PickerChrome.closeSize(Kit) - 10 * s) + S.toastHeader = header + return header +end + return PickerChrome diff --git a/tools/save-editor/README.md b/tools/save-editor/README.md index b8252d84..dd0a77ae 100644 --- a/tools/save-editor/README.md +++ b/tools/save-editor/README.md @@ -43,8 +43,10 @@ the scrim; choosing a destination closes the popup and slides to that section. Popups support finger/wheel scrolling, keyboard selection, Escape, a close icon and outside-tap dismissal. They shield the page underneath from clicks and scrolling. Short screens use compact storage -actions and an item Tools menu. Map browsing splits into Maps, View and Spawn -sections on phones; storage and inventory actions stay in their viewport. +actions and an item Tools menu. Map browsing splits into Maps and View +sections on phones; Player, Heal (Spawn in Gen 2) and Outdoor (Gen 1) buttons +sit on top of the map in every layout and act on the selected cell; storage and +inventory actions stay in their viewport. Safe-area margins protect controls around notches. Short landscape map views have an expand icon that gives the map the editor's full content area; the back icon restores the editor chrome. diff --git a/tools/save-editor/State.lua b/tools/save-editor/State.lua index 13040d26..0542d628 100644 --- a/tools/save-editor/State.lua +++ b/tools/save-editor/State.lua @@ -36,6 +36,7 @@ function State.new() -- chrome tab = "party", -- party|boxes|items|events|map|dex status = "", + note = "", armed = nil, -- id of the destructive button awaiting confirmation armedAt = nil, -- when it was armed (Ops.ARM_SECONDS to commit) diff --git a/tools/save-editor/panels/Events.lua b/tools/save-editor/panels/Events.lua index 4af8bd60..82a09532 100644 --- a/tools/save-editor/panels/Events.lua +++ b/tools/save-editor/panels/Events.lua @@ -310,7 +310,7 @@ local function drawSection(S, Kit, x, y, w, h) function(value) S.eventsOffset = 0 Ops.disarm(S) - Ops.say(S, hints[value] or "") + Ops.note(S, hints[value] or "") end ) local px = cx + chooserW + 10 * s @@ -340,7 +340,7 @@ local function drawSection(S, Kit, x, y, w, h) then S.eventFilter = "" Kit.blur() - Ops.say(S, "Filter cleared") + Ops.note(S, "Filter cleared") end local hintY = filterY + pillH + 10 * s diff --git a/tools/save-editor/panels/ItemPicker.lua b/tools/save-editor/panels/ItemPicker.lua index cec2978e..a003984e 100644 --- a/tools/save-editor/panels/ItemPicker.lua +++ b/tools/save-editor/panels/ItemPicker.lua @@ -19,40 +19,6 @@ local PAL = Theme.PAL local Picker = {} local FIELD_ID = "item-picker" -local FEEDBACK_SECONDS = 2.5 - -local function feedback(S, Kit, changed, dest) - local p = S.itemPicker - if p then - p.feedback = { - text = changed and (dest == "pc" and "Added to PC" or "Added to Bag") or "Item not added", - good = changed, - at = love.timer and love.timer.getTime and love.timer.getTime() or Kit.time, - } - end - return changed -end - -local function drawFeedback(p, Kit, x, y, w, h) - local toast = p.feedback - if not toast then return false end - local elapsed = math.max(0, Kit.time - toast.at) - if elapsed >= FEEDBACK_SECONDS then - p.feedback = nil - return false - end - local alpha = math.min(1, (FEEDBACK_SECONDS - elapsed) / 0.4) - local s, pad = Kit.scale, 8 * Kit.scale - local icon = 18 * s - local tw = math.min(w, Kit.textWidth("small", toast.text) + icon + 3 * pad) - local color = toast.good and PAL.green or PAL.red - Theme.fillRounded(x, y, tw, h, PAL.cardBody, 0.98 * alpha) - Theme.stroke(x, y, tw, h, Theme.radius(), color, 0.8 * alpha, 1) - Kit.icon(toast.good and "check" or "triangle-alert", x + pad, y + (h - icon) / 2, icon, color, alpha) - Kit.text("small", toast.text, x + icon + 2 * pad, y + (h - Kit.textHeight("small")) / 2, PAL.text, alpha) - return true -end - function Picker.results(S) local p = S.itemPicker local hits = Ops.itemSearch(S, p and p.query or "") @@ -73,7 +39,7 @@ end function Picker.commitFirst(S, Kit) local hits = Picker.results(S) if not hits[1] then - return Ops.say(S, "No item matches that") + return Ops.say(S, "No item matches that", "info") end return Picker.commit(S, Kit, hits[1]) end @@ -93,9 +59,9 @@ function Picker.commit(S, Kit, id) return changed end if dest == "pc" then - return feedback(S, Kit, Ops.addToPc(S, id), dest) + return Ops.addToPc(S, id) end - return feedback(S, Kit, Ops.addToBag(S, id), dest) + return Ops.addToBag(S, id) end function Picker.draw(S, Kit, width, height) @@ -134,9 +100,8 @@ function Picker.draw(S, Kit, width, height) local closeW = PickerChrome.closeSize(Kit) local captionH = Kit.textHeight("caption") local headH = math.max(captionH, closeW) - if not drawFeedback(p, Kit, cx, cy, inner - closeW - 10 * s, headH) then - Kit.caption(cx, cy + (headH - captionH) / 2, "ADD AN ITEM") - end + PickerChrome.toastHeader(S, Kit, cx, cy, inner, headH) + Kit.caption(cx, cy + (headH - captionH) / 2, "ADD AN ITEM") if Kit.iconButton( x + w - pad - closeW, diff --git a/tools/save-editor/panels/MapBrowser.lua b/tools/save-editor/panels/MapBrowser.lua index aee66d79..1a732026 100644 --- a/tools/save-editor/panels/MapBrowser.lua +++ b/tools/save-editor/panels/MapBrowser.lua @@ -3,8 +3,7 @@ -- Reuses the game's own MapLoader/TileRenderer/Warp so the editor's view -- matches what the player would actually see. -- --- Three columns: a searchable map list, the viewport, and the spawn --- inspector. Overlays are drawn in this order so the selection always wins: +-- Overlays are drawn in this order so the selection always wins: -- cyan hollow warp cell (clicking follows the warp) -- red filled the save's player position -- green / amber lastHeal / lastOutdoor @@ -181,9 +180,9 @@ local function goToWarp(S, warp) local dest = def.destMap or def.map if dest then MapBrowser.select(S, dest) - S.status = "Followed warp to " .. tostring(dest) + Ops.note(S, "Followed warp to " .. tostring(dest)) else - S.status = "Warp has no destination map" + Ops.say(S, "Warp has no destination map") end return end @@ -197,12 +196,12 @@ local function goToWarp(S, warp) S.save.lastOutdoor = { id = S.mapId, x = def.x, y = def.y } end if def.destMap == "LAST_MAP" and not S.save.lastOutdoor then - S.status = "Can't follow warp: no remembered outdoor map (lastOutdoor unset)" + Ops.say(S, "Can't follow warp: no remembered outdoor map") return end local ok, destMap, dx, dy = pcall(Warp.destination, S.data, def, S.save.lastOutdoor) if not ok then - S.status = "Warp failed: " .. tostring(destMap) + Ops.say(S, "Warp failed: " .. tostring(destMap)) return end S.mapId = destMap @@ -212,7 +211,7 @@ local function goToWarp(S, warp) -- claim the lazy first-draw centering below, so it does not immediately -- re-centre the destination map and lose the warp's landing cell S._mapCenteredFor = destMap - S.status = "Followed warp to " .. destMap + Ops.note(S, "Followed warp to " .. destMap) end -- Screen-space point inside the viewport -> map cell, or nil if the point is @@ -264,7 +263,7 @@ function MapBrowser.select(S, id) S.mapClickCell = nil S._mapCenteredFor = nil MapBrowser.clearTouches(S) - S.status = "Viewing " .. id + Ops.note(S, "Viewing " .. id) end -- Called inside the viewport's translate+scale transform, so every rect is @@ -412,6 +411,118 @@ function MapBrowser.preview(S) return true, map, reason and ("Preview unavailable: " .. reason .. ". Coordinate grid remains available.") end +local function insideOverlay(S, x, y) + for _, r in ipairs(S._mapOverlayRects or {}) do + if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then return true end + end + return false +end +MapBrowser.insideOverlay = insideOverlay + +function MapBrowser.pointActions(S, map) + local pmap, px, py = playerPos(S) + local function at(id, cx, cy) + return id and ("%s (%d,%d)"):format(id, cx or 0, cy or 0) or "unset" + end + local heal = { id = "heal", label = "Heal", what = "Last heal", icon = "heart", run = Ops.setLastHeal } + local actions = { + { id = "player", label = "Player", what = "Player", icon = "user-round", value = at(pmap, px, py), + run = Ops.setPlayerHere }, + heal, + } + local generation = Gen.ofState(S) + if generation == 3 then + heal.value = at(S.save.healMap, S.save.healX, S.save.healY) + elseif generation == 2 then + heal.id, heal.label, heal.what = "spawn", "Spawn", "Spawn" + heal.value = tostring(S.save.spawn or "SPAWN_HOME") + else + local last, out = S.save.lastHeal, S.save.lastOutdoor + heal.value = last and at(last.map, last.x, last.y) or "unset" + actions[3] = { + id = "outdoor", label = "Outdoor", what = "Last outdoor", icon = "flag", + value = out and at(out.id, out.x, out.y) or "unset", + run = function(state) return Ops.setLastOutdoor(state, map) end, + refused = not Ops.isOutdoor(S, map), + } + end + for _, action in ipairs(actions) do + action.enabled = S.mapClickCell ~= nil and not action.refused + if not S.mapClickCell then + action.reason = ("%s: %s. Tap a cell first"):format(action.what, action.value) + end + end + return actions +end + +local function layoutActions(S, Kit, actions, vx0, vy0, vinner, vh0) + local s = Kit.scale + local h, gapB, inset = Kit.controlH(), 6 * s, 6 * s + local right, top = vx0 + vinner - inset, vy0 + inset + local availW, availH = vinner - 2 * inset, vh0 - 2 * inset + local function place(compact, column) + local total, widest = -gapB, 0 + for _, action in ipairs(actions) do + action.w = compact and h or Kit.buttonWidth(action.label, { font = "small", icon = action.icon }, h) + total = total + action.w + gapB + widest = math.max(widest, action.w) + end + if column then + if widest > availW or #actions * (h + gapB) - gapB > availH then return false end + local y = top + for _, action in ipairs(actions) do + action.rect = { x = right - action.w, y = y, w = action.w, h = h } + y = y + h + gapB + end + else + if total > availW or h > availH then return false end + local x = right - total + for _, action in ipairs(actions) do + action.rect = { x = x, y = top, w = action.w, h = h } + x = x + action.w + gapB + end + end + S._mapActionsCompact = compact + return true + end + if not (place(false, false) or place(false, true) or place(true, false) or place(true, true)) then + S._mapOverlayRects, S._mapActionsCompact = {}, nil + return 0 + end + local rects, bottom = {}, top + for _, action in ipairs(actions) do + rects[#rects + 1] = action.rect + bottom = math.max(bottom, action.rect.y + h) + end + S._mapOverlayRects = rects + return bottom - vy0 +end + +local function drawActions(S, Kit, actions) + for _, action in ipairs(S._mapOverlayRects and #S._mapOverlayRects > 0 and actions or {}) do + local r = action.rect + local pressed + if S._mapActionsCompact then + pressed = Kit.iconButton(r.x, r.y, r.w, r.h, action.icon, action.label, { + kind = "accent", enabled = action.enabled, + }) + else + pressed = Kit.button(r.x, r.y, r.w, r.h, action.label, { + kind = "accent", font = "small", icon = action.icon, enabled = action.enabled, + }) + end + if pressed then + action.run(S) + elseif not action.enabled and Kit.press(r.x, r.y, r.w, r.h) then + if action.reason then + Ops.say(S, action.reason, "info") + else + action.run(S) + end + end + end +end + local function drawSection(S, Kit, x, y, w, h) local s = Kit.scale local gap = 20 * s @@ -419,25 +530,24 @@ local function drawSection(S, Kit, x, y, w, h) S.mapQuery = S.mapQuery or "" S.mapZoom = clampZoom(S.mapZoom or 2) - -- Column plan (#715). Side by side, the list and spawn cards claim ~470 - -- logical px before the viewport gets anything, and a portrait phone does - -- not have it: the old layout answered by laying the viewport out at a + -- Column plan (#715). A portrait phone cannot fit the list beside the + -- viewport: the old layout answered by laying the viewport out at a -- negative width, which the scissor below rejected ("Can't set scissor -- with negative width and/or height") and took the whole editor down. - -- Phones open Maps / View / Spawn as sliding pages. Every viewport + -- Phones open Maps / View as sliding pages. Every viewport -- dimension is clamped so no window shape creates a negative scissor. local listW = math.max(200 * s, math.min(260 * s, w * 0.2)) local sideW = math.max(230 * s, math.min(300 * s, w * 0.22)) - local viewW = w - listW - sideW - 2 * gap - local stacked = S.mapFocused or h > w or viewW < 260 * s + local viewW = w - listW - gap + local stacked = S.mapFocused or h > w or viewW - sideW - gap < 260 * s S._mapStacked = stacked - local lr, vr, sr -- list / viewport / spawn card rects + local lr, vr -- list / viewport rects local row, gapNav = Kit.controlH(), 6 * s local labelW = 48 * s local inlineZoom = stacked and w > h and w >= 5 * row + labelW + 6 * gapNav + 100 * s if stacked then - S.mapSection = S.mapSection or "view" - local sections = { { "maps", "Maps" }, { "view", "View" }, { "spawn", "Spawn" } } + if S.mapSection ~= "maps" then S.mapSection = "view" end + local sections = { { "maps", "Maps" }, { "view", "View" } } if Kit.iconButton(x, y, row, row, S.mapFocused and "chevron-left" or "expand", S.mapFocused and "Back to editor" or "Focus map") then S.mapFocused = not S.mapFocused @@ -484,11 +594,9 @@ local function drawSection(S, Kit, x, y, w, h) y, h = y + row + gapNav, math.max(0, h - row - gapNav) lr = { x = x, y = y, w = w, h = h } vr = lr - sr = lr else lr = { x = x, y = y, w = listW, h = h } vr = { x = x + listW + gap, y = y, w = math.max(0, viewW), h = h } - sr = { x = x + w - sideW, y = y, w = sideW, h = h } end -- --------------------------------------------------------- the map list @@ -521,6 +629,9 @@ local function drawSection(S, Kit, x, y, w, h) local gotoY = lr.y + lr.h - pad - gotoH local pagerH = Kit.controlH() local pagerY = gotoY - 10 * s - pagerH + if stacked and not compactList then + S.toastBottom = math.min(S.toastBottom or pagerY, pagerY) + end local listTop = qy + Kit.controlH() + 10 * s local mRowH = Kit.controlH() local mGap = 4 * s @@ -572,7 +683,7 @@ local function drawSection(S, Kit, x, y, w, h) 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)) + Ops.say(S, ("Jumped to %s (%d,%d)"):format(pmap, px, py), "ok") else Ops.say(S, "No player location on this save") end @@ -663,7 +774,7 @@ local function drawSection(S, Kit, x, y, w, h) ) then showPlayer(S) - Ops.say(S, "Centred on the player") + Ops.note(S, "Centred on the player") end end local zoomY = vr.y + vpad @@ -728,8 +839,11 @@ local function drawSection(S, Kit, x, y, w, h) S._mapCenterPlayer, S.mapAutoFit = nil, false end + local actions = MapBrowser.pointActions(S, map) + local barH = layoutActions(S, Kit, actions, vx0, vy0, vinner, vh0) + if Kit.mouseDown and not Kit.blockClicks and not S._mapPinch - and (S._mapDrag or Kit.hit(vx0, vy0, vinner, vh0)) then + and (S._mapDrag or (Kit.hit(vx0, vy0, vinner, vh0) and not insideOverlay(S, Kit.mouseX, Kit.mouseY))) then local d = S._mapDrag if not d then S._mapDrag = { mx = Kit.mouseX, my = Kit.mouseY, camX = S.mapCamX, camY = S.mapCamY } @@ -781,13 +895,15 @@ local function drawSection(S, Kit, x, y, w, h) drawOverlays(S, map) love.graphics.pop() if previewReason then - Kit.textWrapped("tiny", previewReason, vx0 + 8 * s, vy0 + 8 * s, math.max(0, vinner - 16 * s), PAL.yellow) + Kit.textWrapped("tiny", previewReason, vx0 + 8 * s, vy0 + 8 * s + barH, math.max(0, vinner - 16 * s), PAL.yellow) end Kit.popClip() end + drawActions(S, Kit, actions) -- click handling: warp cells jump the view, everything else selects - if Kit.mouseClicked and not Kit.blockClicks and not S._mapPinch then + if Kit.mouseClicked and not Kit.blockClicks and not S._mapPinch + and not insideOverlay(S, Kit.mouseX, Kit.mouseY) then local cx, cy = cellAtScreen(S, map, Kit, vx0, vy0, vinner, vh0) if cx then local warp = map:warpAtCell(cx, cy) @@ -795,7 +911,7 @@ local function drawSection(S, Kit, x, y, w, h) goToWarp(S, warp) else S.mapClickCell = { cx = cx, cy = cy } - S.status = string.format("Selected cell (%d,%d) on %s", cx, cy, S.mapId) + Ops.note(S, string.format("Selected cell (%d,%d) on %s", cx, cy, S.mapId)) end end end @@ -806,12 +922,13 @@ local function drawSection(S, Kit, x, y, w, h) local legend = { { PAL.blue, "warp", false }, { PAL.red, "player", true }, - { PAL.green, "lastHeal", false }, - { PAL.yellow, "lastOutdoor", false }, + { PAL.green, "last heal", false }, + { PAL.yellow, "last outdoor", false }, } if stacked then if not inlineZoom then local toolY = ly - row - 6 * s + S.toastBottom = math.min(S.toastBottom or toolY, toolY) local fitW = Kit.buttonWidth("Fit", { font = "small" }, row) local percentW = Kit.textWidth("tiny", "400%") + 4 * s local toolsW = 3 * row + fitW + percentW + 4 * gapNav @@ -828,7 +945,7 @@ local function drawSection(S, Kit, x, y, w, h) Kit.text( "tiny", S.mapClickCell - and ("Selected (%d,%d) · set in Spawn"):format(S.mapClickCell.cx, S.mapClickCell.cy) + and ("Selected (%d,%d) · set it with the map buttons"):format(S.mapClickCell.cx, S.mapClickCell.cy) or "Pinch to zoom · drag to pan · tap a cell", vx0, ly, @@ -855,140 +972,7 @@ local function drawSection(S, Kit, x, y, w, h) PAL.caption ) end - if stacked then - return - end end - - -- ------------------------------------------------------ spawn inspector - local sx0 = sr.x - Kit.card(sx0, sr.y, sr.w, sr.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 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) == 3 then - local healMap = S.save.healMap - local healX = S.save.healX or 0 - local healY = S.save.healY or 0 - spawns = { - { - key = "PLAYER", - color = PAL.red, - value = playerValue, - set = function() - Ops.setPlayerHere(S) - end, - }, - { - key = "LAST HEAL", - color = PAL.green, - value = healMap and ("%s (%d,%d)"):format(healMap, healX, healY) or "unset", - set = function() - Ops.setLastHeal(S) - end, - }, - } - elseif 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 - 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", - set = function() - Ops.setLastHeal(S) - end, - }, - { - key = "LAST OUTDOOR", - color = PAL.yellow, - 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 = math.max(80 * s, Kit.controlH() + 2 * Kit.textHeight("mono")) - local spawnTop = sTop - local spawnViewH = math.max(0, sr.y + sr.h - pad - spawnTop) - S.mapSpawnScroll = Kit.scrollPixels( - sx0 + pad, - spawnTop, - sInner, - spawnViewH, - S.mapSpawnScroll or 0, - #spawns * (spawnH + 8 * s) + 70 * s - ) - Kit.pushClip(sx0 + pad, spawnTop, sInner, spawnViewH) - sTop = sTop - S.mapSpawnScroll - for i, sp in ipairs(spawns) do - local ry = sTop + (i - 1) * (spawnH + 8 * s) - Theme.row(sx0 + pad, ry, sInner, spawnH, 10 * s, 0.6) - Kit.text("tiny", sp.key, sx0 + pad + 12 * s, ry + 11 * s, sp.color) - local setW = 70 * s - if - Kit.button( - sx0 + pad + sInner - 12 * s - setW, - ry + 8 * s, - setW, - Kit.controlH(), - "Set here", - { kind = "accent", font = "tiny", radius = 7 * s, enabled = S.mapClickCell ~= nil } - ) - then - sp.set() - end - Kit.text( - "mono", - Kit.ellipsize("mono", sp.value, sInner - 24 * s), - sx0 + pad + 12 * s, - ry + spawnH - 10 * s - Kit.textHeight("mono"), - PAL.muted - ) - end - - local noteY = sTop + #spawns * (spawnH + 8 * s) + 6 * s - Kit.textWrapped( - "tiny", - "Select a cell in View, then set a spawn point here. Drag to pan; use the zoom controls to zoom.", - sx0 + pad, - noteY, - sInner, - PAL.caption - ) - Kit.popClip() end function MapBrowser.draw(S, Kit, x, y, w, h) @@ -999,7 +983,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h) require("src.world.gen2.MapPreview").clear(S._g2MapBaker) end S._mapWindowKey = windowKey - S._mapViewRect = nil + S._mapViewRect, S._mapOverlayRects = nil, nil Motion.pages(S, Kit, "mapSection", x, y, w, h, drawSection) if S._mapStacked and S.mapSection ~= "view" then S._mapViewRect = nil end end diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 54dec590..8271553d 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -11,10 +11,26 @@ local MonEditor = {} -- species, cached for the process: the old panel called newImage every frame, -- which re-decoded a PNG sixty times a second. local spriteCache = {} +local SPINDA_CACHE_MAX = 16 +local spindaKeys = {} + +local function remember(key, value) + spriteCache[key] = value + if type(key) == "string" and key:find("^308:") then + spindaKeys[#spindaKeys + 1] = key + while #spindaKeys > SPINDA_CACHE_MAX do + spriteCache[table.remove(spindaKeys, 1)] = nil + end + end +end function MonEditor.spriteKey(S, species, mon) if not species then return nil end local letter = mon and require("Ops").unownForm(S, mon) + if letter == nil and mon and Gen.ofState(S) == 3 + and tonumber(species) == require("src.core.game3.pokemon").SPECIES_SPINDA then + return tostring(species) .. ":" .. tostring((tonumber(mon.personality) or 0) % 4294967296) + end if letter == nil then return species end return tostring(species) .. ":" .. letter end @@ -30,9 +46,10 @@ function MonEditor.sprite(S, species, mon) local spId = tonumber(species) or (Pokemon.speciesFromName and Pokemon.speciesFromName(tostring(species))) if spId then local picId = letter ~= nil and Pokemon.picSpecies(spId, mon.personality) or spId - local pic = (Pokemon.frontPic and Pokemon.frontPic(picId)) or (Pokemon.icon and Pokemon.icon(picId)) + local pic = (Pokemon.frontPic and Pokemon.frontPic(picId, nil, nil, mon and mon.personality)) + or (Pokemon.icon and Pokemon.icon(picId)) if pic and pic.image then - spriteCache[key] = pic.image + remember(key, pic.image) return pic.image end end @@ -44,11 +61,11 @@ function MonEditor.sprite(S, species, mon) path = require("src.core.gen2.Unown").formSprite(S.data.pokemon, letter) or path end if not path or not love.graphics.newImage then - spriteCache[key] = false + remember(key, false) return nil end local ok, img = pcall(love.graphics.newImage, path) - spriteCache[key] = ok and img or false + remember(key, ok and img or false) return ok and img or nil end diff --git a/tools/save-editor/panels/MovePicker.lua b/tools/save-editor/panels/MovePicker.lua index 3723fb30..2670b203 100644 --- a/tools/save-editor/panels/MovePicker.lua +++ b/tools/save-editor/panels/MovePicker.lua @@ -34,7 +34,7 @@ end function Picker.commitFirst(S, Kit) local hits = Picker.results(S) if not hits[1] then - return Ops.say(S, "No move matches that") + return Ops.say(S, "No move matches that", "info") end local ok = commit(S, hits[1]) if ok then @@ -80,6 +80,7 @@ function Picker.draw(S, Kit, width, height) local closeW = PickerChrome.closeSize(Kit) local captionH = Kit.textHeight("caption") local headH = math.max(captionH, closeW) + PickerChrome.toastHeader(S, Kit, cx, cy, inner, headH) Kit.caption(cx, cy + (headH - captionH) / 2, ("CHOOSE MOVE %d"):format(p.slot or 1)) if Kit.iconButton( diff --git a/tools/save-editor/panels/Party.lua b/tools/save-editor/panels/Party.lua index 0981219b..1c4faff0 100644 --- a/tools/save-editor/panels/Party.lua +++ b/tools/save-editor/panels/Party.lua @@ -49,6 +49,7 @@ local function drawRoster(S, Kit, x, y, listW, h) -- "+ Add mon" / "Remove" pinned to the card bottom; the roster fills above. local actH = Kit.controlH() local actY = y + h - pad - actH + S.toastBottom = math.min(S.toastBottom or actY, actY) local listTop = y + pad + Kit.textHeight("caption") + 12 * s local listH = actY - 12 * s - listTop diff --git a/tools/save-editor/panels/SpeciesPicker.lua b/tools/save-editor/panels/SpeciesPicker.lua index 6ca02488..a9a97e94 100644 --- a/tools/save-editor/panels/SpeciesPicker.lua +++ b/tools/save-editor/panels/SpeciesPicker.lua @@ -40,7 +40,7 @@ end function Picker.commitFirst(S, Kit) local hits = Picker.results(S) if not hits[1] then - return Ops.say(S, "No species matches that") + return Ops.say(S, "No species matches that", "info") end local ok = commit(S, hits[1]) if ok then @@ -86,6 +86,7 @@ function Picker.draw(S, Kit, width, height) local closeW = PickerChrome.closeSize(Kit) local captionH = Kit.textHeight("caption") local headH = math.max(captionH, closeW) + PickerChrome.toastHeader(S, Kit, cx, cy, inner, headH) Kit.caption( cx, cy + (headH - captionH) / 2,