diff --git a/main.lua b/main.lua index b820766c..2c352977 100644 --- a/main.lua +++ b/main.lua @@ -327,6 +327,7 @@ local function returnToLauncher() if love.audio and love.audio.stop then pcall(love.audio.stop) end + pcall(function() require("src.render.SecondScreen").setEnabled(false) end) local GameVersion = require("src.core.GameVersion") local currentVersion = GameVersion.get() diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index c484e900..e59fe574 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -398,11 +398,15 @@ public class GameActivity extends SDLActivity { @Override protected void onDestroy() { + secondaryHostResumed = false; if (vibrator != null) { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); } unregisterSecondaryDisplayListener(); + teardownSecondaryDisplay(); + secondaryEnabled = false; + synchronized (secondaryFrameLock) { secondaryFrame = null; } unregisterAudioDeviceCallback(); abandonAudioFocus(); onHostDestroy(); @@ -411,6 +415,7 @@ public class GameActivity extends SDLActivity { @Override protected void onPause() { + secondaryHostResumed = false; if (vibrator != null) { Log.d("GameActivity", "Cancelling vibration"); vibrator.cancel(); @@ -426,6 +431,7 @@ public class GameActivity extends SDLActivity { @Override public void onResume() { super.onResume(); + secondaryHostResumed = true; onHostResume(); requestGameAudioFocus(); registerAudioDeviceCallback(); @@ -1933,6 +1939,7 @@ public class GameActivity extends SDLActivity { private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY; private static volatile long secondaryRetryAfter; private static volatile boolean secondaryEnabled = false; + private static volatile boolean secondaryHostResumed = false; private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO; private static volatile int dualScreenDisplayMode = -1; private static volatile byte[] secondaryFrame; @@ -1963,7 +1970,7 @@ public class GameActivity extends SDLActivity { if (self == null) return; self.runOnUiThread(new Runnable() { @Override public void run() { - if (on) { + if (on && secondaryHostResumed) { self.refreshDualScreenDisplayMode(); self.registerSecondaryDisplayListener(); rebindSecondaryDisplay(); @@ -2035,9 +2042,11 @@ public class GameActivity extends SDLActivity { private static void rebindSecondaryDisplay() { GameActivity self = (GameActivity) mSingleton; - if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return; + if (self == null || !secondaryHostResumed || !secondaryEnabled + || secondaryOutputIsPreferred(self)) return; self.runOnUiThread(() -> { - if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return; + if (!secondaryHostResumed || !secondaryEnabled + || secondaryOutputIsPreferred(self)) return; teardownSecondaryDisplay(); setupSecondaryDisplay(); }); @@ -2045,7 +2054,8 @@ public class GameActivity extends SDLActivity { private static void setupSecondaryDisplay() { GameActivity self = (GameActivity) mSingleton; - if (self == null || !secondaryEnabled || secondaryPresentation != null + if (self == null || !secondaryHostResumed || !secondaryEnabled + || secondaryPresentation != null || secondaryActivity != null || secondaryActivityPending || android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return; try { diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e510a171..68644961 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1718,9 +1718,14 @@ function BattleState:enter() -- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs -- playerMonFainted queues on the battle screen; there is no battle -- screen to queue them on here, so they print over the map. + -- _PlayerBlackedOutText (no "2") extracts to the identical wording from + -- a different ROM address and is unused anywhere in this engine -- not + -- a fallback for this one, just pokered printing the same paragraph + -- from a second call site elsewhere. self.game.stack:push(require("src.render.TextBox").new(self.game, - Strings("%s is out of\nuseable POKéMON!", name) .. "\f" - .. Strings("%s blacked\nout!", name), blackedOut)) + self:romText("_PlayerBlackedOutText2", + "%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name), + blackedOut)) return end self.musicKind = self:computeMusicKind() @@ -1857,7 +1862,8 @@ function BattleState:enter() self:slidePic("foe") end) -- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923) - self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name)) + self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!", + foeName, self.enemy.name)) self:act(function() -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- pic grows out of the ball (AnimateSendingOutMon), then the cry @@ -3638,12 +3644,13 @@ function BattleState:executeAction(user, target, action) }) self.aiUses = self:aiUsesFor() markSeen(self.game, self.enemy.mon.species) - -- _AIBattleWithdrawText: "X with-/drew Y!" - self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName)) + self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!", + self.trainer.name, oldName)) -- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText, -- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434) self.enemySendingOut = true - self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) + self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!", + self.trainer.name, self.enemy.name)) self:actNext(function() self.enemySendingOut = false self:startGrowIn(self.enemy) @@ -4142,8 +4149,12 @@ function BattleState:onFaint(battler) -- acknowledged core.asm:797-798 bug.) self:actNext(function() self:playVictoryMusic() end) end - -- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText - self:sayNext(Strings("%s\nfainted!", displayName(battler))) + -- _EnemyMonFaintedText already carries its own "Enemy" wording, so this + -- passes the raw name -- displayName's separate Strings("Enemy %s", ...) + -- would double it up + self:sayNext(battler.isPlayer + and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name) + or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name)) if battler.isPlayer then self:act(function() self:playerMonFainted() end) else @@ -4319,6 +4330,13 @@ function BattleState:enemyMonFainted() -- "X is" off so "about to use" stays above the name, instead of the -- page ending on a bare nick (#565). Then para "Will PLAYER" / -- "change POKéMON?" with YES/NO. + -- + -- _TrainerAboutToUseText combines both \f-paged, but unlike + -- _ItemUseBallText00's say()+say() merge above, this is say()+ + -- sayChoice(): tried merging into one romText/sayChoice call and + -- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that + -- the battle queue's own \f handling (not TextBox.lua's) does not + -- page a sayChoice string the same way -- left as two calls. self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName)) self:sayChoice( Strings("Will %s\nchange POKéMON?", self.game.save.player.name), @@ -4362,7 +4380,8 @@ function BattleState:enemyMonFainted() -- (AnimateSendingOutMon) with the cry; no POOF -- that animation -- belongs to the player-side SendOutMon (core.asm:1757-1762) self.enemySendingOut = true - self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) + self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!", + self.trainer.name, self.enemy.name)) self:actNext(function() self.enemySendingOut = false self:startGrowIn(self.enemy) @@ -4878,7 +4897,8 @@ function BattleState:storeCaughtMon() -- text_promptbutton (item_effects.asm:624-629), so the fanfare follows -- the box rather than firing when the dex bit is set self:sayNextWaitSfx( - Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name), + self:romText("_ItemUseBallText06", + "New POKéDEX data\nwill be added for\n%s!", self.enemy.name), function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end) self:uiNext(function() return self:buildScreen("DexEntryMenu", species) @@ -4899,9 +4919,12 @@ function BattleState:storeCaughtMon() if boxNum then askCaughtNickname() -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL - local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) - and "BILL's PC" or Strings("someone's PC") - self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc)) + local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL + self:sayNext(self:romText( + metBill and "_ItemUseBallText07" or "_ItemUseBallText08", + metBill and "%s was\ntransferred to\nBILL's PC!" + or "%s was\ntransferred to\nsomeone's PC!", + self.enemy.name)) else self:sayNext(Strings("But every BOX\nis full!")) end @@ -5007,8 +5030,18 @@ function BattleState:throwBall(ball) -- RESTLESS SOUL dodges balls even once the scope has revealed it, -- so it is not a ghost battle any more (#444) self:animNext(self:tossAnimFor(ball), true, nil, ball) - self:sayNext(Strings("It dodged the\nthrown BALL!")) - self:sayNext(Strings("This POKéMON\ncan't be caught!")) + -- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike + -- TextBox.new() (which splits \f itself), the battle queue's own + -- startMessage() only splits on \n/\v -- confirmed live: the \f + -- landed mid-line and the second sentence overflowed off the box + -- instead of starting a fresh page. Resolve the label once, then + -- split it the same way TextBox.lua does and queue one sayNext per + -- page, so the two ROM sentences still render as two pages. + local dodgeText = self:romText("_ItemUseBallText00", + "It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!") + for page in (dodgeText .. "\f"):gmatch("(.-)\f") do + self:sayNext(page) + end self:act(function() self:executeAction(self.enemy, self.player, self:enemyAction()) end) diff --git a/src/core/Game.lua b/src/core/Game.lua index b2c8e5bf..b4bb8d99 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -982,7 +982,11 @@ end -- parked the player until every direction was re-pressed (#799). function Game:focus(f) Input:reset() - if f then Input:reconcile() end + if f then + Input:reconcile() + local eng = self:syncEngine() + if eng then pcall(eng.noteResumed, eng) end + end TouchControls:reset() self:cancelPointers() end @@ -1002,6 +1006,8 @@ function Game:onResume() Input:reconcile() TouchControls:reset() self:cancelPointers() + local eng = self:syncEngine() + if eng then pcall(eng.noteResumed, eng) end -- Chip music may survive NX suspend as a duplicate stream; stop it and let -- the active screen re-cue on the next frame (hardware audio check: T19). -- Desktop/mobile window-visible flips must not kill overworld music. @@ -1209,18 +1215,26 @@ end function Game:syncEngine() if self._syncOff then return nil end - if self._syncEngineRef then return self._syncEngineRef end - local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") - if not ok or type(SyncEngine) ~= "table" then - self._syncOff = true - return nil - end - local eng = SyncEngine.shared() + local eng = self._syncEngineRef if not eng then - self._syncOff = true - return nil + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._syncOff = true + return nil + end + eng = SyncEngine.shared() + if not eng then + self._syncOff = true + return nil + end + self._syncEngineRef = eng + end + if type(eng.protectPlaythrough) == "function" then + local meta = self.save and self.save.meta + eng:protectPlaythrough( + (self.save and self.save.version) or require("src.core.GameVersion").get(), + type(meta) == "table" and meta.playthroughId or nil) end - self._syncEngineRef = eng return eng end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 49f3a8e9..2d5d9418 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -124,12 +124,48 @@ function LauncherView.detach(imp) Kit.clearCaches() end +local function markNoDrag(imp, x, y, w, h) + if Kit.blockClicks then return end + local t = imp._noDragRects + if not t then t = {}; imp._noDragRects = t end + local n = (imp._noDragN or 0) + 1 + imp._noDragN = n + local r = t[n] + if not r then r = {}; t[n] = r end + r.x, r.y, r.w, r.h = x, y, w, h +end + +local function noDragAt(imp, x, y) + local rects = imp._noDragRects + for i = 1, imp._noDragN or 0 do + if inRect(rects[i], x, y) then return true end + end + return false +end + +local function armMouse(imp, x, y) + if noDragAt(imp, x, y) then + imp._clickPt = { x = x, y = y } + return + end + local shielded = imp._modalUpNow + imp._mouseAt = { + x = x, y = y, + region = not shielded and tabScrollMax(imp) > 0 + and inRect(imp._tabRegionRect, x, y) or false, + page = not shielded and (imp._pageScrollMax or 0) > 0 or false, + } + Kit.dragBegin(x, y) +end + -- ---------------------------------------------------------------- input --- The kit is polled, not evented: update() samples the mouse and turns a --- rising edge into a click point that the next draw consumes. Host-forwarded --- mousepressed stays unused, exactly as before, so Android's synthesized --- mouse path cannot double-fire a tap (#553) -- the dedup window below is the --- other half of that guarantee. +-- The kit is polled, not evented: update() samples the mouse. A press arms +-- a drag that scrolls like a finger, and the click dispatches on RELEASE so +-- the drag can disqualify it, exactly like the touch path below; only the +-- cartridge (which owns its own spin-drag) keeps the press-down click. +-- Host-forwarded mousepressed stays unused, exactly as before, so Android's +-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window +-- below is the other half of that guarantee. function LauncherView.update(imp, dt) if not imp._flex then return end if imp._launchFade then return end @@ -154,7 +190,39 @@ function LauncherView.update(imp, dt) if not touching and now >= (imp._suppressMouseUntil or 0) and now >= (imp._suppressClickUntil or 0) then local mx, my = love.mouse.getPosition() - imp._clickPt = { x = mx, y = my } + armMouse(imp, mx, my) + end + elseif down and imp._mouseAt then + local start = imp._mouseAt + local mx, my = love.mouse.getPosition() + local ddx, ddy = mx - start.x, my - start.y + if ddx * ddx + ddy * ddy > TAP_SLOP2 then + start.dragged = true + end + if start.dragged then + local last = start.lastY or start.y + local move = -(my - last) + if move ~= 0 and start.region then + local at, leftover = Kit.scrollHandoff(tabScrollAt(imp), + tabScrollMax(imp), move) + setTabScroll(imp, at) + move = leftover + end + if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then + local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0, + imp._pageScrollMax, move) + imp._pageScroll = at + move = leftover + end + if move ~= 0 then Kit.dragAdd(move) end + end + start.lastY = my + elseif not down and imp._mouseAt then + local start = imp._mouseAt + imp._mouseAt = nil + Kit.dragEnd() + if not start.dragged then + imp._clickPt = { x = start.x, y = start.y } end end imp._prevMouseDown = down @@ -233,10 +301,12 @@ function LauncherView.clickAt(imp, x, y) imp._clickPt = { x = x, y = y } end --- Event-driven click: a macOS trackpad tap delivers press+release inside one --- frame, so update()'s love.mouse.isDown poll never sees it. Mint the click --- from the press event under the poll's own suppression rules, and mark the --- press seen so the poll cannot mint a second one when isDown does catch it. +-- Event-driven press: a macOS trackpad tap delivers press+release inside one +-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag +-- from the press event under the poll's own suppression rules -- the poll's +-- release branch then mints the tap, still within the same frame for a +-- one-frame tap -- and mark the press seen so the poll cannot arm a second +-- one when isDown does catch it. function LauncherView.mousepressed(imp, x, y) if not imp._flex then return end local now = love.timer.getTime() @@ -245,7 +315,7 @@ function LauncherView.mousepressed(imp, x, y) or now < (imp._suppressClickUntil or 0) then return end - imp._clickPt = { x = x, y = y } + if not imp._mouseAt then armMouse(imp, x, y) end imp._prevMouseDown = true end @@ -495,6 +565,7 @@ end local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) local state = cartridgeState(imp, version) + markNoDrag(imp, x, y, w, h) local focused = Kit.focusable(key, x, y, w, h) local hot = Kit.hover(x, y, w, h) local active = state.active @@ -4483,7 +4554,7 @@ local function buildSyncHome(imp, m, eng) local linked = eng:linked() local codes = eng.codes local body = linked - and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.") + and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.") or Strings(SYNC_HINT) local innerW = w - 2 * pad local hintH = Kit.wrapHeight("small", body, innerW, 5) @@ -4868,13 +4939,15 @@ function LauncherView.draw(imp) Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0) imp._clickPt = nil imp._wheelY = 0 + imp._noDragN = 0 Theme.field() -- Everything from here to buildModals sits UNDER any open modal, so the -- whole stage draws shielded (no clicks, no hover, no focus ring) while -- one is up; buildModals lowers the shield for the modal's own controls. - Kit.blockClicks = modalUp(imp) + imp._modalUpNow = modalUp(imp) + Kit.blockClicks = imp._modalUpNow local step = Kit.scrollStep(m.s) do diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 9ce176ff..d5b4aecf 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1537,6 +1537,9 @@ function RomImporter:focus(f) self._modPress = nil return end + if type(self._sync) == "table" then + pcall(self._sync.noteResumed, self._sync) + end if not (f and self.android and self.workState ~= "working") then return end -- SAF create-document finished: GameActivity wrote export_done.flag. if love.filesystem.getInfo("export_done.flag", "file") then diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 2d899738..fa27805d 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts) self.opponentName = theirName -- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2 -- takes PrintBeginningBattleText's .trainerBattle arm, link included - self.introText = Strings("%s wants\nto fight!", theirName) + self.introText = self:romText("_TrainerWantsToFightText", + "%s wants\nto fight!", theirName) self.remoteHashes = {} self.localHashes = {} self.remoteParts = {} diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index 49c3298f..985ed415 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine SyncEngine.UPLOAD_DEBOUNCE = 5 SyncEngine.AUTO_INTERVAL = 300 +SyncEngine.RESUME_MIN_GAP = 60 SyncEngine.MAX_STEPS_PER_UPDATE = 8 local IDLE_STATUS = "Ready" @@ -133,6 +134,7 @@ function SyncEngine.new(opts) eng.modPlan = nil eng.shareCode = nil eng.clock = 0 + eng.autoAt = SyncEngine.AUTO_INTERVAL eng.queue = {} eng.pending = nil eng.uploadAt = nil @@ -258,6 +260,11 @@ function SyncEngine:update(dt) self.uploadAt = nil if self.state.enabled and self:linked() then self:syncNow() end end + if self.clock >= self.autoAt and not self:busy() + and (self.phase == "idle" or self.phase == "error") + and self.state.enabled and self:linked() then + self:syncNow() + end local steps = 0 while not self.pending and #self.queue > 0 and steps < SyncEngine.MAX_STEPS_PER_UPDATE do @@ -296,6 +303,7 @@ function SyncEngine:createAccount(label) eng.phase = "idle" eng.status = "Sync account created" eng:_persist() + eng:syncNow() end) end @@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled) return self.state.enabled end +function SyncEngine:protectPlaythrough(version, playthroughId) + self.protectedKey = SyncState.key(version, playthroughId) +end + +function SyncEngine:noteResumed() + if not (self.state.enabled and self:linked()) then return end + if self:busy() or self.phase == "conflict" then return end + if self.now() - (tonumber(self.state.lastSyncAt) or 0) + < SyncEngine.RESUME_MIN_GAP then + return + end + self:syncNow() +end + function SyncEngine:syncNow() if not self:linked() then return false, "this device is not linked" end if self.pending then return false, "sync is busy" end + self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL self.queue = {} self.conflicts = {} self.state.pendingConflicts = {} @@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState) self:_addConflict(entry, key, row) elseif localChanged then self:_queueUpload(entry, key, false) - elseif remoteChanged then + elseif remoteChanged and key ~= self.protectedKey then self:_queueDownload(key, entry.version, entry.playthroughId, "replace") end end end for key, row in pairs(remote) do - if not seen[key] then + if not seen[key] and key ~= self.protectedKey then local version, id = SyncState.splitKey(key) if version and id then self:_queueDownload(key, version, id, "replace", tonumber(row.rev)) diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 5dc2b694..5c39c7cc 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -185,14 +185,16 @@ local function release(game) return end game.stack:push(TextBox.new(game, - Strings("Once released,\n%s is\ngone forever. OK?", name), nil, { + (t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name)) + :gsub("{RAM:wStringBuffer}", name), nil, { defaultNo = true, noSound = true, choice = function(yes) if not yes then return end table.remove(box, list.index) require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, - Strings("%s was\nreleased outside.\fBye %s!", name, name))) + ((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name)) + :gsub("{RAM:wStringBuffer}", name)))) list:removeCurrent() end, })) diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index b185f6ad..23ecc7ad 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local QuantityBox = require("src.ui.QuantityBox") local Strings = require("src.core.Strings") +local romText = require("src.core.RomText") local ShopMenu = {} @@ -57,7 +58,8 @@ local function buy(game, stock) end local cost = qty * def.price -- _PokemartTellBuyPriceText + yes/no confirm - list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost) + list.footer = romText(game.data, "_PokemartTellBuyPriceText", + "%s?\nThat will be\n¥%d. OK?", def.name, cost) game.stack:push(ChoiceBox.new(game, function(yes) if not yes then list.footer = greet @@ -147,7 +149,8 @@ local function sell(game) return end -- _PokemartTellSellPriceText + yes/no confirm - list.footer = Strings("I can pay you\n¥%d for that.", unit * qty) + list.footer = romText(game.data, "_PokemartTellSellPriceText", + "I can pay you\n¥%d for that.", unit * qty) game.stack:push(ChoiceBox.new(game, function(yes) if not yes then list.footer = greet diff --git a/src/ui/SlotMachine.lua b/src/ui/SlotMachine.lua index 353b414a..a0c04734 100644 --- a/src/ui/SlotMachine.lua +++ b/src/ui/SlotMachine.lua @@ -46,6 +46,7 @@ local Font = require("src.render.Font") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local romText = require("src.core.RomText") local SlotMachine = {} SlotMachine.__index = SlotMachine @@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win) -- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port -- shows it in the box while the screen flashes. LinedUpText follows. self.yeah = (sym == "7") - self.message = Strings("%s lined up!\nScored %d coins!", sym, pay) + self.message = sym .. romText(self.game.data, "_LinedUpText", + " lined up!\nScored %d coins!", pay) -- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not -- credited until the player dismisses the "lined up" text (see startPayout). self.stage = "flash" diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 7ae2464f..18718dd2 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -918,16 +918,58 @@ function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows) return math.max(minRows or 1, math.min(maxRows or 99, per)) end +Kit.dragX = nil +Kit.dragY = nil +Kit.dragAccum = 0 + +function Kit.dragBegin(x, y) + Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0 +end + +function Kit.dragAdd(dy) + if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end +end + +function Kit.dragEnd() + Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0 +end + +local function dragOriginIn(x, y, w, h) + if not Kit.dragX then return false end + local x1, y1, x2, y2 = x, y, x + w, y + h + local c = Kit._clipRect + if c then + x1, y1 = math.max(x1, c.x), math.max(y1, c.y) + x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h) + end + return Kit.dragX >= x1 and Kit.dragX <= x2 + and Kit.dragY >= y1 and Kit.dragY <= y2 +end + -- Mouse wheel over a paginated list turns PAGES. The wheel still has to do -- something (users expect it), but it moves a bounded page index rather than -- driving a pixel offset, so there is no scroll state and no interpolation. function Kit.wheelPage(x, y, w, h, page, total, perPage) - if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end - if not Kit.hit(x, y, w, h) then return page end + if Kit.blockClicks then return page end local pages = math.max(1, math.ceil(total / math.max(1, perPage))) - local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages) - Kit.wheelY = 0 - return math.floor(moved) + local out = page + if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then + out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1), + 1, pages)) + Kit.wheelY = 0 + end + local acc = Kit.dragAccum or 0 + if acc ~= 0 and dragOriginIn(x, y, w, h) then + local stepPx = math.max(1, math.floor((h or 0) / 2)) + local flips = acc >= 0 and math.floor(acc / stepPx) + or -math.floor(-acc / stepPx) + if flips ~= 0 then + local want = (out or 1) + flips + out = math.floor(Theme.clamp(want, 1, pages)) + Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx + end + end + return out end function Kit.scrollExtent(contentH, viewH) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 3af3a100..088fbe84 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -846,15 +846,20 @@ function OverworldState:useSoftboiledFieldMove(user, target) if not user or not user.stats or not target or not target.stats or target == user or target.hp <= 0 or target.hp >= target.stats.hp or user.hp <= heal then - Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect."))) + Game.stack:push(TextBox.new(Game, + romText(Game.data, "_ItemUseNoEffectText", "It won't have\nany effect."))) return false end + local before = target.hp user.hp = user.hp - heal target.hp = math.min(target.stats.hp, target.hp + heal) require("src.core.Sound").play(Game.data, "Heal_HP") local def = Game.data.pokemon[target.species] + -- _PotionText's second slot is the recovered amount, same as + -- ItemEffects.lua's potion message -- the engine fallback never shows it Game.stack:push(TextBox.new(Game, - Strings("%s's HP\nwas restored!", target.nickname or def.name))) + romText(Game.data, "_PotionText", "%s's HP\nwas restored!", + target.nickname or def.name, target.hp - before))) return true end @@ -2126,7 +2131,8 @@ function OverworldState:tryHiddenObject(fx, fy) -- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872) local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", save.player.name, name) .. "\f" + romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!", + save.player.name, name) .. "\f" .. romText(Game.data, "_HiddenItemBagFullText", "But, {PLAYER} has\nno more room for\vother items!"))) return true @@ -2137,7 +2143,8 @@ function OverworldState:tryHiddenObject(fx, fy) -- text_asm tail runs it as PlaySoundWaitForCurrent + -- WaitForSoundToFinish once the box has printed (hidden_items.asm) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", save.player.name, name), + romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!", + save.player.name, name), nil, TextBox.soundOpts(Game, "Get_Item2"))) return true end @@ -2795,8 +2802,8 @@ function OverworldState:talkTo(npc) "No more room for\nitems!") if GameVersion.isYellow() then local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item - noRoom = Strings("%s found\n%s!", Game.save.player.name, name) - .. "\f" .. noRoom + noRoom = romText(Game.data, "_FoundItemText", "%s found\n%s!", + Game.save.player.name, name) .. "\f" .. noRoom end Game.stack:push(TextBox.new(Game, noRoom)) return @@ -2813,7 +2820,8 @@ function OverworldState:talkTo(npc) local ddef = Game.data.items[d.item] -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", Game.save.player.name, name), nil, + romText(Game.data, "_FoundItemText", "%s found\n%s!", + Game.save.player.name, name), nil, TextBox.soundOpts(Game, (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1"))) return @@ -3745,7 +3753,7 @@ function OverworldState:applyFieldPoison() local queue = {} for _, mon in ipairs(fainted) do local name = mon.nickname or Game.data.pokemon[mon.species].name - table.insert(queue, Strings("%s\nfainted!", name)) + table.insert(queue, romText(Game.data, "_PokemonFaintedText", "%s\nfainted!", name)) end local alive = false for _, mon in ipairs(save.party) do diff --git a/tests/engine/android_host_extension_test.lua b/tests/engine/android_host_extension_test.lua index aabef3f7..4033cbed 100644 --- a/tests/engine/android_host_extension_test.lua +++ b/tests/engine/android_host_extension_test.lua @@ -58,6 +58,38 @@ check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") < "secondary display monitoring starts before initial discovery") check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true), "a disconnected active display is rebound without replacing a live one") +check(source:find("private static volatile boolean secondaryHostResumed = false;", + 1, true), "secondary output tracks the primary activity lifecycle") +check(source:find("if (on && secondaryHostResumed)", 1, true) + and source:find("self == null || !secondaryHostResumed || !secondaryEnabled", + 1, true), + "paused hosts cannot reopen secondary output from a late mod frame") + +local pause = position("protected void onPause()") +local paused = assert(source:find("secondaryHostResumed = false;", pause, true)) +local teardown = assert(source:find("teardownSecondaryDisplay();", pause, true)) +local pauseSuper = assert(source:find("super.onPause();", pause, true)) +check(pause < paused and paused < teardown and teardown < pauseSuper, + "pause blocks secondary setup before dismissing its output") + +local resume = position("public void onResume()") +local resumeSuper = assert(source:find("super.onResume();", resume, true)) +local resumed = assert(source:find("secondaryHostResumed = true;", resume, true)) +local resumeSetup = assert(source:find("setupSecondaryDisplay();", resume, true)) +check(resume < resumeSuper and resumeSuper < resumed and resumed < resumeSetup, + "resume permits secondary setup only after the primary activity resumes") + +local destroy = position("protected void onDestroy()") +local destroyTeardown = assert(source:find("teardownSecondaryDisplay();", destroy, true)) +local destroySuper = assert(source:find("super.onDestroy();", destroy, true)) +check(destroy < destroyTeardown and destroyTeardown < destroySuper, + "destroy always dismisses secondary output before SDL destruction") + +local mainFile = assert(io.open("main.lua", "rb")) +local main = mainFile:read("*a") +mainFile:close() +check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true), + "returning from a game disables mod-owned secondary output") check(not source:lower():find("openxr", 1, true), "generic Android activity must not require OpenXR") diff --git a/tests/engine/battle_ball_dodge_romtext.lua b/tests/engine/battle_ball_dodge_romtext.lua new file mode 100644 index 00000000..f49c96a3 --- /dev/null +++ b/tests/engine/battle_ball_dodge_romtext.lua @@ -0,0 +1,87 @@ +-- BattleState:throwBall()'s can't-be-caught path used to queue two +-- separate Strings() literals ("It dodged the\nthrown BALL!" then "This +-- POKéMON\ncan't be caught!"). The real ROM label _ItemUseBallText00 +-- combines both as one \f-paged string. TextBox.new() would split \f +-- itself, but throwBall() queues through self:sayNext(), which goes +-- through the battle queue's own BattleState:startMessage() -- and that +-- one only splits on \n/\v, not \f (confirmed live: the \f landed +-- mid-line and the second sentence overflowed off the box instead of +-- starting a fresh page). The fix resolves the label once, then splits +-- it the same way TextBox.lua does and queues one sayNext per page. This +-- test fakes the label and checks the two pages reach the queue as two +-- separate messages, in order, not merged into one with a raw \f still +-- inside it. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local function mkbattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 10) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 8) + battle.ghost = true -- forces the can't-be-caught path + return battle +end + +-- throwBall defers its message-queuing work into self.queue via one +-- top-level self:act(fn); run only that one function to reach the +-- say() calls the fix touches. It's the last entry throwBall itself +-- appends (after the immediate sayAuto), and it further queues its own +-- self:act(function() self:executeAction(...) end) for the actual enemy +-- turn -- deliberately NOT run here (out of scope, and running the queue +-- generically after mutation risks looping into a real turn simulation) +local function runThrowBallAct(battle) + for i = #battle.queue, 1, -1 do + if battle.queue[i].fn then + battle.queue[i].fn() + return + end + end +end + +local function textEntries(battle) + local out = {} + for _, entry in ipairs(battle.queue) do + if entry.text then out[#out + 1] = entry.text end + end + return out +end + +-- translated: the faked label's two \f-separated pages reach the queue +-- as two separate messages, in order, and neither one still contains a +-- raw \f (which would mean the battle queue's own renderer has to deal +-- with it, and it can't) +do + local battle = mkbattle() + Data.text._ItemUseBallText00 = "FAKE-DODGE!\fFAKE-CANTCATCH!" + battle:throwBall("FIX_BALL") + runThrowBallAct(battle) + local texts = textEntries(battle) + T.eq(texts[1], "FAKE-DODGE!", "page 1 reaches the queue on its own") + T.eq(texts[2], "FAKE-CANTCATCH!", "page 2 follows right after, still in order") + for _, t in ipairs(texts) do + T.check(not t:find("\f", 1, true), "no queued message still carries a raw \\f") + end + Data.text._ItemUseBallText00 = nil +end + +-- vanilla: no catalog entry still falls back to the two English pages, +-- split the same way +do + local battle = mkbattle() + battle:throwBall("FIX_BALL") + runThrowBallAct(battle) + local texts = textEntries(battle) + T.eq(texts[1], "It dodged the\nthrown BALL!", "vanilla page 1") + T.eq(texts[2], "This POKéMON\ncan't be caught!", "vanilla page 2") +end + +T.finish("battle_ball_dodge_romtext") diff --git a/tests/engine/battle_catch_messages_romtext.lua b/tests/engine/battle_catch_messages_romtext.lua new file mode 100644 index 00000000..b0feb333 --- /dev/null +++ b/tests/engine/battle_catch_messages_romtext.lua @@ -0,0 +1,77 @@ +-- BattleState:storeCaughtMon() queues up to two plain-Lua-literal +-- messages: the new-Pokedex-data line (_ItemUseBallText06) and, when the +-- party is full, the box-transfer line (_ItemUseBallText07/08, keyed on +-- EVENT_MET_BILL -- two full, independently-translated ROM strings, not +-- one template with a substituted PC name). This test fakes all three +-- labels and checks the queued messages use them, not the English +-- literals. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local function findText(battle, needle) + for _, entry in ipairs(battle.queue) do + if entry.text and entry.text:find(needle, 1, true) then return entry.text end + end + return nil +end + +local function mkbattle(partySize, metBill) + local save = SaveData.newGame() + save.party = {} + for i = 1, partySize do + save.party[i] = Pokemon.new(Data, "FIXMON_A", 5) + end + save.flags = save.flags or {} + save.flags.EVENT_MET_BILL = metBill + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + return BattleState.newWild(game, "FIXMON_C", 8) +end + +-- empty party: Party.add succeeds, only the new-Pokedex-data message fires +do + local battle = mkbattle(0, false) + Data.text._ItemUseBallText06 = "FAKE-DEX {RAM:wEnemyMonNick} FAKE!" + battle:storeCaughtMon() + T.eq(findText(battle, "FAKE-DEX"), "FAKE-DEX " .. battle.enemy.name .. " FAKE!", + "a translated _ItemUseBallText06 reaches the new-Pokedex-data message") + Data.text._ItemUseBallText06 = nil +end + +-- full party, EVENT_MET_BILL true: box transfer via _ItemUseBallText07 +do + local battle = mkbattle(6, true) + Data.text._ItemUseBallText07 = "FAKE-BILL {RAM:wBoxMonNicks} FAKE!" + battle:storeCaughtMon() + T.eq(findText(battle, "FAKE-BILL"), "FAKE-BILL " .. battle.enemy.name .. " FAKE!", + "EVENT_MET_BILL true routes through the translated _ItemUseBallText07") + Data.text._ItemUseBallText07 = nil +end + +-- full party, EVENT_MET_BILL false: box transfer via _ItemUseBallText08 +do + local battle = mkbattle(6, false) + Data.text._ItemUseBallText08 = "FAKE-SOMEONE {RAM:wBoxMonNicks} FAKE!" + battle:storeCaughtMon() + T.eq(findText(battle, "FAKE-SOMEONE"), "FAKE-SOMEONE " .. battle.enemy.name .. " FAKE!", + "EVENT_MET_BILL false routes through the translated _ItemUseBallText08") + Data.text._ItemUseBallText08 = nil +end + +-- vanilla, full party, EVENT_MET_BILL true: English literal, BILL's PC +do + local battle = mkbattle(6, true) + battle:storeCaughtMon() + T.eq(findText(battle, "transferred"), + battle.enemy.name .. " was\ntransferred to\nBILL's PC!", + "no catalog entry falls back to the English BILL's-PC literal") +end + +T.finish("battle_catch_messages_romtext") diff --git a/tests/engine/battle_fainted_message_romtext.lua b/tests/engine/battle_fainted_message_romtext.lua new file mode 100644 index 00000000..6f1ce0b7 --- /dev/null +++ b/tests/engine/battle_fainted_message_romtext.lua @@ -0,0 +1,64 @@ +-- BattleState:onFaint's "%s\nfainted!" collapsed two distinct ROM +-- strings (_EnemyMonFaintedText already carries its own "Enemy" wording; +-- _PlayerMonFaintedText does not) into one literal, substituted with +-- displayName(battler) -- which itself runs the enemy name through a +-- SEPARATE Strings("Enemy %s", ...) call. The fix passes the raw +-- battler.name and lets each label supply its own wording. This test +-- fakes both labels and checks the raw name reaches the right one, with +-- no "Enemy" ever duplicated. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) + +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") + +local function mkbattle() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 10) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + return BattleState.newWild(game, "FIXMON_C", 8) +end + +-- finds the queued say-message text (onFaint queues several entries: a +-- wait, then the say) +local function findText(battle) + for _, entry in ipairs(battle.queue) do + if entry.text then return entry.text end + end + return nil +end + +-- enemy: translated _EnemyMonFaintedText reaches onFaint, raw name only +do + local battle = mkbattle() + Data.text._EnemyMonFaintedText = "FAKE-ENEMY {RAM:wEnemyMonNick} FAKE!" + battle:onFaint(battle.enemy) + T.eq(findText(battle), "FAKE-ENEMY " .. battle.enemy.name .. " FAKE!", + "a translated _EnemyMonFaintedText reaches the enemy faint message") + Data.text._EnemyMonFaintedText = nil +end + +-- enemy vanilla: the English literal already carries "Enemy " itself +do + local battle = mkbattle() + battle:onFaint(battle.enemy) + T.eq(findText(battle), "Enemy " .. battle.enemy.name .. "\nfainted!", + "no catalog entry falls back to the English literal, Enemy included") +end + +-- player: translated _PlayerMonFaintedText reaches onFaint +do + local battle = mkbattle() + Data.text._PlayerMonFaintedText = "FAKE-PLAYER {RAM:wBattleMonNick} FAKE!" + battle:onFaint(battle.player) + T.eq(findText(battle), "FAKE-PLAYER " .. battle.player.name .. " FAKE!", + "a translated _PlayerMonFaintedText reaches the player faint message") + Data.text._PlayerMonFaintedText = nil +end + +T.finish("battle_fainted_message_romtext") diff --git a/tests/engine/box_release_confirmation_romtext.lua b/tests/engine/box_release_confirmation_romtext.lua new file mode 100644 index 00000000..c2db6709 --- /dev/null +++ b/tests/engine/box_release_confirmation_romtext.lua @@ -0,0 +1,129 @@ +-- BoxMenu's RELEASE confirmation ("Once released,\n%s is\ngone forever. +-- OK?") used to be a bare Lua literal. tests/engine/pc_release.lua only +-- ever runs with an empty Data.text, so it can't tell a properly-wired +-- t._OnceReleasedText or "..." fallback apart from a literal that never +-- looked at t at all -- every assertion there passes either way. This +-- test drives the same interactive release flow with a faked +-- Data.text._OnceReleasedText and checks the pushed TextBox's raw text +-- (captured via a TextBox.new spy, so real pagination/choice behavior is +-- untouched) uses the translated value, not the English literal. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local ids = T.fixtures.ids +require("src.render.Font").load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local Boxes = require("src.pokemon.Boxes") +local TextBox = require("src.render.TextBox") +local BoxMenu = require("src.ui.BoxMenu") +local ListMenu = require("src.ui.ListMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") + +local realCry, realPlay = Sound.playCry, Sound.play +Sound.playCry = function() end +Sound.play = function() end + +-- spy: records every raw text TextBox.new receives, without disturbing +-- pagination/choice re-push, so the interactive flow behaves exactly like +-- pc_release.lua's +local captured +local realNew = TextBox.new +TextBox.new = function(game, text, onDone, opts) + captured[#captured + 1] = text + return realNew(game, text, onDone, opts) +end + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() + local t = self.states[#self.states] + self.states[#self.states] = nil + return t +end +function stack:top() return self.states[#self.states] end +function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end +end + +local pressed = {} +local function press(btn) + pressed = { [btn] = true } + stack:update(1 / 60) + pressed = {} +end + +local function topMt() return getmetatable(stack:top()) end +local function mash(btn, cond, n) + for _ = 1, (n or 400) do + if cond() then return true end + press(btn) + end + return false +end + +local function mkGame() + stack.states = {} + captured = {} + local game = { + data = Data, + save = SaveData.newGame(), + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, + } + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + local box = Boxes.active(game.save) + box[1] = Pokemon.new(Data, ids.species[1], 5) + return game, box +end + +local function releaseFirstMon(game) + stack:push(BoxMenu.new(game)) + press("down"); press("down"); press("a") -- open RELEASE list + T.check(topMt() == ListMenu, "RELEASE opens the box list") + press("a") -- choose the first (only) mon + T.check(mash("a", function() return topMt() == ChoiceBox end), + "confirm choice opens") + -- the confirmation TextBox is captured[1] the moment it was pushed, + -- before this mash even ran + return captured[1] +end + +-- monName (BoxMenu.lua): mon.nickname or def.name +local function monName(box) + local mon = box[1] + local def = Data.pokemon[mon.species] + return mon.nickname or def.name +end + +-- translated: the fake value must reach the pushed TextBox +do + local game, box = mkGame() + local name = monName(box) + Data.text._OnceReleasedText = "FAKE {RAM:wStringBuffer} released!" + local text = releaseFirstMon(game) + T.eq(text, "FAKE " .. name .. " released!", + "a translated _OnceReleasedText reaches the release confirmation") + Data.text._OnceReleasedText = nil +end + +-- vanilla: with no catalog entry, the English literal still substitutes +do + local game, box = mkGame() + local name = monName(box) + local text = releaseFirstMon(game) + T.eq(text, "Once released,\n" .. name .. " is\ngone forever. OK?", + "no catalog entry still falls back to the English literal") +end + +TextBox.new = realNew +Sound.playCry, Sound.play = realCry, realPlay +T.finish("box_release_confirmation_romtext") diff --git a/tests/engine/launcher_scroll_test.lua b/tests/engine/launcher_scroll_test.lua index 0816897c..b896fba3 100644 --- a/tests/engine/launcher_scroll_test.lua +++ b/tests/engine/launcher_scroll_test.lua @@ -300,6 +300,88 @@ LauncherView.draw(edgeImp) check((edgeImp._tabScroll.skins or 0) > 0, "which reaches the tab region even though the cursor is below it") +Kit.blockClicks = false +Kit._clipRect = nil +Kit.wheelY = 0 +Kit.mouseX, Kit.mouseY = 400, 400 +Kit.dragBegin(50, 50) +Kit.dragAdd(120) +local pg = Kit.wheelPage(0, 0, 100, 100, 1, 100, 10) +eq(pg, 3, "a drag that crossed two half-heights turns two pages") +eq(Kit.dragAccum, 20, "and keeps the remainder for the next flip") +Kit.dragAdd(-140) +pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10) +eq(pg, 1, "dragging back down returns those pages") +eq(Kit.dragAccum, -20, "with the remainder's sign preserved") +Kit.dragAdd(-80) +pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10) +eq(pg, 1, "a drag past the first page clamps to it") +eq(Kit.dragAccum, 0, "and drops the pile-up so reversing is instant") +Kit.dragAdd(200) +pg = Kit.wheelPage(200, 200, 100, 100, pg, 100, 10) +eq(pg, 1, "a drag that began outside the list is not the list's") +eq(Kit.dragAccum, 200, "and its travel stays queued") +Kit.dragEnd() +eq(Kit.dragAccum, 0, "releasing the button retires the gesture") + +window(360, 780) +local mouseImp = skinLauncher(12) +LauncherView.draw(mouseImp) +LauncherView.draw(mouseImp) +local mreg = mouseImp._tabRegionRect +local mmax = mouseImp._tabScrollMax.skins +check(mmax > 0, "the mouse-dragged panel has travel") +local mdown = false +love.mouse.isDown = function() return mdown end + +pointer(mreg.x + 20, mreg.y + 40) +mdown = true +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, + "a press over a scrollable region mints no click") +check(mouseImp._mouseAt ~= nil, "it arms a drag instead") +pointer(mreg.x + 20, mreg.y + 40 - 200) +LauncherView.update(mouseImp, 0.016) +eq(mouseImp._tabScroll.skins, math.min(200, mmax), + "dragging the held mouse scrolls the panel by its travel") +eq(mouseImp._pageScroll or 0, 0, + "while the panel still has travel, the page waits") +pointer(mreg.x + 20, mreg.y + 40 - 200 - mmax * 2) +LauncherView.update(mouseImp, 0.016) +eq(mouseImp._tabScroll.skins, mmax, + "a longer mouse drag reaches the panel's bottom") +check((mouseImp._pageScroll or 0) > 0, "and spills into the page from there") +mdown = false +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, "a released drag is not a click") +check(mouseImp._mouseAt == nil, "and the gesture is retired") + +pointer(mreg.x + 20, mreg.y + 40) +mdown = true +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt == nil, "a fresh press still holds its click back") +mdown = false +LauncherView.update(mouseImp, 0.016) +check(mouseImp._clickPt ~= nil, + "press and release without travel is still a tap, on release") +mouseImp._clickPt = nil + +LauncherView.draw(gameImp) +LauncherView.draw(gameImp) +check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect") +local cart = gameImp._noDragRects[1] +pointer(cart.x + cart.w / 2, cart.y + cart.h / 2) +mdown = true +LauncherView.update(gameImp, 0.016) +check(gameImp._clickPt ~= nil, + "a press on the cartridge clicks at once so its own spin-drag still owns " + .. "the gesture") +check(gameImp._mouseAt == nil, "and never arms the scroll drag") +gameImp._clickPt = nil +mdown = false +LauncherView.update(gameImp, 0.016) +love.mouse.isDown = nil + local function read(path) local f = assert(io.open(path, "r")) local src = f:read("*a") diff --git a/tests/engine/overworld_field_faint_heal_romtext.lua b/tests/engine/overworld_field_faint_heal_romtext.lua new file mode 100644 index 00000000..2e2344dd --- /dev/null +++ b/tests/engine/overworld_field_faint_heal_romtext.lua @@ -0,0 +1,139 @@ +-- Two OverworldController.lua messages used to be bare Lua literals: +-- applyFieldPoison()'s "%s\nfainted!" (the third of three collapsed +-- fainted-message ROM strings, _PokemonFaintedText) and +-- useSoftboiledFieldMove()'s "It won't have\nany effect."/"%s's HP\nwas +-- restored!" (the same _ItemUseNoEffectText/_PotionText labels +-- ItemEffects.lua's real potion message already uses -- _PotionText's +-- second slot is the actual amount healed, which the old literal never +-- showed at all). +-- +-- Uses the debug.setupvalue technique already established in +-- oaks_pc_flow.lua to fake the module-level Game/TextBox upvalues +-- ROM-free, without going through the heavy OverworldState:enter(). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() + +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local pushed = {} +local textBoxStub = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} +local realSound = package.loaded["src.core.Sound"] +package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } + +local function mkGame() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + pushed = {} + return { + data = Data, save = save, + stack = { push = function(_, item) pushed[#pushed + 1] = item end }, + } +end + +for _, name in ipairs({ "applyFieldPoison", "useSoftboiledFieldMove" }) do + T.check(setUpvalue(OW[name], "Game", mkGame()), ("Game upvalue on %s"):format(name)) + T.check(setUpvalue(OW[name], "TextBox", textBoxStub), ("TextBox upvalue on %s"):format(name)) +end + +local fakeSelf = setmetatable({}, { __index = OW }) + +-- ---- applyFieldPoison: _PokemonFaintedText ---- +do + local game = mkGame() + setUpvalue(OW.applyFieldPoison, "Game", game) + local mon = game.save.party[1] + mon.status = "PSN" + mon.hp = 1 -- one poison tick (1 dmg by default) faints it + game.save.poisonSteps = 3 -- (3+1) % 4 == 0: this step ticks poison + + Data.text._PokemonFaintedText = "FAKE {RAM:wNameBuffer} FAKE!" + fakeSelf:applyFieldPoison() + T.eq(pushed[1] and pushed[1].text, "FAKE " .. (mon.nickname or "FIXMON A") .. " FAKE!", + "a translated _PokemonFaintedText reaches the field-poison faint message") + Data.text._PokemonFaintedText = nil +end + +-- vanilla: no catalog entry, English literal +do + local game = mkGame() + setUpvalue(OW.applyFieldPoison, "Game", game) + local mon = game.save.party[1] + mon.status = "PSN" + mon.hp = 1 + game.save.poisonSteps = 3 + + fakeSelf:applyFieldPoison() + T.eq(pushed[1] and pushed[1].text, + (mon.nickname or "FIXMON A") .. "\nfainted!", + "no catalog entry falls back to the English fainted literal") +end + +-- ---- useSoftboiledFieldMove: _ItemUseNoEffectText / _PotionText ---- +do + local game = mkGame() + setUpvalue(OW.useSoftboiledFieldMove, "Game", game) + local user = Pokemon.new(Data, "FIXMON_A", 20) + local target = Pokemon.new(Data, "FIXMON_B", 20) + target.hp = target.stats.hp -- already full: no effect + + Data.text._ItemUseNoEffectText = "FAKE-NOEFFECT!" + local ok = fakeSelf:useSoftboiledFieldMove(user, target) + T.check(ok == false, "a full-HP target reports no effect") + T.eq(pushed[1] and pushed[1].text, "FAKE-NOEFFECT!", + "a translated _ItemUseNoEffectText reaches the no-effect message") + Data.text._ItemUseNoEffectText = nil +end + +do + local game = mkGame() + setUpvalue(OW.useSoftboiledFieldMove, "Game", game) + local user = Pokemon.new(Data, "FIXMON_A", 20) + local target = Pokemon.new(Data, "FIXMON_B", 20) + target.hp = target.stats.hp - 10 -- missing exactly 10 HP + + Data.text._PotionText = "FAKE {RAM:wNameBuffer} healed {NUM:wHPBarHPDifference, 2, 3}!" + local ok = fakeSelf:useSoftboiledFieldMove(user, target) + T.check(ok == true, "a damaged target heals successfully") + T.eq(pushed[1] and pushed[1].text, + "FAKE " .. (target.nickname or "FIXMON B") .. " healed 10!", + "a translated _PotionText reaches the heal message, amount included") + Data.text._PotionText = nil +end + +-- vanilla: no catalog entry, the fallback's single %s slot still fills +-- correctly (the amount is silently dropped by design, same as +-- ItemEffects.lua's own _PotionText fallback -- not a regression, this +-- matches the pre-fix literal's behavior exactly) +do + local game = mkGame() + setUpvalue(OW.useSoftboiledFieldMove, "Game", game) + local user = Pokemon.new(Data, "FIXMON_A", 20) + local target = Pokemon.new(Data, "FIXMON_B", 20) + target.hp = target.stats.hp - 10 + local ok = fakeSelf:useSoftboiledFieldMove(user, target) + T.check(ok == true, "a damaged target heals successfully (vanilla)") + T.eq(pushed[1] and pushed[1].text, + (target.nickname or "FIXMON B") .. "'s HP\nwas restored!", + "no catalog entry falls back to the English literal (no amount shown)") +end + +package.loaded["src.core.Sound"] = realSound +T.finish("overworld_field_faint_heal_romtext") diff --git a/tests/engine/overworld_hidden_item_romtext.lua b/tests/engine/overworld_hidden_item_romtext.lua new file mode 100644 index 00000000..f1804e49 --- /dev/null +++ b/tests/engine/overworld_hidden_item_romtext.lua @@ -0,0 +1,85 @@ +-- OverworldState:tryHiddenObject()'s "%s found\n%s!" message used to +-- substitute both the player name and the item name into one bare Lua +-- literal. The real _FoundHiddenItemText label leads with a {PLAYER} +-- named token, which romText auto-fills from a 2-arg call in the same +-- order the literal already used -- this test checks both slots land +-- correctly (an accidental argument swap is the easy mistake this shape +-- invites). +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() + +local SaveData = require("src.core.SaveData") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local pushed = {} +local textBoxStub = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, + soundOpts = function() return {} end, +} + +local MAP_ID = "FIX_TOWN" +Data.field.hiddenItems[MAP_ID] = { { x = 3, y = 3, item = "FIX_BALL" } } + +local function mkGame() + local save = SaveData.newGame() + save.player.name = "FAKEPLAYER" + pushed = {} + return { + data = Data, save = save, + stack = { push = function(_, item) pushed[#pushed + 1] = item end }, + } +end + +T.check(setUpvalue(OW.tryHiddenObject, "Game", mkGame()), "Game upvalue on tryHiddenObject") +T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject") + +local fakeSelf = setmetatable({ map = { id = MAP_ID } }, { __index = OW }) + +-- translated: player name and item name both land in the right slots +do + local game = mkGame() + setUpvalue(OW.tryHiddenObject, "Game", game) + Data.text._FoundHiddenItemText = "FAKE {PLAYER} found FAKE {RAM:wNameBuffer} FAKE!" + local found = fakeSelf:tryHiddenObject(3, 3) + T.check(found == true, "the hidden item at (3,3) is found") + T.eq(pushed[1] and pushed[1].text, + "FAKE FAKEPLAYER found FAKE FIX BALL FAKE!", + "a translated _FoundHiddenItemText fills both {PLAYER} and the item name") + Data.text._FoundHiddenItemText = nil +end + +-- vanilla: no catalog entry, so romText falls back to plain +-- Strings(fallback, ...) -- the fallback literal is "%s found\n%s!" (both +-- slots plain %s, matching the pre-fix literal's own shape), not the +-- {PLAYER} token the real label uses, since Strings() never does +-- {TOKEN} substitution on its own. (A {PLAYER}-token fallback would +-- still render correctly too, since the real TextBox.new always runs +-- TextBox.substitute over whatever text it's given -- but this test +-- stubs TextBox without that call, and the fallback shouldn't lean on a +-- substitution pass happening downstream regardless.) +do + local game = mkGame() + setUpvalue(OW.tryHiddenObject, "Game", game) + game.save.hiddenTaken = {} -- fresh spot + local found = fakeSelf:tryHiddenObject(3, 3) + T.check(found == true, "the hidden item is found again in a fresh game") + T.eq(pushed[1] and pushed[1].text, "FAKEPLAYER found\nFIX BALL!", + "with no catalog entry, the fallback still fills both the player " + .. "and item name via plain %s substitution") +end + +T.finish("overworld_hidden_item_romtext") diff --git a/tests/engine/slot_machine_lined_up_romtext.lua b/tests/engine/slot_machine_lined_up_romtext.lua new file mode 100644 index 00000000..699007c4 --- /dev/null +++ b/tests/engine/slot_machine_lined_up_romtext.lua @@ -0,0 +1,39 @@ +-- SlotMachine:resolveWin's "%s lined up!\nScored %d coins!" message used +-- to interpolate the symbol id AND the payout into one bare Lua literal. +-- The real _LinedUpText label has no slot for the symbol at all (the +-- original ROM drew it separately) -- only for the coin count. This test +-- fakes _LinedUpText and checks the symbol is concatenated in front of the +-- translated suffix, with the payout correctly substituted into it. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() + +local SlotMachine = require("src.ui.SlotMachine") + +local function mkSelf() + local game = { data = Data, save = { coins = 0 } } + return setmetatable({ game = game, allowMatchesCounter = 0 }, SlotMachine) +end + +-- translated: the fake suffix reaches self.message, with the symbol +-- concatenated in front and the payout substituted into the fake text +do + local self = mkSelf() + Data.text._LinedUpText = " FAKE-SUFFIX {RAM:wStringBuffer}!" + self:resolveWin({ symbol = "CHERRY", payout = 8 }) + T.eq(self.message, "CHERRY FAKE-SUFFIX 8!", + "a translated _LinedUpText reaches the lined-up message") + Data.text._LinedUpText = nil +end + +-- vanilla: with no catalog entry, the English literal still substitutes, +-- with its own leading space (the original had no slot for the symbol) +do + local self = mkSelf() + self:resolveWin({ symbol = "CHERRY", payout = 8 }) + T.eq(self.message, "CHERRY lined up!\nScored 8 coins!", + "no catalog entry still falls back to the English literal") +end + +T.finish("slot_machine_lined_up_romtext") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index f6ee37fd..ddd3bc78 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -94,20 +94,26 @@ do local eng, transport = engine({ ["POST /sync/create"] = { code = 200, body = '{"account":"aa11","code1":"11112222","code2":"33334444","deviceToken":"tok"}' }, - }, {}, SyncState.defaults()) + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":1}' }, + }, { saveEntry("red", "abc", 500, 400) }, SyncState.defaults()) T.eq(eng:linked(), false, "a fresh engine is not linked") T.eq(eng.status, "Not set up", "and says so") eng:createAccount("laptop") - pump(eng, 3) + pump(eng) T.eq(eng:linked(), true, "creating an account links this device") T.eq(eng.state.account, "aa11", "and stores the account id") T.eq(eng.codes.code1, "1111-2222", "the first code is shown grouped") T.eq(eng.codes.code2, "3333-4444", "and so is the second") T.eq(eng.state.code1, nil, "codes never enter the persisted state") + T.eq(transport.sent[2].url, "http://sync.test/sync/state", + "creating the account starts a sync straight away") + T.eq(transport.sent[3].method, "PUT", + "so the saves that existed before setup are uploaded") + T.eq(SyncState.rev(eng.state, "red/abc"), 1, "and the served rev is remembered") T.eq(eng.phase, "idle", "and the engine settles") - T.eq(#transport.sent, 1, "one request was made") end do @@ -293,14 +299,105 @@ do T.eq(#transport.sent, 0, "with sync off an in-game save uploads nothing") end +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}) + eng:update(SyncEngine.AUTO_INTERVAL - 1) + T.eq(#transport.sent, 0, "an idle linked engine does not poll early") + eng:update(1) + T.eq(#transport.sent, 1, "after the auto interval it checks the server") + pump(eng) + T.eq(eng.phase, "idle", "and settles") + eng:update(SyncEngine.AUTO_INTERVAL - 10) + T.eq(#transport.sent, 1, "the next poll waits a whole interval again") + eng:update(10) + T.eq(#transport.sent, 2, "then fires") +end + +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}, SyncState.defaults()) + eng:update(SyncEngine.AUTO_INTERVAL * 2) + T.eq(#transport.sent, 0, "an unlinked engine never polls on its own") +end + +do + local calls = 0 + local eng = engine({ + ["GET /sync/state"] = function() + calls = calls + 1 + if calls == 1 then return { code = 500, body = '{"error":"down"}' } end + return { code = 200, body = '{"saves":{}}' } + end, + }, {}) + eng:syncNow() + pump(eng, 3) + T.eq(eng.phase, "error", "the first sync fails") + eng:update(SyncEngine.AUTO_INTERVAL) + pump(eng, 3) + T.eq(eng.phase, "idle", "the auto interval retries and recovers") +end + +do + local eng, transport = conflictEngine() + eng:syncNow() + pump(eng) + local sent = #transport.sent + eng:update(SyncEngine.AUTO_INTERVAL * 2) + T.eq(#transport.sent, sent, "a waiting conflict is never auto-synced over") + T.eq(eng.phase, "conflict", "the player still decides") +end + +do + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + }, {}) + eng:noteResumed() + T.eq(#transport.sent, 1, "regaining the app checks the server") + pump(eng) + eng:noteResumed() + T.eq(#transport.sent, 1, "but not twice in quick succession") +end + +do + local eng, transport = engine({}, {}, SyncState.defaults()) + eng:noteResumed() + T.eq(#transport.sent, 0, "an unlinked engine ignores a resume") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 2, 500) + local eng, transport, saves = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":4,"meta":{"savedAt":900}},' .. + '"gold/xyz":{"rev":1,"meta":{"savedAt":900}}}}' }, + ["GET /sync/save"] = { code = 200, + body = '{"rev":1,"meta":{"savedAt":900},"blob":"return { player = {} }"}' }, + }, { saveEntry("red", "abc", 500, 400) }, state) + eng:protectPlaythrough("red", "abc") + eng:syncNow() + pump(eng) + T.eq(#saves.writes, 1, "only the save that is not being played downloads") + T.eq(saves.writes[1].version, "gold", "the other playthrough still arrives") + T.eq(SyncState.rev(eng.state, "red/abc"), 2, + "the live playthrough keeps its rev so the launcher can fetch it later") + T.eq(eng.phase, "idle", "and the sync settles") + eng:protectPlaythrough("red", nil) + T.eq(eng.protectedKey, nil, "no live playthrough means no protection") +end + do local eng = engine({ ["POST /sync/create"] = { code = 200, body = '{"account":"aa11","code1":"11112222","code2":"33334444",' .. '"deviceToken":"tok","device":"0a1b2c3d"}' }, + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, }, {}, SyncState.defaults()) eng:createAccount("laptop") - pump(eng, 3) + pump(eng) T.eq(eng.state.deviceId, "0a1b2c3d", "creating an account records the id the server gave this device") T.eq(SyncState.sanitize(eng.state).deviceId, "0a1b2c3d",