diff --git a/data/scripts/flavor/pewter_city.lua b/data/scripts/flavor/pewter_city.lua index b5047092..eb525b7d 100644 --- a/data/scripts/flavor/pewter_city.lua +++ b/data/scripts/flavor/pewter_city.lua @@ -16,9 +16,13 @@ local function push(game, s, done) game.stack:push(TextBox.new(game, s, done)) end +-- PrintText on a text_end string returns with the box still drawn and +-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters, +-- engine/menus/text_box.asm); no A press clears the question first. Ride +-- TextBox's opts.choice, the same as Commands.ask (#854). local function ask(game, s, cb) - local ChoiceBox = require("src.ui.ChoiceBox") - push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end M.PEWTER_CITY = { diff --git a/data/scripts/flavor/viridian_city.lua b/data/scripts/flavor/viridian_city.lua index b9e476da..f78626f8 100644 --- a/data/scripts/flavor/viridian_city.lua +++ b/data/scripts/flavor/viridian_city.lua @@ -23,9 +23,13 @@ local function push(game, s, done) game.stack:push(TextBox.new(game, s, done)) end +-- PrintText on a text_end string returns with the box still drawn and +-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters, +-- engine/menus/text_box.asm); no A press clears the question first. Ride +-- TextBox's opts.choice, the same as Commands.ask (#854). local function ask(game, s, cb) - local ChoiceBox = require("src.ui.ChoiceBox") - push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end M.VIRIDIAN_CITY = { diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 56567a50..8cca471b 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -517,6 +517,14 @@ M.GAME_CORNER = { done() return end + -- GameCornerRocketText hands the battle its own loss line through + -- SaveEndBattleTextPointers (.BattleEndText -> + -- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText + -- prints it ON the battle screen between TrainerDefeatedText and + -- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory). + -- He is a text_asm trainer with no def_trainers header, so there is no + -- header.won for engageTrainer to find and the line has to be handed + -- over here or it never shows at all (#862). ow:engageTrainer(npc, function() if not ow:trainerDefeated(npc) then done() @@ -527,19 +535,44 @@ M.GAME_CORNER = { game.data.text._GameCornerRocketAfterBattleText or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", function() - -- #198: GameCornerRocketExitScript (scripts/GameCorner.asm) - -- ApplyMovementData walks the grunt one tile UP into the poster - -- (the hideout's secret entrance at 9,4) before HideObject, so - -- he leaves the floor rather than popping out of existence on - -- (9,5). scriptMove locks player input (#scriptMoves>0) and - -- ignores collision, so we despawn + unfreeze (done) only once - -- the step lands. - ow:scriptMove(npc, "up", 1, function() - hideRocket() - done() - end) + -- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm) + -- picks the exit walk from where the player is standing, because + -- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to + -- from the south) or wXCoord == 8 (from the west) leaves the row + -- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps + -- RIGHT; otherwise the player is east of him on (10,5) and + -- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP + -- and right again to go AROUND him. pokeyellow's copy of the + -- around-path takes one extra RIGHT on the lower row before coming + -- back up (it also has to clear Pikachu); both versions end on + -- (15,5). He never steps UP: (9,4) is the poster wall, which is + -- where the old single UP step sent him. + local px = ow.player and ow.player.cellX + local py = ow.player and ow.player.cellY + local path + if py == 6 or px == 8 then + path = { { "right", 5 } } + elseif require("src.core.GameVersion").isYellow() then + path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } } + else + path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } } + end + -- GameCornerRocketExitScript only HideObjects him once + -- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step. + -- scriptMove locks player input (#scriptMoves>0) and ignores + -- collision, so the despawn + unfreeze (done) ride the final step. + local function step(i) + if i > #path then + hideRocket() + done() + return + end + ow:scriptMove(npc, path[i][1], path[i][2], + function() step(i + 1) end) + end + step(1) end)) - end) + end, game.data.text._GameCornerRocketBattleEndText or "Dang!") end, -- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a -- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 874a2b9c..110a3cae 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -13,9 +13,18 @@ local function push(game, s, done) game.stack:push(TextBox.new(game, s, done)) end +-- The question stays on screen under the YES/NO menu. The dojo prize +-- balls are the clearest case: FightingDojoHitmonleePokeBallText +-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string -- +-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed +-- by `call YesNoChoice`, and InitYesNoTextBoxParameters +-- (engine/menus/text_box.asm) puts the menu above the dialogue box +-- rather than replacing it. Ride TextBox's opts.choice, the same as +-- Commands.ask, instead of popping the box with an A press and leaving a +-- bare ChoiceBox over the overworld (#854). local function ask(game, s, cb) - local ChoiceBox = require("src.ui.ChoiceBox") - push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end -- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER}) @@ -155,19 +164,35 @@ local function dojoBall(species, ownBall, otherBall, askKey) push(game, "You'll have to\nbeat the master\nfirst!", done) return end - ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes) - if not yes then done() return end - flags["EVENT_GOT_" .. species] = true - flags.EVENT_DEFEATED_FIGHTING_DOJO = true - local Commands = require("src.script.Commands") - local ctx = { save = game.save, game = game, overworld = ow } - Commands.give_pokemon(ctx, species, 30) - -- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides - -- just the picked object's index) and routes to the greedy line above - -- when talked to (#197). - Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall) - push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) - end) + local function offer() + ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes) + if not yes then done() return end + flags["EVENT_GOT_" .. species] = true + flags.EVENT_DEFEATED_FIGHTING_DOJO = true + local Commands = require("src.script.Commands") + local ctx = { save = game.save, game = game, overworld = ow } + Commands.give_pokemon(ctx, species, 30) + -- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides + -- just the picked object's index) and routes to the greedy line above + -- when talked to (#197). + Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall) + push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) + end) + end + -- FightingDojoHitmonleePokeBallText / ...HitmonchanPokeBallText run + -- `ld a, HITMONLEE / call DisplayPokedex` BEFORE .Text and YesNoChoice, + -- so the ball opens the prize's dex page first and the offer follows + -- it. _DisplayPokedex (engine/events/display_pokedex.asm) sets only + -- the SEEN bit, so the page stays the name-and-sprite preview until + -- the mon is owned, the same shape as the Fuchsia exhibit signs in + -- data/scripts/flavor/fuchsia_city.lua (#853). + local dex = game.save.pokedex + if dex then + dex.seen = dex.seen or {} + dex.seen[species] = true + end + require("src.ui.Screens").push(game, "DexEntryMenu", + { species = species, onClose = offer }) end end diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua index e28c1230..21de982e 100644 --- a/data/scripts/story6.lua +++ b/data/scripts/story6.lua @@ -12,9 +12,13 @@ local function push(game, s, done) game.stack:push(TextBox.new(game, s, done)) end +-- PrintText on a text_end string returns with the box still drawn and +-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters, +-- engine/menus/text_box.asm); no A press clears the question first. Ride +-- TextBox's opts.choice, the same as Commands.ask (#854). local function ask(game, s, cb) - local ChoiceBox = require("src.ui.ChoiceBox") - push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end -- ------------------------------------------------------------------- diff --git a/data/scripts/yellow_jessie_james.lua b/data/scripts/yellow_jessie_james.lua index c1e4f226..7379012e 100644 --- a/data/scripts/yellow_jessie_james.lua +++ b/data/scripts/yellow_jessie_james.lua @@ -62,10 +62,15 @@ M.MT_MOON_B2F = { { "walk_npc", 6, { "left", "left", "left", "left", "left" } }, { "face_object", 6, "left" }, { "show_text", "_MtMoonJessieJamesText2" }, + -- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with + -- SaveEndBattleTextPointers before it sets wCurOpponent, so + -- TrainerBattleVictory prints it on the battle screen as "ROCKET: A + -- brat beat us?" between TrainerDefeatedText and MoneyForWinningText. + -- Its one-word first line only reads right behind that tag (#866). + { "save_end_battle_text", "_MtMoonJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 42 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_MtMoonJessieJamesText3" }, { "show_text", "_MtMoonJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, @@ -85,7 +90,8 @@ M.MT_MOON_B2F = { -- motto plays from off-screen FIRST, then the duo pops in at (25,10) / -- (24,10) and whichever of them shares the player's column ($18=24 or -- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three --- tiles down to loom over the player while the other steps one. A loss +-- tiles down to loom over the player while the other walks four and ends +-- up beside him. A loss -- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the -- trigger re-arms clean. -- ------------------------------------------------------------------- @@ -106,7 +112,7 @@ M.ROCKET_HIDEOUT_B4F = { if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end -- ON_LEFT: player under James's column (25); movement data pairs -- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the - -- column-mate walks 3, the other 1. + -- column-mate walks 3, the other 4. local onLeft = (x == 25) ow.runner:run({ { "stop_music" }, @@ -116,16 +122,30 @@ M.ROCKET_HIDEOUT_B4F = { { "emote", "player", "shock", 30 }, { "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" }, { "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" }, - -- James (object 2) then Jessie (object 3), Script4..Script9 order - { "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } }, + -- James (object 2) then Jessie (object 3), Script4..Script9 order. + -- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS + -- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm) + -- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's + -- Func_5288 lookup (engine/overworld/movement.asm), which walks with no + -- collision test. From (25,10)/(24,10) against a player on y=14 the + -- column-mate stops three down, right above him, and the other walks the + -- full four to stand alongside -- which is what the facings below assume. + -- Reading _45605 as a single step stranded whoever was off-column three + -- tiles away, so James never reached the player (#865). + { "walk_npc", 2, onLeft and { "down", "down", "down" } + or { "down", "down", "down", "down" } }, { "face_object", 2, onLeft and "down" or "left" }, - { "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } }, + { "walk_npc", 3, onLeft and { "down", "down", "down", "down" } + or { "down", "down", "down" } }, { "face_object", 3, onLeft and "right" or "down" }, { "show_text", "_RocketHideoutJessieJamesText2" }, + -- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the + -- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on + -- the battle screen ahead of MoneyForWinningText (#866). + { "save_end_battle_text", "_RocketHideoutJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 43 }, { "check_battle_result", "win" }, { "jump_if_false", "lost" }, - { "show_text", "_RocketHideoutJessieJamesText3" }, { "show_text", "_RocketHideoutJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, @@ -175,16 +195,27 @@ M.POKEMON_TOWER_7F = { { "show_text", "_PokemonTowerJessieJamesText1" }, { "face_player_dir", "up" }, { "emote", "player", "shock", 30 }, - -- Jessie (object 1) then James (object 2), Script1..Script6 order - { "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } }, + -- Jessie (object 1) then James (object 2), Script1..Script6 order. + -- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a + -- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR + -- steps and _60d7b is three. From (10,8)/(11,8) against a player on + -- y=12 the column-mate halts one tile above him and the other closes the + -- full four to his side; the single-step reading is why James only + -- "moved a bit" here (#865). + { "walk_npc", 1, onLeft and { "down", "down", "down", "down" } + or { "down", "down", "down" } }, { "face_object", 1, onLeft and "right" or "down" }, - { "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } }, + { "walk_npc", 2, onLeft and { "down", "down", "down" } + or { "down", "down", "down", "down" } }, { "face_object", 2, onLeft and "down" or "left" }, { "show_text", "_PokemonTowerJessieJamesText2" }, + -- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the + -- end-battle text: "ROCKET: You will regret this!" on the battle screen, + -- before the prize money (#866). + { "save_end_battle_text", "_PokemonTowerJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 44 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_PokemonTowerJessieJamesText3" }, { "show_text", "_PokemonTowerJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, @@ -254,10 +285,12 @@ M.SILPH_CO_11F = { { "walk_npc", 6, jessieDirs }, { "face_object", 6, jessieFace }, { "show_text", "_SilphCoJessieJamesText2" }, + -- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2) + -- as the end-battle text: "ROCKET: Like always..." before the money (#866). + { "save_end_battle_text", "_SilphCoJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 45 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_SilphCoJessieJamesText3" }, { "show_text", "_SilphCoJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, diff --git a/docs/new-features.md b/docs/new-features.md index cea7bdb3..77552928 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -332,6 +332,26 @@ one used sideways. An `options.lua` from before this split keeps its single layout in both orientations until one of them is edited. In-game, Options → **TOUCH PAD** toggles the same on/off flag without leaving a play session. +## Haptic feedback (mobile) + +Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device +the instant an on-screen control takes a button (#806). A glass pad has no +edges under a thumb, so the pulse is what tells you the press landed: +sliding the d-pad from one direction to the next buzzes again, a second +finger landing on a button that is already held does not, and releasing +never does. + +Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**. +"Intensity" is really a pulse length -- the platform call takes a duration +and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms. +Stepping the row fires one sample pulse at the level you land on, so the +three can be compared without leaving the menu. On iOS the system +vibration has one fixed length, so all three levels feel the same there and +the row is effectively on/off. The setting lives in `options.lua` and the +row only appears where the on-screen pad can (Android/iOS, or desktop with +`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no +vibrator). + ## Screen orientation lock (Android) Options → **ORIENTATION** (also in the launcher's gear menu) locks the @@ -585,6 +605,11 @@ settings gear and pulses when an update is waiting, instead of sitting in a banner at the bottom of the page that you had to scroll to notice. Checking for updates from there shows a loader like everything else. +**A quit button.** An X sits to the right of the settings gear and closes +the app cleanly, the same shutdown path as the window's close button. Mostly +for platforms where reaching the window chrome is awkward (Android, Steam +Deck, fullscreen desktops). + **The look.** Black background, white outlines, no gradients or glows, and buttons that are solid colour-coded keys: green commits, blue navigates, red destroys, yellow wants attention. The three game tabs keep their red, blue diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c0aaf737..da79b2b7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3188,6 +3188,16 @@ function BattleState:executeAction(user, target, action) user.boundTurns = target.trappingTurns and math.max(1, target.trappingTurns) or nil + -- wPlayerSelectedMove / wEnemySelectedMove as the status gauntlet + -- reads it: the locked specials keep continuing the move they + -- started, so they carry a move id too. Resolved once here and + -- handed to every statusInterrupt below, which is where + -- .TriedToUseDisabledMoveCheck lives (#860). + local selectedId = action.id + or (action.special == "trapping" and user.trapMove) + or (action.special == "bide" and "BIDE") + or nil + -- trainer class AI actions (engine/battle/trainer_ai.asm) if action.special == "aiItem" then self.aiUses = (self.aiUses or 1) - 1 @@ -3245,17 +3255,17 @@ function BattleState:executeAction(user, target, action) return end if action.special == "trapping" then - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, selectedId) then return end self:continueTrapping(user, target) return end if action.special == "bide" then - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, selectedId) then return end self:continueBide(user, target) return end - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, selectedId) then return end self:performMove(user, target, action, false) end run() @@ -3349,8 +3359,8 @@ end -- Runs Status.beforeMove plus the shared interruption bookkeeping; -- returns true when the user's action is interrupted. -function BattleState:statusInterrupt(user, target) - local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) +function BattleState:statusInterrupt(user, target, selectedId) + local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self, selectedId) for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end if selfHit then -- confusion self-hit (core.asm:3428-3434): clears everything in diff --git a/src/battle/Status.lua b/src/battle/Status.lua index a42062ae..90dd8d74 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -168,7 +168,7 @@ end -- The active status record's beforeMove runs at its priority slot: above -- VOLATILE_PRIORITY before the held/disable/confusion block (sleep, -- freeze), at or below after it (paralysis) -- the original's order. -function Status.beforeMove(battler, rng, battle) +function Status.beforeMove(battler, rng, battle, selectedMoveId) local mon = battler.mon -- Haze curing this mon's sleep/freeze forfeits its pending move for -- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected @@ -225,6 +225,28 @@ function Status.beforeMove(battler, rng, battle) end end end + -- .TriedToUseDisabledMoveCheck (engine/battle/core.asm, and the enemy + -- copy .checkIfTriedToUseDisabledMove): the disabled-move test runs at + -- EXECUTION time, comparing wPlayerDisabledMoveNumber against the + -- already SELECTED move, so a Disable that lands earlier in the same + -- turn still blocks the slower mon's move (#860). It sits after the + -- confusion block and before the paralysis roll, so a confusion self-hit + -- still pre-empts it and the paralysis roll is never spent on a turn the + -- disable eats. PrintMoveIsDisabledText clears CHARGING_UP before + -- printing, so a disabled charge move drops its stored turn instead of + -- releasing later. + if selectedMoveId and battler.disabledSlot then + local disabled = (battler.curMoves or {})[battler.disabledSlot] + if disabled and disabled.id == selectedMoveId then + battler.charging, battler.chargeReady = nil, nil + local moves = battle and battle.data and battle.data.moves + local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name + or tostring(selectedMoveId) + table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText", + "%s's\n%s is\ndisabled!", name(battler), shown)) + return false, msgs + end + end if handler then local canMove, selfHit = runStatus() if not canMove or selfHit then return canMove, msgs, selfHit end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index e05ac52b..d9c0569d 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -30,6 +30,15 @@ local SaveData = {} -- deliberately shared across versions (it holds global preferences and the -- mod enable-state, not per-playthrough data). local OPTIONS_FILENAME = "options.lua" +-- #828: options.lua is rewritten whole on every write (see saveOptions), and +-- unlike the progress files it had no staged copy, so a write interrupted +-- between the truncate and the flush -- the process replaced by +-- HostShell.restart on the way back to the launcher, an Android +-- external-storage volume that never flushed -- left a truncated or empty +-- file that loadOptions could only answer with defaults: every setting +-- "reset" at once. Same .bak/.tmp witness names the save files use. +local OPTIONS_BACKUP_FILENAME = OPTIONS_FILENAME .. ".bak" +local OPTIONS_TMP_FILENAME = OPTIONS_FILENAME .. ".tmp" -- Main / backup / staged-witness names for a version (defaults to the active -- one). The backup is a rolling copy and .tmp is the staged-write witness; @@ -305,6 +314,13 @@ function SaveData.defaultOptions() -- layout (#633). Pre-#633 files stored one top-level positions table; -- TouchControls.normalizeConfig folds it into both orientations on load. touchControls = { enabled = true }, + -- Haptic feedback level for on-screen pad presses (#806): + -- off | light | medium | heavy, mapped to a love.system.vibrate + -- duration in src/core/TouchControls.lua. LIGHT by default, like the + -- overlay itself defaulting on, so an options.lua predating this key + -- gets the tick without going looking for the row. Inert wherever the + -- overlay never appears (desktop) or LOVE has no vibrator. + haptics = "light", } end @@ -369,7 +385,21 @@ function SaveData.saveOptions(opts, fs) opts.modOptions = merged end local encoded = SaveSerializer.encode(opts) - local ok, err = fs.write(OPTIONS_FILENAME, encoded) + -- Stage the new bytes and roll the last good file aside BEFORE the main + -- write truncates it, the same tmp/bak dance SaveData.save uses for + -- progress: whatever ends the process mid-write, one of the three copies + -- is complete and loadOptions promotes it instead of falling back to + -- defaults (#828). + local ok, err = fs.write(OPTIONS_TMP_FILENAME, encoded) + if not ok then + Logger.error("options save failed: %s", tostring(err)) + return nil + end + local prev = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME) + if type(prev) == "string" and prev ~= "" and prev ~= encoded then + fs.write(OPTIONS_BACKUP_FILENAME, prev) + end + ok, err = fs.write(OPTIONS_FILENAME, encoded) if not ok then Logger.error("options save failed: %s", tostring(err)) return nil @@ -387,6 +417,8 @@ function SaveData.saveOptions(opts, fs) #encoded, type(wrote) == "string" and tostring(#wrote) or "nothing") return nil end + -- the staged witness has served its purpose; the main file is verified + remove(fs, OPTIONS_TMP_FILENAME) return opts end @@ -397,6 +429,26 @@ function SaveData.loadOptions(fs) if fs.getInfo(OPTIONS_FILENAME) then Logger.error("options load failed: %s", tostring(err)) end + -- #828: answering defaults here is what "closing the game reset all my + -- settings" looked like -- one interrupted whole-file rewrite and every + -- preference, the mod enable-state and the slot registry were gone. + -- Promote the staged copy, then the rolled-aside backup, exactly as + -- SaveData.load does for progress, and heal the main file from whichever + -- one parsed. + local recovered = readTable(fs, OPTIONS_TMP_FILENAME) + local from = "tmp" + if not recovered then + recovered = readTable(fs, OPTIONS_BACKUP_FILENAME) + from = "bak" + end + if recovered then + Logger.warn("options.lua %s; recovered from %s copy", + fs.getInfo(OPTIONS_FILENAME) and "corrupt" or "missing", from) + if fs.write then + fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered)) + end + return SaveData.mergeOptions(recovered) + end return SaveData.defaultOptions() end return SaveData.mergeOptions(data) diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index efdfee22..a2e3b22b 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -85,6 +85,55 @@ local function clampScale(v) return v end +-- Haptic feedback (#806): a short vibration the instant a control takes a GB +-- button, the way every mobile emulator front-end does it -- the pad has no +-- edges under a thumb, so the buzz is the only confirmation a press landed. +-- Persisted as options.haptics (src/core/SaveData.lua defaultOptions), NOT +-- under options.touchControls: TouchControls:config() is the launcher +-- editor's save snapshot and only emits enabled + layouts, so a nested key +-- would be dropped on every editor save. +-- love.system.vibrate takes a duration and nothing else, so "intensity" is a +-- duration preset: Android runs the platform vibrator for exactly that long, +-- while iOS ignores the duration and fires the fixed system vibration, so +-- there the three levels all read as simply on. +TouchControls.HAPTICS = { "off", "light", "medium", "heavy" } +TouchControls.HAPTIC_DEFAULT = "light" + +local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 } +local HAPTIC_LABELS = { + off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY", +} + +function TouchControls.normalizeHaptics(level) + if HAPTIC_SECONDS[level] then return level end + return TouchControls.HAPTIC_DEFAULT +end + +function TouchControls.hapticLabel(level) + return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)] +end + +function TouchControls.cycleHaptics(level, dir) + local cur, idx = TouchControls.normalizeHaptics(level), 1 + for i, m in ipairs(TouchControls.HAPTICS) do + if m == cur then idx = i break end + end + local n = #TouchControls.HAPTICS + return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1] +end + +-- One pulse at the given level. Feature-guarded rather than platform-gated: +-- love.system.vibrate is a no-op on desktop and absent from the headless love +-- stubs, so the press path below stays identical everywhere and the tests +-- never reach a vibrator. +function TouchControls.buzz(level) + local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)] + if not secs or secs <= 0 then return false end + if not (love and love.system and love.system.vibrate) then return false end + pcall(love.system.vibrate, secs) + return true +end + -- Copy a persisted positions table, dropping unknown / non-numeric entries. -- Always a fresh table: two orientations seeded from the same pre-#633 -- layout must not alias, or dragging one would still move the other. @@ -174,6 +223,10 @@ end function TouchControls:init() self.active = wantsOverlay() self.enabled = true + -- vibration level for presses (#806); applyOptions overwrites it from + -- options.haptics, this is the value a harness that never applies options + -- runs with + self.haptics = TouchControls.HAPTIC_DEFAULT -- per-orientation buckets (#633); self.positions / self.scale mirror the -- one currently on screen so layout(), the editor and the tests keep a -- single lookup @@ -210,6 +263,9 @@ end function TouchControls:applyOptions(opts) local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) self.enabled = cfg.enabled + -- haptics is a plain top-level option, not part of the layout config the + -- launcher editor round-trips through config() (#806) + self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) self.layouts = cfg.layouts self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil @@ -401,7 +457,14 @@ end local function pressBtn(self, btn) local n = (self.held[btn] or 0) + 1 self.held[btn] = n - if n == 1 then Input:overlayPressed(btn) end + -- Buzz only on the 0 -> 1 edge, the same edge that presses the GB button: + -- a second finger landing on a button that is already held, and a d-pad + -- finger resting inside one direction, must not retrigger it. Sliding the + -- d-pad to a new direction does, which is the point (#806). + if n == 1 then + Input:overlayPressed(btn) + TouchControls.buzz(self.haptics) + end end local function releaseBtn(self, btn) diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 6de23c77..45292922 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -242,6 +242,18 @@ local function coreRows(opts) opts.touchControls = tc return true end) + -- VIBRATION sits with it (#806): same gate, same subsystem. Stepping + -- the row buzzes once at the level being selected. + local okTC, TC = pcall(require, "src.core.TouchControls") + if okTC then + add(Strings("VIBRATION"), + function() return Strings(TC.hapticLabel(opts.haptics)) end, + function(dir) + opts.haptics = TC.cycleHaptics(opts.haptics, dir) + TC.buzz(opts.haptics) + return true + end) + end end end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 7be70f26..0ae0d5b0 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -311,8 +311,20 @@ local function setPage(imp, key, v) imp._pages[key] = v end +-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has +-- no guaranteed glyph, and the launcher ships no icon asset for it. +local function drawCross(x, y, size, color) + love.graphics.push("all") + love.graphics.setColor(color) + love.graphics.setLineWidth(math.max(2, size * 0.16)) + love.graphics.setLineJoin("bevel") + love.graphics.line(x, y, x + size, y + size) + love.graphics.line(x + size, y, x, y + size) + love.graphics.pop() +end + -- ------------------------------------------------------------- header --- Rail, logo row (settings on the right), tab bar. +-- Rail, logo row (settings and quit on the right), tab bar. -- Returns the y at which content may start. Its vertical arithmetic is -- mirrored by headerHeight() at the bottom of this file (the short-window -- scroll decision needs the height before anything draws) -- keep in sync. @@ -340,7 +352,14 @@ local function buildHeader(imp, m) local rx = m.x + m.w - m.pad local by = y + (rowH - gear) / 2 - -- Settings gear, top-right corner. + -- The right cluster is laid out right to left -- Quit outermost, the gear + -- inboard of it -- but the two are REGISTERED gear first, because the first + -- focusable of the first frame adopts the keyboard ring and that must not be + -- the button that exits the app. + local quitX = rx - gear + rx = quitX - math.floor(6 * m.s) + + -- Settings gear. imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") rx = rx - gear @@ -365,6 +384,23 @@ local function buildHeader(imp, m) end end + -- Quit, top-right corner. + do + local x = quitX + Kit._audit("control", x, by, gear, gear, "quit") + local focused = Kit.focusable("quit", x, by, gear, gear) + local hot = focused or Kit.hover(x, by, gear, gear) + Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1) + Theme.stroke(x, by, gear, gear, PAL.line, + hot and Theme.A.focus or Theme.A.hairline, 1) + local pad = math.floor(gear * 0.32) + drawCross(x + pad, by + pad, gear - 2 * pad, + hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) + if Kit.press(x, by, gear, gear) or Kit._activateId == "quit" then + queueAction(imp, "quit", function() imp:_quitApp() end) + end + end + -- The self-update control lives in the FOOTER next to the BCG mark (small, -- out of the wordmark's way -- it used to overlap the logo on a phone). It -- still GLOWS through Kit.button when there is something to act on. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 20b2071e..eb46ad29 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2459,6 +2459,13 @@ function RomImporter:_openSettings() if ok and model then self._settings = model end end +-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's +-- love.quit hook still runs: that is where the worker threads are shut down +-- (#339) and where a launcher close is told apart from a running game's (#785). +function RomImporter:_quitApp() + if love.event and love.event.quit then love.event.quit() end +end + function RomImporter:_closeSettings() if self._settings then self._settings.save() end self._settings = nil diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 20792244..9148583e 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -260,6 +260,26 @@ function Commands.take_item(ctx, itemId, count) if inv[itemId] == 0 then inv[itemId] = nil end end +-- save_end_battle_text : SaveEndBattleTextPointers +-- (home/trainers.asm, called from e.g. RocketHideoutB4FScript10 just before +-- wCurOpponent is set). The armed line is the trainer's OWN loss line and +-- belongs on the battle screen: PrintEndBattleText (home/trainers.asm) runs +-- from TrainerBattleVictory (engine/battle/core.asm) after +-- TrainerDefeatedText and the pic scroll but BEFORE MoneyForWinningText, and +-- TrainerEndBattleText prints _TrainerNameText first so the line opens with +-- the "CLASS: " tag. Scripts that printed it with a plain show_text after +-- start_battle got it a box too late -- after the payout -- and untagged +-- (#866). Arms exactly one battle; start_battle consumes it. +function Commands.save_end_battle_text(ctx, textId) + local text = ctx.game.data.text[textId] + if not text and ctx.overworld then + text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId) + end + -- BattleState takes finished text, so expand {PLAYER}/{RIVAL} here the + -- way OverworldState:engageTrainer does for the sight/talk path + ctx.endBattleText = TextBox.substitute(ctx.game, text or textId) +end + -- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex function Commands.start_battle(ctx, kind, a, b) local BattleState = require("src.battle.BattleState") @@ -270,6 +290,9 @@ function Commands.start_battle(ctx, kind, a, b) else battle = BattleState.newTrainer(ctx.game, a, b) end + -- one SaveEndBattleTextPointers arms one battle; leaving it set would leak + -- the line into the next scripted fight + battle.endBattleText, ctx.endBattleText = ctx.endBattleText, nil battle.onFinish = function(result) ctx.lastBattleResult = result ctx.lastCheck = result == "win" diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 11e0d808..152ae7ce 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -2,10 +2,13 @@ -- dex description (data/pokemon/dex_entries.asm + dex_text.asm). -- -- `species` may be a species id string, or a table --- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's --- StarterDex (engine/events/starter_dex.asm), which temporarily sets the --- owned bit so Oak's lab ball previews show height/weight/description --- without permanently marking the mon owned. +-- `{ species = id, forceOwned = true, onClose = fn }`. forceOwned mirrors +-- pret's StarterDex (engine/events/starter_dex.asm), which temporarily sets +-- the owned bit so Oak's lab ball previews show height/weight/description +-- without permanently marking the mon owned. onClose fires once the page +-- is dismissed, for callers that push this screen from a plain callback +-- instead of a script runner (the push_screen command yields on the runner +-- instead, src/script/Commands.lua). local Font = require("src.render.Font") local Strings = require("src.core.Strings") @@ -26,14 +29,16 @@ end local function resolveArgs(speciesOrOpts) if type(speciesOrOpts) == "table" then return speciesOrOpts.species or speciesOrOpts[1], - speciesOrOpts.forceOwned and true or false + speciesOrOpts.forceOwned and true or false, + speciesOrOpts.onClose end - return speciesOrOpts, false + return speciesOrOpts, false, nil end function DexEntryMenu.new(game, speciesOrOpts) - local species, forceOwned = resolveArgs(speciesOrOpts) - local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) + local species, forceOwned, onClose = resolveArgs(speciesOrOpts) + local self = setmetatable({ game = game, forceOwned = forceOwned, + onClose = onClose }, DexEntryMenu) self.def = game.data.pokemon[species] local path, trueColor = require("src.pokemon.Sprites").path( game.data, species, "front", { kind = "dex" }) @@ -52,6 +57,11 @@ function DexEntryMenu:update(dt) local input = self.game.input if input:wasPressed("a") or input:wasPressed("b") then self.game.stack:pop() + -- onClose resumes a callback-style caller after the page closes: the + -- dojo prize balls print their offer only once DisplayPokedex returns + -- (data/scripts/story4.lua, #853). Script rows do not need it, they + -- yield on push_screen's waitingCheck instead. + if self.onClose then self.onClose() end end end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 56379f7f..1187dc6c 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -437,6 +437,25 @@ local function buildRows(game) require("src.core.TouchControls"):applyOptions(o) return true end }, + -- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT / + -- MEDIUM / HEAVY, where the intensity is a vibration duration -- + -- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below, + -- since the only thing that buzzes is a virtual button press. + { id = "haptics", label = Strings("VIBRATION"), + value = function(g) + local TC = require("src.core.TouchControls") + return Strings(TC.hapticLabel(g.save.options.haptics)) + end, + step = function(g, dir) + local o = g.save.options + local TC = require("src.core.TouchControls") + o.haptics = TC.cycleHaptics(o.haptics, dir) + TC:applyOptions(o) + -- sample the level being selected: stepping the row is the only way + -- to compare LIGHT against HEAVY without leaving the menu + TC.buzz(o.haptics) + return true + end }, } -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks if not GBCFX.isSupported() then @@ -454,8 +473,10 @@ local function buildRows(game) end rows = filtered end - -- TOUCH PAD only where the overlay can appear (mobile, or desktop with - -- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere. + -- TOUCH PAD and VIBRATION only where the overlay can appear (mobile, or + -- desktop with POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off + -- everywhere. VIBRATION rides the same gate: nothing else in the port + -- vibrates, and love.system.vibrate is a no-op on desktop anyway. do local env = os.getenv("POKEPORT_TOUCH") local osName = love.system and love.system.getOS and love.system.getOS() @@ -464,7 +485,9 @@ local function buildRows(game) if not show then local filtered = {} for _, row in ipairs(rows) do - if row.id ~= "touchControls" then filtered[#filtered + 1] = row end + if row.id ~= "touchControls" and row.id ~= "haptics" then + filtered[#filtered + 1] = row + end end rows = filtered end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index ebca790e..ff56ae22 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1259,8 +1259,14 @@ end function OverworldState:checkBoulderPush(dir) local p = self.player local fx, fy = Collision.target(p.cellX, p.cellY, dir) - local npc = self:npcAtCell(fx, fy) - if not npc or not Map.isPushable(npc.def) or npc.moving then + -- IsSpriteInFrontOfPlayer (home/overworld.asm) hands TryPushingBoulder + -- the LOWEST sprite index standing on the faced cell, so in the original a + -- second sprite parked on the boulder's cell hides the boulder from the + -- push path for the rest of the map visit. Pick the pushable sprite out of + -- the cell instead: a scripted walk-up that lands a trainer on the boulder + -- must not brick it permanently (#809). + local npc = self:pushableAtCell(fx, fy) + if not npc or npc.moving then self.boulderTried = nil -- pokered resets when no boulder is in front return false end @@ -1720,6 +1726,22 @@ function OverworldState:npcAtCell(cx, cy) return nil end +-- The Strength boulder on a cell, ignoring anything else standing there. +-- npcAtCell returns whichever object the map listed first, which is only +-- well defined while at most one sprite occupies a cell; scripted walks +-- (TrainerWalkUpToPlayer) can break that, and the push path must still find +-- the boulder underneath (#809). +function OverworldState:pushableAtCell(cx, cy) + for _, npc in ipairs(self.npcs) do + if ((npc.cellX == cx and npc.cellY == cy) or + (npc.targetX == cx and npc.targetY == cy)) + and Map.isPushable(npc.def) then + return npc + end + end + return nil +end + -- what the A press resolved to, for world.interacted's listeners local function interacted(self, fx, fy, kind, target) Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy, @@ -2962,7 +2984,7 @@ local function meetTrainerTheme(cls) end -- Run the pre-battle text -> battle -> won text -> flags sequence. -function OverworldState:engageTrainer(npc, onDone) +function OverworldState:engageTrainer(npc, onDone, endBattleText) local d = npc.def Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, partyIndex = d.trainerParty }) @@ -2972,7 +2994,15 @@ function OverworldState:engageTrainer(npc, onDone) battleText = select(1, Game.data:resolveText(self.map.def.label, d.text)) or Strings("I like shorts!\nThey're comfy and\neasy to wear!") end - local wonText = header and header.won and Game.data.text[header.won] + -- `endBattleText` is a caller-supplied stand-in for header.won: the + -- text_asm trainers that hand their loss line to the battle through + -- SaveEndBattleTextPointers (scripts/GameCorner.asm GameCornerRocketText + -- passes _GameCornerRocketBattleEndText, "Dang!") have no def_trainers + -- header for the extractor to read, so their script passes the finished + -- line here and it still lands where PrintEndBattleText puts it -- between + -- TrainerDefeatedText and MoneyForWinningText, on the battle screen (#862). + local wonText = endBattleText + or (header and header.won and Game.data.text[header.won]) local BattleState = require("src.battle.BattleState") Game.stack:push(TextBox.new(Game, battleText, function() @@ -3241,8 +3271,26 @@ function OverworldState:startTrainerApproach(npc, dist) self.emote = { npc = npc, frames = 60, onDone = function() - if dist > 1 then - self:scriptMove(npc, npc.facing, dist - 1, fight) + -- TrainerWalkUpToPlayer (engine/overworld/trainer_sight.asm) writes + -- dist-1 NPC_MOVEMENT_* bytes and hands them to MoveSprite, and every + -- scripted step skips collision entirely (CanWalkOntoTile, + -- engine/overworld/movement.asm: "always allow walking if the + -- movement is scripted"), so the original marches the trainer straight + -- through a Strength boulder sitting on the sight line. Stop one cell + -- short of the boulder instead: two sprites on one cell is a state the + -- push path cannot represent, and the walk-up is the one scripted move + -- the player can steer a boulder into (#809). + local steps = dist - 1 + local cx, cy = npc.cellX, npc.cellY + for i = 1, steps do + cx, cy = Collision.target(cx, cy, npc.facing) + if self:pushableAtCell(cx, cy) then + steps = i - 1 + break + end + end + if steps > 0 then + self:scriptMove(npc, npc.facing, steps, fight) else fight() end diff --git a/tests/drivers/boulder_trainer_bug809_test.lua b/tests/drivers/boulder_trainer_bug809_test.lua new file mode 100644 index 00000000..52229aee --- /dev/null +++ b/tests/drivers/boulder_trainer_bug809_test.lua @@ -0,0 +1,222 @@ +-- A trainer's walk-up must stop short of a Strength boulder, and the boulder +-- must still be pushable afterwards (#809). TrainerWalkUpToPlayer (pokered +-- engine/overworld/trainer_sight.asm) writes dist-1 movement bytes that skip +-- collision, so the trainer used to park ON the boulder, and after that +-- IsSpriteInFrontOfPlayer (home/overworld.asm) handed TryPushingBoulder the +-- trainer instead of the rock. POKEPORT_DRIVER=tests/drivers/boulder_trainer_bug809_test.lua POKEPORT_IDENTITY=bug809 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . + +-- No POKEPORT_SPEED: the sighting, the "!" bubble and the walk-up all run at +-- the normal 60 Hz logic clock so the stop-short frame is the one a player +-- would see. The setup pushes are slow for the same reason; the run takes +-- about half a minute of real time before it hands the pad over. +return function(game) + local U = dofile("tests/drivers/util.lua") + + -- pokered data/maps/objects/VictoryRoad3F.asm: + -- object_event 13, 3, SPRITE_COOLTRAINER_F, STAY, RIGHT, ..., OPP_COOLTRAINER_F, 3 + -- object_event 22, 3, SPRITE_BOULDER, STAY, BOULDER_MOVEMENT_BYTE_2, ... + -- Her header range is 4 (data/generated/trainer_headers.lua VictoryRoad3F[4]), + -- so she spots the player anywhere on row 3 within four cells to her east and + -- then walks dist-1 cells toward him. Row 3 is walled at x=19, so BOULDER1 + -- cannot simply be shoved west into her sight line: it has to go down column + -- 22 to row 6, west along row 6, and back up column 17 onto row 3. + local MAP = "VICTORY_ROAD_3F" + local MAP_LABEL = "VictoryRoad3F" + local BOULDER = "VICTORYROAD3F_BOULDER1" + local TRAINER = "VICTORYROAD3F_COOLTRAINER_F2" + local START = { x = 22, y = 2, facing = "down" } + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local pass = true + local function check(label, ok) + if not ok then pass = false end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function findNpc(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- Hold `btn` until `cond` goes true or the budget runs out, then release and + -- let any half-finished step land. Boulder pushes need a held direction: + -- handleInput only reaches checkBoulderPush while the player already faces + -- that way, and TryPushingBoulder arms on one poll and moves on the next + -- (BIT_TRIED_PUSH_BOULDER), so a single tap can never shift a rock. + local function holdUntil(btn, cond, budget) + local first = true + for _ = 1, budget or 600 do + if cond() then break end + if first then table.insert(game.input.pressQueue, btn); first = false end + game.input.state[btn] = true + coroutine.yield() + end + game.input.state[btn] = false + for _ = 1, 40 do + if not game.overworld.player.moving and #game.overworld.scriptMoves == 0 then + break + end + coroutine.yield() + end + U.wait(4) -- an input-free poll re-arms turning in place (wCheckFor180DegreeTurn) + return cond() + end + + U.teleport(game, MAP, START.x, START.y, START.facing) + U.wait(10) + local ow = game.overworld + local rock = findNpc(ow, BOULDER) + local trainer = findNpc(ow, TRAINER) + + check("BOULDER1 loaded on " .. MAP, rock ~= nil) + check("COOLTRAINER_F2 loaded on " .. MAP, trainer ~= nil) + if not (rock and trainer) then + U.log("map objects missing; nothing to drive") + while true do coroutine.yield() end + end + check("BOULDER1 starts at the asm cell (22,3)", + rock.cellX == 22 and rock.cellY == 3) + check("COOLTRAINER_F2 starts at the asm cell (13,3) facing right", + trainer.cellX == 13 and trainer.cellY == 3 and trainer.facing == "right") + check("checkBoulderPush resolves through pushableAtCell", + type(ow.pushableAtCell) == "function") + + local header = game.data:trainerHeader(MAP_LABEL, trainer.def.index) + local range = header and header.range or 0 + check("her sight range is 4 cells", range == 4) + + -- The whole route, so a map or tileset edit shows up here instead of as a + -- driver that quietly wanders off. If a cell is not walkable the boulder + -- cannot be pushed onto it (CheckForCollisionWhenPushingBoulder reuses the + -- player's passability check) and the run is not worth continuing. + local ROUTE = { + { 22, 4 }, { 22, 5 }, { 22, 6 }, { 23, 5 }, { 23, 6 }, + { 21, 6 }, { 20, 6 }, { 19, 6 }, { 18, 6 }, { 17, 6 }, + { 18, 7 }, { 17, 7 }, { 17, 5 }, { 17, 4 }, { 17, 3 }, + { 18, 4 }, { 18, 3 }, { 16, 3 }, { 16, 4 }, { 16, 2 }, + } + local routeOk = true + for _, c in ipairs(ROUTE) do + if not ow.map:isWalkableCell(c[1], c[2]) then + routeOk = false + U.log("route cell not walkable:", c[1], c[2]) + end + end + check("the push route is walkable end to end", routeOk) + + -- STRENGTH is live for the map visit. BIT_STRENGTH_ACTIVE is what + -- TryPushingBoulder gates on -- it never re-reads badges or party moves -- + -- so setting the field-move state is the whole grant (see the comment in + -- OverworldState:checkBoulderPush). + ow.strengthActive = true + + -- Victory Road rolls a wild encounter on every completed step, not just in + -- grass (wild_encounters.asm counts caves as indoor), and this run walks + -- twenty-odd cells with an empty party. Drop the map's table: a wild + -- battle mid-route interrupts the push with a screen transition and has + -- nothing to do with what is being checked. + game.data.encounters[MAP] = nil + + if not pass then + U.log("setup checks already failed; not driving the push") + while true do coroutine.yield() end + end + + local function boulderAt(x, y) + return function() return rock.cellX == x and rock.cellY == y end + end + local function playerAt(x, y) + local p = ow.player + return function() return p.cellX == x and p.cellY == y end + end + + -- down column 22 to row 6 + holdUntil("down", boulderAt(22, 6), 400) + check("boulder pushed down column 22 to (22,6)", rock.cellX == 22 and rock.cellY == 6) + -- around to its east side + holdUntil("right", playerAt(23, 5), 120) + holdUntil("down", playerAt(23, 6), 120) + -- west along row 6 to the column that reaches row 3 + holdUntil("left", boulderAt(17, 6), 700) + check("boulder pushed west along row 6 to (17,6)", rock.cellX == 17 and rock.cellY == 6) + -- around to its south side + holdUntil("down", playerAt(18, 7), 120) + holdUntil("left", playerAt(17, 7), 120) + -- up column 17 onto her row + holdUntil("up", boulderAt(17, 3), 400) + check("boulder pushed up column 17 onto row 3 at (17,3)", + rock.cellX == 17 and rock.cellY == 3) + if not pass then + U.log("the boulder never reached her row; the race below cannot happen") + while true do coroutine.yield() end + end + + -- Step onto row 3 one cell out of range (18 - 13 = 5 > 4) so the sighting + -- happens on the push itself and not a moment earlier. + holdUntil("right", playerAt(18, 4), 120) + holdUntil("up", playerAt(18, 3), 120) + check("player waiting at (18,3), one cell outside her range", + ow.player.cellX == 18 and ow.player.cellY == 3 and not ow.engaging) + + -- The engage lands on a battle we are not going to fight: stand in for it, + -- record where the walk-up stopped, and mark her beaten the way winning + -- would. Everything the walk-up does has already happened by this point. + local stopped + local realEngage = ow.engageTrainer + ow.engageTrainer = function(self, npc, onDone) + stopped = { npc = npc, x = npc.cellX, y = npc.cellY } + game.save.defeatedTrainers[npc.id] = true + if onDone then onDone() end + end + + -- One push west: the boulder lands on (16,3) and the player follows onto + -- (17,3), four cells from her, which is the frame she spots him on. + holdUntil("left", function() return stopped ~= nil end, 400) + + check("she spotted the player and finished her walk-up", stopped ~= nil) + check("the boulder moved one cell west to (16,3)", + rock.cellX == 16 and rock.cellY == 3) + if stopped then + U.log("she stopped at", stopped.x, stopped.y, "boulder at", rock.cellX, rock.cellY) + check("she is not standing on the boulder cell", + not (stopped.x == rock.cellX and stopped.y == rock.cellY)) + check("she stopped one cell short of it, at (15,3)", + stopped.x == 15 and stopped.y == 3) + check("the push path still finds the boulder under that cell", + ow:pushableAtCell(rock.cellX, rock.cellY) == rock) + check("nothing else shares the boulder's cell", + ow:npcAtCell(rock.cellX, rock.cellY) == rock) + end + U.shot(game, SHOT_DIR .. "/bug809_walkup_stop.png") + + -- ...and the rock still moves. Push it north, the one free direction left: + -- west is her, east is the player, south is where he came from. + holdUntil("down", playerAt(17, 4), 120) + holdUntil("left", playerAt(16, 4), 120) + holdUntil("up", boulderAt(16, 2), 400) + if not check("the boulder is still pushable after the engage", + rock.cellX == 16 and rock.cellY == 2) then + U.log("boulder ended at", rock.cellX, rock.cellY, "player at", + ow.player.cellX, ow.player.cellY) + end + holdUntil("down", playerAt(16, 4), 120) + U.shot(game, SHOT_DIR .. "/bug809_still_pushable.png") + + ow.engageTrainer = realEngage + U.log(pass and "ALL CHECKS PASSED" or "SOME CHECKS FAILED") + + U.log("On screen: the COOLTRAINER stands at (15,3) with a one-cell gap") + U.log("between her and the rock, which now sits at (16,2), one row up from") + U.log("where she stopped. The near miss to watch for is her sprite ending") + U.log("the walk-up on top of the rock, or standing clear of it but leaving") + U.log("it inert: walk into the rock from any side and it should still shift") + U.log("a cell. Her battle was stubbed out and she is flagged as beaten;") + U.log("re-run the driver to watch the race again from the start.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/dojo_balls_bug853_test.lua b/tests/drivers/dojo_balls_bug853_test.lua new file mode 100644 index 00000000..211e1256 --- /dev/null +++ b/tests/drivers/dojo_balls_bug853_test.lua @@ -0,0 +1,209 @@ +-- Driver: Fighting Dojo prize balls, #853 (dex page first) and #854 (the +-- question stays on screen under YES/NO). pokered scripts/FightingDojo.asm +-- runs `ld a, HITMONLEE / call DisplayPokedex` before .Text, and .Text is a +-- text_end string printed with PrintText immediately followed by YesNoChoice. +-- No POKEPORT_SPEED here: the dex page and the YES/NO pop are what is judged. +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/dojo_balls_bug853_test.lua POKEPORT_IDENTITY=bug853 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local DexEntryMenu = require("src.ui.DexEntryMenu") + local MapScripts = require("src.script.MapScripts") + local Screens = require("src.ui.Screens") + local OW = require("src.world.OverworldController") + local Pokemon = require("src.pokemon.Pokemon") + + -- pokered data/maps/objects/FightingDojo.asm: the two SPRITE_POKE_BALL + -- objects sit at (4, 1) HITMONLEE and (5, 1) HITMONCHAN, on the north wall + -- under the posters. The only approach is from the mat below them. + local MAP = "FIGHTING_DOJO" + local LEE = { name = "FIGHTINGDOJO_HITMONLEE_POKE_BALL", x = 4, y = 1 } + local CHAN = { name = "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", x = 5, y = 1 } + local START = { x = 4, y = 4 } -- walk up from here to (4, 2), facing LEE + + local failures = {} + local function check(cond, msg) + if cond then U.log("PASS", msg) else + failures[#failures + 1] = msg + U.log("FAIL", msg) + end + return cond + end + + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + local function under() + return game.stack.states[#game.stack.states - 1] + end + + local function npcByName(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + end + + local function pageText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local page = top.pages and top.pages[top.pageIndex] + return page and table.concat(page, "\n") or "" + end + + local function waitFor(cond, cap) + for _ = 1, (cap or 200) do + if cond() then return true end + U.wait(2) + end + return cond() + end + + local function mashUntil(cond, cap) + for _ = 1, (cap or 100) do + if cond() then return true end + U.tap(game, "a") + U.wait(2) + end + return cond() + end + + -- fresh dojo with the master already beaten and neither prize taken + local function seed(x, y, facing) + while game.stack:top() do game.stack:pop() end + game.save.flags = { + EVENT_BEAT_KARATE_MASTER = true, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = true, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = true, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = true, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = true, + } + game.save.defeatedTrainers = { FIGHTING_DOJO_obj_1 = true } + game.save.objectToggles = {} + game.save.player.name = game.save.player.name or "RED" + -- one mon so give_pokemon has a party to append to, and room for a prize + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.stack:push(OW, MAP, x, y, facing or "up") + U.wait(10) + return game.stack:top() + end + + local ow = seed(START.x, START.y, "up") + + ------------------------------------------------------------------ + -- machine-checkable half: seed, objects, script rows, text, screen id + ------------------------------------------------------------------ + check(game.save.flags.EVENT_BEAT_KARATE_MASTER == true, + "EVENT_BEAT_KARATE_MASTER is set (the balls answer at all)") + check(not game.save.flags.EVENT_GOT_HITMONLEE + and not game.save.flags.EVENT_GOT_HITMONCHAN, + "neither prize taken yet (no 'greedy' refusal path)") + + local leeBall, chanBall = npcByName(ow, LEE.name), npcByName(ow, CHAN.name) + check(leeBall ~= nil, "HITMONLEE ball object loaded") + check(chanBall ~= nil, "HITMONCHAN ball object loaded") + check(leeBall and leeBall.cellX == LEE.x and leeBall.cellY == LEE.y, + ("HITMONLEE ball sits at the asm cell (%d, %d)"):format(LEE.x, LEE.y)) + check(chanBall and chanBall.cellX == CHAN.x and chanBall.cellY == CHAN.y, + ("HITMONCHAN ball sits at the asm cell (%d, %d)"):format(CHAN.x, CHAN.y)) + + check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL")) + == "function", + "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL has a hand-ported talk script") + check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL")) + == "function", + "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL has a hand-ported talk script") + + -- the ask() string is the extracted descriptor, not the "You want X?" stub + local leeText = game.data.text._FightingDojoHitmonleePokeBallText + local chanText = game.data.text._FightingDojoHitmonchanPokeBallText + check(type(leeText) == "string" and leeText ~= "", + "_FightingDojoHitmonleePokeBallText resolves") + check(type(chanText) == "string" and chanText ~= "", + "_FightingDojoHitmonchanPokeBallText resolves") + if type(leeText) == "string" then + U.log("lee prompt reads:", (leeText:gsub("\n", " / "))) + end + local dexOk = pcall(Screens.get, game, "DexEntryMenu") + check(dexOk, "DexEntryMenu resolves through the Screens registry") + + ------------------------------------------------------------------ + -- rehearsal on the HITMONCHAN ball, answered NO so nothing is consumed + ------------------------------------------------------------------ + if chanBall then + ow:talkTo(chanBall) + check(waitFor(function() return topIs(DexEntryMenu) end, 60), + "#853: the ball opens the HITMONCHAN dex page before any question") + U.shot(game, DIR .. "/dojo_balls_1_dex.png") + U.tap(game, "b") + check(waitFor(function() return topIs(TextBox) end, 60), + "#853: closing the dex page leads into the offer text") + mashUntil(function() return topIs(ChoiceBox) end, 60) + check(topIs(ChoiceBox), "#854: the YES/NO menu opens on the offer") + check(getmetatable(under()) == TextBox, + "#854: the question box is still on the stack under the YES/NO menu") + U.shot(game, DIR .. "/dojo_balls_2_choice.png") + U.tap(game, "b") -- B answers NO; the prize stays unclaimed + waitFor(function() return game.stack:top() == ow end, 120) + check(not game.save.flags.EVENT_GOT_HITMONCHAN, + "answering NO leaves the HITMONCHAN prize unclaimed") + check(#game.save.party == 1, "answering NO adds nothing to the party") + end + + ------------------------------------------------------------------ + -- hand-off: walk to the HITMONLEE ball and open it for real + ------------------------------------------------------------------ + ow = seed(START.x, START.y, "up") + for _ = 1, 12 do + if ow.player.cellY <= LEE.y + 1 then break end + U.hold(game, "up", 16) + U.wait(4) + end + + local function facingTheBall() + local cur = game.overworld + local ball = cur and npcByName(cur, LEE.name) + if not ball then return false end + local fx, fy = cur.player:facingCell() + return cur:npcAtCell(fx, fy) == ball + end + + if not facingTheBall() then + -- a map edit or a mod moved the ball: stand on any free walkable + -- neighbour instead. {dx, dy, facing} is the offset from the ball to + -- the stand cell plus the direction that looks back at it. + local sides = { + { 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" }, + } + for _, s in ipairs(sides) do + local cx, cy = LEE.x + s[1], LEE.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("walk up stopped short; standing on", cx, cy, "facing", s[3]) + ow = seed(cx, cy, s[3]) + break + end + end + end + check(facingTheBall(), "player is standing against the HITMONLEE ball") + + U.tap(game, "a") + check(waitFor(function() return topIs(DexEntryMenu) end, 60), + "#853: pressing A opens the HITMONLEE dex page") + U.shot(game, DIR .. "/dojo_balls_3_handoff.png") + + if #failures == 0 then + U.log("all checks passed") + else + U.log(("%d check(s) failed:"):format(#failures), table.concat(failures, "; ")) + end + + U.log("On screen now: the HITMONLEE dex page the ball opened, name and") + U.log("sprite only, since the mon is seen but not owned yet. Press B: the") + U.log("offer types out, and the YES/NO menu should appear above it with the") + U.log("question still readable -- the old bug swapped the text away for a") + U.log("bare YES/NO over the overworld. Answer YES to take HITMONLEE; the") + U.log("HITMONCHAN ball beside it stays put and gives the greedy refusal.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/fighting_dojo_bug197_test.lua b/tests/drivers/fighting_dojo_bug197_test.lua index ce529e1d..8d59f849 100644 --- a/tests/drivers/fighting_dojo_bug197_test.lua +++ b/tests/drivers/fighting_dojo_bug197_test.lua @@ -3,7 +3,9 @@ -- BUG1 gate -- the master stops the player on the tile to his left -- BUG2 no speech -- no won text + no prize dialogue after the win -- BUG3 wrong re-talk -- shows the pre-battle challenge, not the after line --- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, not a dex entry +-- BUG4 (verify) -- the ball opens the prize's dex preview first +-- (FightingDojo.asm DisplayPokedex, #853) and then +-- asks with the Gen1 descriptor text -- BUG5 both balls -- the chosen ball AND the other one both vanish; the -- other should stay and give the "greedy" refusal -- BUG6 poster -- the north-wall posters ("Enemies on every side!") are @@ -20,6 +22,7 @@ return function(game) local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") + local DexEntryMenu = require("src.ui.DexEntryMenu") local OW = require("src.world.OverworldController") local Pokemon = require("src.pokemon.Pokemon") local Commands = require("src.script.Commands") @@ -35,6 +38,7 @@ return function(game) local function topIsTextBox() return getmetatable(game.stack:top()) == TextBox end local function topIsChoice() return getmetatable(game.stack:top()) == ChoiceBox end + local function topIsDex() return getmetatable(game.stack:top()) == DexEntryMenu end local function currentPageText() local top = game.stack:top() @@ -172,8 +176,14 @@ return function(game) check(leeBall ~= nil and chanBall ~= nil, "BUG5: both prize balls on the mat") if leeBall then ow:talkTo(leeBall) + -- DisplayPokedex runs before .Text and YesNoChoice in FightingDojo.asm, + -- so the dex page is the first thing the ball opens (#853) + U.wait(3) + check(topIsDex(), "BUG4: the ball opens the HITMONLEE dex entry first") + U.shot(game, DIR .. "/dojo_4_dexentry.png") + mashUntil(function() return not topIsDex() end, 20) check(sawText("hard kicking") or sawText("HITMONLEE"), - "BUG4: ball asks the Gen1 descriptor prompt (no dex entry)") + "BUG4: the dex page is followed by the Gen1 descriptor prompt") U.shot(game, DIR .. "/dojo_4_prompt.png") ------------------------------------------------------------------ -- BUG5: choose YES -> only the chosen ball vanishes; the other stays diff --git a/tests/drivers/game_corner_grunt_bug862_test.lua b/tests/drivers/game_corner_grunt_bug862_test.lua new file mode 100644 index 00000000..3c00bcfa --- /dev/null +++ b/tests/drivers/game_corner_grunt_bug862_test.lua @@ -0,0 +1,321 @@ +-- Driver: #862 Celadon Game Corner poster grunt, loss line + exit walk. +-- GameCornerRocketText saves _GameCornerRocketBattleEndText ("Dang!") for +-- PrintEndBattleText, and GameCornerRocketBattleScript picks the exit walk +-- from the player's cell (pokered/scripts/GameCorner.asm:54-102): east of +-- him it is WalkAroundPlayer, DOWN/R/R/UP/R/R/R/R, never UP into the poster. +-- No POKEPORT_SPEED: the walk and the battle text are what is under test. +-- SHOT_DIR=/tmp/shots POKEPORT_IDENTITY=bug862 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/game_corner_grunt_bug862_test.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + os.execute("mkdir -p " .. DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local BattleState = require("src.battle.BattleState") + + local pass, fail = 0, 0 + local function check(label, ok, detail) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label, detail or "") + return ok + end + + -- pokered/data/maps/objects/GameCorner.asm:36 -- the grunt is + -- object_event 9, 5, SPRITE_ROCKET, STAY, UP, facing the poster bg_event + -- at (9,4), which is wall. Standing east of him on (10,5) is the branch + -- that matters: wYCoord ~= 6 and wXCoord ~= 8, so the script takes + -- GameCornerMovement_Rocket_WalkAroundPlayer. + local MAP = "GAME_CORNER" + local NAME = "GAMECORNER_ROCKET" + local GX, GY = 9, 5 + local STAND = { x = 10, y = 5, facing = "left" } + local POSTER = { x = 9, y = 4 } + -- DOWN, RIGHT, RIGHT, UP, RIGHT x4 from (9,5), ending on (15,5) + local AROUND = { + { 9, 6 }, { 10, 6 }, { 11, 6 }, { 11, 5 }, + { 12, 5 }, { 13, 5 }, { 14, 5 }, { 15, 5 }, + } + + -- clean slate: he must not read as already defeated or already hidden + game.save.defeatedTrainers = {} + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles.GAME_CORNER = nil + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "RED" + game.save.money = game.save.money or 3000 + + -- a tank that one-shots OPP_ROCKET #7, so the mash win below is quick and + -- the same every run whatever the type matchups are + local tank = Pokemon.new(game.data, "MEWTWO", 100) + tank.moves = { + { id = "PSYCHIC_M", pp = 99 }, + { id = "THUNDERBOLT", pp = 99 }, + { id = "ICE_BEAM", pp = 99 }, + { id = "EARTHQUAKE", pp = 99 }, + } + game.save.party = { tank } + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + local ow = game.overworld + + local function findGrunt() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == NAME then return n end + end + return nil + end + + local grunt = findGrunt() + check("GAMECORNER_ROCKET is on the floor", grunt ~= nil) + if grunt then + check("he stands on (9,5)", grunt.cellX == GX and grunt.cellY == GY, + ("at (%d,%d)"):format(grunt.cellX, grunt.cellY)) + end + + -- a map edit or a mod could take (10,5) away; anything east of him keeps + -- the WalkAroundPlayer branch, so fall back to a free walkable neighbour + -- and say which branch that lands on + local function facingGrunt() + local g = findGrunt() + if not g then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == g + end + if grunt and not facingGrunt() then + local sides = { + { 1, 0, "left" }, { 0, 1, "up" }, { -1, 0, "right" }, { 0, -1, "down" }, + } + for _, s in ipairs(sides) do + local cx, cy = grunt.cellX + s[1], grunt.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d,%d) is blocked; standing on"):format(STAND.x, STAND.y), + cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + ow = game.overworld + grunt = findGrunt() + break + end + end + end + check("the player is face to face with him", facingGrunt()) + local px, py = ow.player.cellX, ow.player.cellY + local around = not (py == 6 or px == 8) + U.log(("talking from (%d,%d): the script should take %s"):format( + px, py, around and "WalkAroundPlayer (down, right, right, up, " + .. "right x4)" or "WalkDirect (right x5)")) + + -- the two strings the fix depends on, and the poster cell the pre-fix + -- single UP step walked him into + local t = game.data.text + check("_GameCornerRocketBattleEndText resolves", + type(t._GameCornerRocketBattleEndText) == "string" + and t._GameCornerRocketBattleEndText ~= "", + tostring(t._GameCornerRocketBattleEndText)) + check("_GameCornerRocketAfterBattleText resolves", + type(t._GameCornerRocketAfterBattleText) == "string" + and t._GameCornerRocketAfterBattleText ~= "") + check("(9,4) is the poster wall, not a cell he can stand on", + not ow.map:isWalkableCell(POSTER.x, POSTER.y)) + + -- engageTrainer has to accept the script-supplied loss line; a stale + -- two-parameter copy would silently drop it and print nothing + local info = debug.getinfo(ow.engageTrainer, "S") + local sigOk = false + if info and info.short_src then + local src = io.open((info.short_src:gsub("^@", "")), "r") + if src then + local n = 0 + for line in src:lines() do + n = n + 1 + if n == info.linedefined then + sigOk = line:find("endBattleText", 1, true) ~= nil + break + end + end + src:close() + end + end + check("engageTrainer takes an endBattleText argument", sigOk) + + U.shot(game, DIR .. "/bug862_0_before.png") + + -- Talk and mash to a win, recording every battle message in order and + -- pausing on the loss line long enough to photograph it. + local said, battle = {}, nil + local lastSaid, dangShot = nil, false + local function sample() + local top = game.stack:top() + if getmetatable(top) == BattleState then + battle = battle or top + local cur = top.current + local text = type(cur) == "table" and cur.text + if type(text) == "string" and text ~= lastSaid then + lastSaid = text + said[#said + 1] = text + U.log("battle says:", (text:gsub("\n", " "))) + end + end + end + + local function pageText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local parts = {} + for _, page in ipairs(top.pages or {}) do + if type(page) == "table" then + for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end + end + end + return table.concat(parts, " ") + end + + local function idle() + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and not ow.transitioning + end + + U.tap(game, "a") + local sawAfter = false + for f = 1, 4000 do + sample() + if pageText():find("hideout", 1, true) then sawAfter = true break end + local top = game.stack:top() + if lastSaid and lastSaid:find("Dang", 1, true) and not dangShot then + -- stop mashing for a moment: the loss line is on the battle screen. + -- The row is picked up the frame it starts typing, so let it finish + -- before the capture or the shot is one letter wide. + dangShot = true + U.wait(60) + U.shot(game, DIR .. "/bug862_1_dang.png") + elseif top and top.phase then + if top.phase == "menu" then top.menuIndex = 1 + elseif top.phase == "moveSelect" then top.moveIndex = 1 end + U.tap(game, "a") + if f > 2400 and top.onFinish then + U.log("force-finishing a stalled battle") + top.onFinish("win") + if game.stack:top() == top then game.stack:pop() end + end + else + U.tap(game, "a") + end + U.wait(2) + sample() + end + check("reached the after-battle 'hideout' line", sawAfter) + check("the battle carried the script's loss line", + battle ~= nil and type(battle.endBattleText) == "string" + and battle.endBattleText:find("Dang", 1, true) ~= nil, + battle and tostring(battle.endBattleText) or "no battle seen") + + -- PrintEndBattleText sits between TrainerDefeatedText and + -- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory) + local iDefeat, iDang, iMoney + for i, line in ipairs(said) do + if not iDefeat and line:find("defeated", 1, true) then iDefeat = i end + if not iDang and line:find("Dang", 1, true) then iDang = i end + if not iMoney and line:find("winning", 1, true) then iMoney = i end + end + check("the loss line printed on the battle screen", iDang ~= nil) + check("it printed with the ROCKET: name tag", + iDang ~= nil and said[iDang]:find(":", 1, true) ~= nil, + iDang and said[iDang] or "") + check("order is defeated -> Dang! -> payout", + iDefeat ~= nil and iDang ~= nil and iMoney ~= nil + and iDefeat < iDang and iDang < iMoney, + ("defeated=%s dang=%s payout=%s"):format(tostring(iDefeat), + tostring(iDang), + tostring(iMoney))) + U.shot(game, DIR .. "/bug862_2_afterbattle.png") + + -- Dismiss the after-battle box and watch the exit walk cell by cell. + U.tap(game, "a") + local visited, order, lowShot = {}, {}, false + local function mark(cx, cy) + local key = cx .. "," .. cy + if not visited[key] then + visited[key] = true + order[#order + 1] = key + end + end + -- the last step's hide_object rides its own onDone, so the grunt leaves + -- ow.npcs on the frame he lands: count the cell he is walking INTO as + -- visited too, or the destination never shows up in the sample + local last = { GX, GY } + for _ = 1, 900 do + local g = findGrunt() + if g then + mark(g.cellX, g.cellY) + last = { g.cellX, g.cellY } + if g.targetX and g.targetY then + mark(g.targetX, g.targetY) + last = { g.targetX, g.targetY } + end + if g.cellY > GY and not lowShot then + lowShot = true + U.shot(game, DIR .. "/bug862_3_walk.png") + end + elseif idle() then + break + end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(1) + end + for _ = 1, 400 do + if idle() then break end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(2) + end + U.wait(5) + U.shot(game, DIR .. "/bug862_4_gone.png") + + U.log("cells he stood on:", table.concat(order, " ")) + check("he never stood on the poster cell (9,4)", + not visited[POSTER.x .. "," .. POSTER.y]) + check("he never stepped north of his start row", (function() + for key in pairs(visited) do + local y = tonumber(key:match(",(%d+)$")) + if y and y < GY then return false end + end + return true + end)()) + if around then + check("he stepped down to (9,6) to get past the player", visited["9,6"]) + check("he came back up onto row 5 and finished on (15,5)", + last[1] == 15 and last[2] == 5, + ("last seen on (%d,%d)"):format(last[1], last[2])) + else + check("he walked straight along row 5 to (15,5)", + last[1] == 15 and last[2] == 5 and not visited["9,6"], + ("last seen on (%d,%d)"):format(last[1], last[2])) + end + local toggles = game.save.objectToggles.GAME_CORNER + check("he despawned only after the last step", findGrunt() == nil) + check("his objectToggle is hidden", + toggles ~= nil and toggles.GAMECORNER_ROCKET == false) + check("he is recorded as defeated", + game.save.defeatedTrainers["GAME_CORNER_obj_11"] == true) + U.log(("checks: %d passed, %d failed"):format(pass, fail)) + + -- Hand the pad over on a clean copy of the same setup so the whole beat + -- can be watched at speed. + game.save.defeatedTrainers = {} + game.save.objectToggles.GAME_CORNER = nil + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.log("You are east of the grunt again, facing him. Press A and win.") + U.log("Right looks like: he says his piece on the battle screen after") + U.log("\"RED defeated ROCKET!\" -- one box, \"ROCKET: Dang!\" -- and the") + U.log("¥ payout comes after it, not before. Then the hideout line, then") + U.log("he steps DOWN off row 5, right past you, back up and out east.") + U.log("The near miss to watch for: he steps UP into the poster, or the") + U.log("Dang! box turns up in the overworld after the battle has torn down.") + U.log("Talk to him from (9,6) below instead and he takes the straight") + U.log("five-step version east; both are correct, the branch is your cell.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/jessie_james_bug866_test.lua b/tests/drivers/jessie_james_bug866_test.lua new file mode 100644 index 00000000..ec415d3b --- /dev/null +++ b/tests/drivers/jessie_james_bug866_test.lua @@ -0,0 +1,177 @@ +-- Manual check of the Rocket Hideout B4F Jessie & James ambush: James walks +-- the full four tiles to the player's side (#865) and their loss line prints +-- on the battle screen before the prize money (#866). +-- pokeyellow scripts/RocketHideoutB4F.asm (MovementData_45605 falls through +-- into _45606) and data/maps/objects/RocketHideoutB4F.asm. No fast-forward: +-- POKEPORT_DRIVER=tests/drivers/jessie_james_bug866_test.lua POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Commands = require("src.script.Commands") + local GameVersion = require("src.core.GameVersion") + local mapScripts = require("data.scripts.init") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local MAP = "ROCKET_HIDEOUT_B4F" + local BEAT = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" + local JAMES, JESSIE = "ROCKETHIDEOUTB4F_JAMES", "ROCKETHIDEOUTB4F_JESSIE" + -- RocketHideoutB4FScript_455a5 fires on wYCoord $e with wXCoord $18 or $19. + -- x=24 leaves EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT clear, which is + -- the branch that hands the four-step blob to James (object 2, spawned at + -- 25,10) and the three-step one to Jessie (object 3, at 24,10). + local TRIGGER = { x = 24, y = 14 } + local EXPECT = { + [JAMES] = { x = 25, y = 14, facing = "left" }, + [JESSIE] = { x = 24, y = 13, facing = "down" }, + } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("running the Yellow cache (the duo exists nowhere else)", + GameVersion.isYellow()) + if not GameVersion.isYellow() then + U.log("re-run with POKEPORT_VERSION=yellow; nothing below will be true") + end + + local hooks = mapScripts.get(MAP) + check("yellow_jessie_james registered an onStep for " .. MAP, + type(hooks) == "table" and type(hooks.onStep) == "function") + check("their talk entries are registered too", + type(hooks) == "table" and type(hooks.talk) == "table" + and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JAMES ~= nil + and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JESSIE ~= nil) + + -- the #866 fix is a new script verb; if a mod shadowed it or the registry + -- never picked it up, the row would silently no-op and the line would come + -- back after the money instead of before it + local verb = Commands.resolve(game.data, "save_end_battle_text") + check("save_end_battle_text resolves as a script verb", type(verb) == "function") + + local texts = {} + for i = 1, 4 do + local key = "_RocketHideoutJessieJamesText" .. i + texts[i] = game.data.text[key] + check(key .. " resolves to a string", + type(texts[i]) == "string" and texts[i] ~= "") + end + if type(texts[3]) == "string" then + U.log("the armed loss line reads:", (texts[3]:gsub("\n", " / "))) + end + + local objs = (game.data.maps[MAP] or {}).objects or {} + local defs = {} + for _, o in ipairs(objs) do + if o.name == JAMES or o.name == JESSIE then defs[o.name] = o end + end + check("James is object 2 of " .. MAP .. ", hidden at (25,10)", + defs[JAMES] ~= nil and defs[JAMES].index == 2 + and defs[JAMES].x == 25 and defs[JAMES].y == 10 + and defs[JAMES].hidden == true) + check("Jessie is object 3, hidden at (24,10)", + defs[JESSIE] ~= nil and defs[JESSIE].index == 3 + and defs[JESSIE].x == 24 and defs[JESSIE].y == 10 + and defs[JESSIE].hidden == true) + + local rocket = game.data.trainers.OPP_ROCKET + check("OPP_ROCKET party 43 (the duo's shared team) exists", + rocket ~= nil and rocket.parties ~= nil and rocket.parties[43] ~= nil) + + -- arm the site: the ambush is gated only on its beat flag, so no story + -- progress is needed to make it live + game.save.flags[BEAT] = nil + game.save.flags.EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT = nil + check(BEAT .. " cleared, so the trigger is live", + game.save.flags[BEAT] == nil) + + -- a real party, because the human has to win the battle for the loss line + -- to print at all + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 60), + Pokemon.new(game.data, "NIDOKING", 58), + Pokemon.new(game.data, "STARMIE", 58), + } + game.save.player.name = "RED" + + -- walk in from the north; the two elevator warps sit on row 15, so the + -- approach cannot come from below + U.teleport(game, MAP, TRIGGER.x, TRIGGER.y - 1, "down") + local ow = game.overworld + if not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y - 1) then + -- a map edit moved the free cell: any walkable neighbour of the trigger + -- works, the script only reads the tile the player lands on + local sides = { { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } } + for _, s in ipairs(sides) do + local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2] + if ow.map:isWalkableCell(cx, cy) then + U.log("standing on", cx, cy, "facing", s[3], "instead") + U.teleport(game, MAP, cx, cy, s[3]) + ow = game.overworld + U.hold(game, s[3] == "down" and "down" or (s[3] == "right" and "right" or "left"), 20) + break + end + end + else + U.hold(game, "down", 20) + end + U.wait(10) + check("player stepped onto the trigger tile (24,14)", + ow.player.cellX == TRIGGER.x and ow.player.cellY == TRIGGER.y) + check("the ambush script is running", ow.runner:isRunning()) + + U.log("The cutscene is yours now: press A to read, then fight and win.") + U.log("Right looks like both Rockets closing in -- Jessie stopping one tile") + U.log("above you, James coming all the way down to stand at your right -- and") + U.log("after you win, \"ROCKET: Such a dreadful twerp!\" appearing on the") + U.log("battle screen just before the money line. The near-miss to watch for") + U.log("is James halting three tiles up by the wall, or that line showing up") + U.log("in the overworld box after the payout with no ROCKET: tag on it.") + U.log("Two more checks print below as you get to them.") + + local function npcNamed(name) + for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + local approachDone, battleSeen = false, false + while true do + if not approachDone then + local j, s = npcNamed(JAMES), npcNamed(JESSIE) + local ow2 = game.overworld + if j and s and not j.moving and not s.moving and ow2 + and #(ow2.scriptMoves or {}) == 0 + and (j.cellY > 10 or s.cellY > 10) then + approachDone = true + check("James walked the full four tiles to (25,14) facing left", + j.cellX == EXPECT[JAMES].x and j.cellY == EXPECT[JAMES].y + and j.facing == EXPECT[JAMES].facing) + check("Jessie stopped three down at (24,13) facing the player", + s.cellX == EXPECT[JESSIE].x and s.cellY == EXPECT[JESSIE].y + and s.facing == EXPECT[JESSIE].facing) + U.log("James at", j.cellX, j.cellY, j.facing, + "Jessie at", s.cellX, s.cellY, s.facing) + U.shot(game, SHOT_DIR .. "/jj866_approach.png") + end + end + if not battleSeen then + local top = game.stack:top() + if getmetatable(top) == BattleState then + battleSeen = true + -- BattleState prints endBattleText between _TrainerDefeatedText and + -- _MoneyForWinningText, so an armed field IS the ordering fix + check("the battle carries the loss line as its end-battle text", + type(top.endBattleText) == "string" and top.endBattleText ~= "" + and top.endBattleText == texts[3]) + if type(top.endBattleText) == "string" then + U.log("armed:", (top.endBattleText:gsub("\n", " / "))) + end + end + end + coroutine.yield() + end +end diff --git a/tests/engine/disable_same_turn_bug860.lua b/tests/engine/disable_same_turn_bug860.lua new file mode 100644 index 00000000..80c4ce65 --- /dev/null +++ b/tests/engine/disable_same_turn_bug860.lua @@ -0,0 +1,211 @@ +-- Disable blocks the move the slower mon ALREADY selected, on the very +-- turn the Disable lands (#860). pokered runs the test at execution +-- time, not at selection time: CheckPlayerStatusConditions +-- .TriedToUseDisabledMoveCheck (engine/battle/core.asm:3437-3447) compares +-- wPlayerDisabledMoveNumber against wPlayerSelectedMove and jumps to +-- ExecutePlayerMoveDone when they match -- "prevents a disabled move that +-- was selected before being disabled from being used", in the asm's own +-- comment. The enemy copy is .checkIfTriedToUseDisabledMove +-- (core.asm:5752+). The port only refused a disabled move at menu time, +-- so the second mover still fired the move it had latched before the +-- Disable resolved. +-- +-- The check sits after the confusion block and before the paralysis roll, +-- so this suite also pins the neighbours: the counter tick that clears an +-- expired Disable still runs first, and a move that was never disabled is +-- untouched. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +-- the fixture dataset has no status move; this dataset is this file's own +-- copy (fixtures.fresh), so registering one here cannot leak into another +-- case. Accuracy 100 keeps DisableEffect's MoveHitTest out of the way. +Data.moves.FIX_DISABLE = { + id = "FIX_DISABLE", index = 90, name = "FIX DISABLE", + type = "NORMAL", power = 0, accuracy = 100, pp = 20, + effect = "DISABLE_EFFECT", +} + +-- Deterministic rolls: the minimum of every range, except DisableEffect's +-- own "1-8 turns disabled" roll (effects.asm:1343-1345), which is pinned +-- at 4. A rolled 1 would be spent by the disabled mon's own counter tick +-- in the same CheckStatusConditions pass -- vanilla behaviour, but it +-- clears the disable before .TriedToUseDisabledMoveCheck can see it, so it +-- is not the case this suite is about. rng(0, 255) -> 0 makes every +-- accuracy roll hit. +local function rolls(disableTurns) + return function(a, b) + if a == 1 and b == 8 then return disableTurns end + if a then return a end + return 0 + end +end + +-- playerFirst decides who lands the Disable; the other side is the one +-- whose already-selected move has to die. Both mons get FIX_TACKLE in +-- slot 1 (the slot DisableEffect picks with the min roll) and FIX_SCRATCH +-- in slot 2 as the never-disabled control. +local function newBattle(playerFirst, disableTurns) + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 30) + battle.rng = rolls(disableTurns or 4) + + local function loadout(battler) + battler.mon.moves = { + { id = "FIX_TACKLE", pp = 35 }, + { id = "FIX_SCRATCH", pp = 35 }, + { id = "FIX_DISABLE", pp = 20 }, + } + battler.curMoves = battler.mon.moves + end + loadout(battle.player) + loadout(battle.enemy) + + -- no speed tie to resolve: the disabler outruns its target outright + battle.player.curStats.speed = playerFirst and 200 or 1 + battle.enemy.curStats.speed = playerFirst and 1 or 200 + return battle +end + +-- the move instances the sides actually own, so PP decrements land on +-- the party copy the way DecrementPP mutates wBattleMonPP +local function slot(battler, i) return battler.curMoves[i] end + +-- consume the queue the way updateQueue does, minus the presentation +local function drain(battle) + local rows = {} + for _ = 1, 400 do + local item = table.remove(battle.queue, 1) + if not item then return rows end + if item.text then rows[#rows + 1] = { text = item.text } end + if item.fn then + battle.nextInsert = 0 + item.fn() + end + end + error("the turn queue never drained") +end + +local function saidWith(rows, needle) + for i, row in ipairs(rows) do + if row.text and row.text:find(needle, 1, true) then return i end + end + return nil +end + +-- "X's / MOVE is / disabled!" (PrintMoveIsDisabledText) versus +-- DisableEffect's own "MOVE was / disabled!" -- the two lines differ only +-- in that verb, so match on it +local function blocked(rows) return saidWith(rows, "is\ndisabled!") end +local function landed(rows) return saidWith(rows, "was\ndisabled!") end +local function usedTackle(rows) return saidWith(rows, "used FIX TACKLE!") end + +-- --------------------------------------------------------------------- +-- the player Disables first; the foe's latched FIX TACKLE dies this turn +-- --------------------------------------------------------------------- +do + local battle = newBattle(true) + battle.enemyAction = function() return slot(battle.enemy, 1) end + local hpBefore = battle.player.mon.hp + + battle:resolveTurn(slot(battle.player, 3)) + local rows = drain(battle) + + T.check(landed(rows) ~= nil, "the Disable lands") + T.eq(battle.enemy.disabledSlot, 1, "and latches onto the foe's slot 1") + T.check(blocked(rows) ~= nil, + "the foe's already-selected move reports as disabled") + T.check(landed(rows) and blocked(rows) and landed(rows) < blocked(rows), + "in that order: disabled first, then the blocked attempt") + T.check(usedTackle(rows) == nil, + "the disabled move is never announced, so it never executed") + T.eq(battle.player.mon.hp, hpBefore, "and it deals no damage") + T.eq(battle.enemy.disabledTurns, 3, + "the counter ticked once for this turn and the disable is still live") +end + +-- --------------------------------------------------------------------- +-- the same, mirrored: the foe Disables first and the player's latched +-- move dies (core.asm:5752 .checkIfTriedToUseDisabledMove) +-- --------------------------------------------------------------------- +do + local battle = newBattle(false) + battle.enemyAction = function() return slot(battle.enemy, 3) end + local hpBefore = battle.enemy.mon.hp + local ppBefore = slot(battle.player, 1).pp + + battle:resolveTurn(slot(battle.player, 1)) + local rows = drain(battle) + + T.check(landed(rows) ~= nil, "the foe's Disable lands") + T.eq(battle.player.disabledSlot, 1, "on the player's slot 1") + T.check(blocked(rows) ~= nil, "the player's latched move reports as disabled") + T.check(usedTackle(rows) == nil, "and is never announced") + T.eq(battle.enemy.mon.hp, hpBefore, "the foe takes no damage") + T.eq(slot(battle.player, 1).pp, ppBefore, + "and the move that never executed spends no PP (DecrementPP is inside " + .. "the move, past the status gauntlet)") +end + +-- --------------------------------------------------------------------- +-- no regression on the turns after: the disable keeps blocking that move +-- while its counter runs, and a different move still works +-- --------------------------------------------------------------------- +do + local battle = newBattle(true) + battle.enemyAction = function() return slot(battle.enemy, 1) end + battle:resolveTurn(slot(battle.player, 3)) + drain(battle) + T.eq(battle.enemy.disabledTurns, 3, "the disable is live going into turn 2") + + -- turn 2: the foe picks the disabled move with no Disable in flight + local hpBefore = battle.player.mon.hp + battle:resolveTurn(slot(battle.player, 2)) + local rows = drain(battle) + T.check(blocked(rows) ~= nil, "turn 2 still blocks the disabled move") + T.check(usedTackle(rows) == nil, "still no execution") + T.eq(battle.player.mon.hp, hpBefore, "still no damage") + T.eq(battle.enemy.disabledTurns, 2, "and the counter keeps ticking down") + + -- turn 3: the foe picks its OTHER move, which was never disabled + battle.enemyAction = function() return slot(battle.enemy, 2) end + hpBefore = battle.player.mon.hp + rows = (function() battle:resolveTurn(slot(battle.player, 2)); return drain(battle) end)() + T.check(blocked(rows) == nil, "an undisabled move is not blocked") + T.check(saidWith(rows, "used FIX SCRATCH!") ~= nil, "it is announced") + T.check(battle.player.mon.hp < hpBefore, "and it deals damage") +end + +-- --------------------------------------------------------------------- +-- the counter tick still runs ahead of the check: a disable that expires +-- on this turn frees the move it was holding (.DisabledCheck precedes +-- .TriedToUseDisabledMoveCheck) +-- --------------------------------------------------------------------- +do + local battle = newBattle(true) + battle.enemy.disabledSlot, battle.enemy.disabledTurns = 1, 1 + battle.enemyAction = function() return slot(battle.enemy, 1) end + local hpBefore = battle.player.mon.hp + + battle:resolveTurn(slot(battle.player, 2)) + local rows = drain(battle) + + T.check(saidWith(rows, "disabled no more!") ~= nil, "the disable expires") + T.check(blocked(rows) == nil, "so the move is not blocked") + T.check(usedTackle(rows) ~= nil, "it executes") + T.check(battle.player.mon.hp < hpBefore, "and deals damage") +end + +T.finish("disable blocks the already-selected move (#860)") diff --git a/tests/engine/options_write_readback_bug828.lua b/tests/engine/options_write_readback_bug828.lua index 3d68fac2..ccd8508c 100644 --- a/tests/engine/options_write_readback_bug828.lua +++ b/tests/engine/options_write_readback_bug828.lua @@ -110,4 +110,92 @@ local last = Logger.history[#Logger.history] check(last and last:find("options save failed", 1, true) ~= nil, "the last failure logged is the false-return one, not the readback one") +-- ---- an interrupted write no longer resets every setting +-- The launcher wrote WIDE and a later write dies partway through (the +-- process replaced by HostShell.restart on the way back to the launcher, an +-- external-storage flush that never happened), leaving a corrupt +-- options.lua. loadOptions must promote the staged/backup copy instead of +-- answering defaults, which is what "closing the game reset all my +-- settings" looked like. +local live = memfs("honest") +SaveData.saveOptions({ battleLayout = "wide" }, live) +SaveData.saveOptions({ battleLayout = "wide", textSpeed = 1 }, live) +check(live.files[OPTIONS .. ".bak"] ~= nil, + "the previous good options.lua is rolled aside before the rewrite") +check(live.files[OPTIONS .. ".tmp"] == nil, + "the staged witness is dropped once the main write is verified") +live.files[OPTIONS] = "return { battleLayout = " -- died mid-rewrite +local healed = SaveData.loadOptions(live) +eq(healed and healed.battleLayout, "wide", + "a corrupt options.lua is recovered from the rolled-aside copy") +check(live.files[OPTIONS] ~= "return { battleLayout = ", + "the main options file is healed from the copy that parsed") + +local gone = memfs("honest") +SaveData.saveOptions({ battleLayout = "wide" }, gone) +gone.files[OPTIONS] = nil +gone.files[OPTIONS .. ".bak"] = nil +gone.files[OPTIONS .. ".tmp"] = nil +eq(SaveData.loadOptions(gone).battleLayout, + SaveData.defaultOptions().battleLayout, + "with no copy left the defaults are still the answer") + +-- ---- the reported sequence end to end: launcher setting -> play -> quit +-- #828 as the reporter walks it (issue steps 2-7, and the "so its partly +-- fixed" comment): change BATTLE LAYOUT from OG to WIDE in the launcher, go +-- in game, close, reopen the launcher. Every options write is a whole-file +-- rewrite out of the caller's table (saveOptions above), so the only thing +-- keeping the launcher's key alive across a game-side write is WHEN the game +-- took its copy: SaveData.load re-attaches a fresh loadOptions() to the save +-- it just read (src/core/SaveData.lua:1108, and SaveData.newGame does the +-- same at :1458), which is after the launcher's last write because +-- RomImporter:play hands off only once the settings modal has saved +-- (src/import/LauncherSettings.lua open/save, src/import/RomImporter.lua +-- play). This pins that ordering: it is the invariant, not the merge, that +-- makes the launcher's change survive. +local hop = memfs("honest") +SaveData.saveOptions({ battleLayout = "og" }, hop) + +-- launcher: the gear menu's edited table, persisted on close +local launcherOpts = SaveData.loadOptions(hop) +launcherOpts.battleLayout = "wide" +launcherOpts.lastVersion = "blue" -- #835 rides the same file +SaveData.saveOptions(launcherOpts, hop) + +-- boot: the game's copy is taken here, never earlier +local gameOpts = SaveData.loadOptions(hop) +eq(gameOpts.battleLayout, "wide", + "the game boots on the value the launcher just wrote") + +-- play: an in-game OPTION menu change writes the whole table back +gameOpts.textSpeed = 1 +check(SaveData.saveOptions(gameOpts, hop) ~= nil, "the game-side write lands") + +local reopened = SaveData.loadOptions(hop) +eq(reopened.battleLayout, "wide", + "the launcher's BATTLE LAYOUT survives a game-side options write (#828)") +eq(reopened.textSpeed, 1, "and the in-game change is persisted alongside it") +eq(reopened.lastVersion, "blue", + "launcher-only keys the game never reads are carried through its write") + +-- The corollary, and the reason the copy has to come from loadOptions: a +-- caller that writes a partial literal instead of a loaded table drops every +-- key it does not mention, because mergeOptions only fills DEFAULTS in around +-- what it is handed (SaveData.mergeOptions). Nothing on the boot path does +-- this today; the assertion is the guard rail if someone shortcuts it. +SaveData.saveOptions({ battleLayout = "og" }, hop) +eq(SaveData.loadOptions(hop).lastVersion, nil, + "a partial write drops launcher-only keys, so the game must write the " + .. "table loadOptions handed it") + +-- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's +-- write and flushed after it still wins, because saveOptions merges only +-- modOptions from disk and every other key is last-writer-wins. Measured, +-- not guessed (og beats a newer wide). No shipping path holds an options +-- table across a launcher write -- HostShell.restart replaces the process on +-- the way back to the launcher (#785, #575) and LauncherSettings.open notes +-- its own cached table is only true while its modal covers the launcher -- +-- so closing that gap needs a three-way merge (baseline vs caller vs disk), +-- not a straight "disk wins", which would throw away real in-game changes. + T.finish("options_write_readback_bug828")