From f7695308b7229b44ccd91eb4bbb418b03cef6fbc Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Sun, 26 Jul 2026 13:38:52 -0400 Subject: [PATCH] big bug squash (#261) --- data/scripts/oaks_lab.lua | 93 +- data/scripts/story.lua | 86 + data/scripts/story2.lua | 6 +- data/scripts/story3.lua | 93 +- data/scripts/story4.lua | 28 +- data/scripts/story5.lua | 16 +- data/scripts/victories.lua | 7 +- src/battle/BattleState.lua | 146 +- src/core/Data.lua | 31 + src/link/LinkState.lua | 23 +- src/link/Protocol.lua | 34 +- src/link/Tournament.lua | 7 +- src/pokemon/Evolution.lua | 50 +- src/render/HudTiles.lua | 32 +- src/render/PaletteFX.lua | 61 +- src/render/Renderer.lua | 32 +- src/render/SpriteRenderer.lua | 17 +- src/ui/BagMenu.lua | 24 +- src/ui/EvolutionState.lua | 50 +- src/ui/FlyMenu.lua | 8 +- src/ui/PartyMenu.lua | 119 +- src/ui/ShopMenu.lua | 7 +- src/ui/SummaryMenu.lua | 11 +- src/ui/TownMap.lua | 111 +- src/world/Map.lua | 10 + src/world/OverworldController.lua | 156 +- src/world/Player.lua | 34 +- .../battle_boosted_exp_bug216_test.lua | 173 ++ .../drivers/battle_hpbar_gbc_bug229_test.lua | 104 ++ .../battle_levelup_hpbar_bug224_test.lua | 125 ++ .../battle_mono_sprite_bug207_test.lua | 150 ++ tests/drivers/blues_house_bug11_test.lua | 115 ++ tests/drivers/decline_push_bug151_test.lua | 141 ++ tests/drivers/dig_bug196_test.lua | 193 +++ .../drivers/evolution_cancel_bug213_test.lua | 126 ++ tests/drivers/evolution_move_bug12_test.lua | 143 ++ tests/drivers/fighting_dojo_bug197_test.lua | 221 +++ tests/drivers/fly_indigo_bug203_test.lua | 125 ++ tests/drivers/fly_townmap_bug195_test.lua | 101 ++ .../drivers/gamecorner_rocket_bug198_test.lua | 165 ++ tests/drivers/grass_overlay_bug150_test.lua | 105 ++ tests/drivers/grass_seam_bug217_test.lua | 63 + tests/drivers/link_any_level_test.lua | 71 + .../drivers/oak_early_battle_bug219_test.lua | 162 ++ tests/drivers/oak_leave_block_bug232_test.lua | 122 ++ .../oak_rival_greeting_bug218_test.lua | 92 ++ .../oak_rival_smell_later_bug231_test.lua | 170 ++ tests/drivers/ogblue_palette_bug155_test.lua | 107 ++ tests/drivers/party_bug147_message_test.lua | 74 + tests/drivers/pc_bug228_test.lua | 74 + .../prize_room_coincase_bug194_test.lua | 127 ++ .../drivers/reds_house_stairs_bug230_test.lua | 115 ++ .../rocket_hideout_gate_bug199_test.lua | 109 ++ tests/drivers/route.lua | 1419 +++++++++++++++-- tests/drivers/route4_downsweep_bug223.lua | 103 ++ tests/drivers/route4_ledge_bug223_test.lua | 168 ++ tests/drivers/saffron_gate_bug221_test.lua | 206 +++ tests/drivers/seafoam_current_bug212_test.lua | 124 ++ tests/drivers/summary_type_bug214_test.lua | 78 + tests/drivers/tmhm_able_bug210_test.lua | 86 + tests/drivers/tower7f_bug200_test.lua | 218 +++ .../drivers/townmap_selector_bug152_test.lua | 156 ++ tests/drivers/trade_autosave_bug222_test.lua | 150 ++ tests/drivers/trade_ot_bug215_test.lua | 95 ++ tests/engine/save_file_io_tests.lua | 73 + tests/parity_H.lua | 21 +- tests/parity_grass_seam.lua | 94 ++ tests/run_link_tests.lua | 75 + tests/run_tests.lua | 31 +- tools/extract/field.py | 35 +- tools/rom_manifest.json | 25 +- tools/rom_manifest_blue.json | 25 +- 72 files changed, 7375 insertions(+), 372 deletions(-) create mode 100644 tests/drivers/battle_boosted_exp_bug216_test.lua create mode 100644 tests/drivers/battle_hpbar_gbc_bug229_test.lua create mode 100644 tests/drivers/battle_levelup_hpbar_bug224_test.lua create mode 100644 tests/drivers/battle_mono_sprite_bug207_test.lua create mode 100644 tests/drivers/blues_house_bug11_test.lua create mode 100644 tests/drivers/decline_push_bug151_test.lua create mode 100644 tests/drivers/dig_bug196_test.lua create mode 100644 tests/drivers/evolution_cancel_bug213_test.lua create mode 100644 tests/drivers/evolution_move_bug12_test.lua create mode 100644 tests/drivers/fighting_dojo_bug197_test.lua create mode 100644 tests/drivers/fly_indigo_bug203_test.lua create mode 100644 tests/drivers/fly_townmap_bug195_test.lua create mode 100644 tests/drivers/gamecorner_rocket_bug198_test.lua create mode 100644 tests/drivers/grass_overlay_bug150_test.lua create mode 100644 tests/drivers/grass_seam_bug217_test.lua create mode 100644 tests/drivers/link_any_level_test.lua create mode 100644 tests/drivers/oak_early_battle_bug219_test.lua create mode 100644 tests/drivers/oak_leave_block_bug232_test.lua create mode 100644 tests/drivers/oak_rival_greeting_bug218_test.lua create mode 100644 tests/drivers/oak_rival_smell_later_bug231_test.lua create mode 100644 tests/drivers/ogblue_palette_bug155_test.lua create mode 100644 tests/drivers/party_bug147_message_test.lua create mode 100644 tests/drivers/pc_bug228_test.lua create mode 100644 tests/drivers/prize_room_coincase_bug194_test.lua create mode 100644 tests/drivers/reds_house_stairs_bug230_test.lua create mode 100644 tests/drivers/rocket_hideout_gate_bug199_test.lua create mode 100644 tests/drivers/route4_downsweep_bug223.lua create mode 100644 tests/drivers/route4_ledge_bug223_test.lua create mode 100644 tests/drivers/saffron_gate_bug221_test.lua create mode 100644 tests/drivers/seafoam_current_bug212_test.lua create mode 100644 tests/drivers/summary_type_bug214_test.lua create mode 100644 tests/drivers/tmhm_able_bug210_test.lua create mode 100644 tests/drivers/tower7f_bug200_test.lua create mode 100644 tests/drivers/townmap_selector_bug152_test.lua create mode 100644 tests/drivers/trade_autosave_bug222_test.lua create mode 100644 tests/drivers/trade_ot_bug215_test.lua create mode 100644 tests/parity_grass_seam.lua diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index dbb01673..2105ae85 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -6,7 +6,8 @@ -- takes it ("I'll take this one, then!") and both balls disappear. -- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText / -- OaksLabRivalTakePokeBallScript. --- * Rival (object 1): before starter -> "gramps isn't around"; with +-- * Rival (object 1): before starter -> "go ahead and choose" once Oak +-- has walked you in, else "gramps isn't around" (#218); with -- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party -- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 = -- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm); @@ -162,36 +163,45 @@ return { starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR", "OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"), + -- Talking to the rival only ever prints a line: the lab battle is a + -- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6; + -- see onStep below), never a talk action. The handler used to fall + -- through from the "looks stronger" taunt straight into start_battle, + -- so talking to Blue at the table launched the rival fight before the + -- player ever stepped onto the trigger (#219). scripts/OaksLab.asm + -- OaksLabText8 branches text only: + -- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger + -- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22 + -- line; normally unreachable since the rival is hidden after the + -- lab battle in OaksLabRivalEndBattleScript) + -- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218) TEXT_OAKSLAB_RIVAL = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_GOT_STARTER" }, -- 2 - { "jump_if_false", 21 }, -- 3 - { "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4 - { "jump_if_true", 19 }, -- 5 - { "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6 - { "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7 - { "jump_if_false", 11 }, -- 8 - { "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander - { "jump", 16 }, -- 10 - { "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11 - { "jump_if_false", 15 }, -- 12 - { "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur - { "jump", 16 }, -- 14 - { "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle - -- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way - { "heal_party" }, -- 16 - { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17 - { "jump", 23 }, -- 18 - { "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19 - { "jump", "end" }, -- 20 - { "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21 - { "jump", "end" }, -- 22 - -- win: sulk text then exit; loss: Rival1WinText already played in - -- battle (HandlePlayerBlackOut), so skip straight to the walk-out - { "jump_if_false", 25 }, -- 23 - { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24 - { "move_npc_to", 1, 4, 11 }, -- 25 - { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26 + { "face_player" }, + { "check_flag", "EVENT_GOT_STARTER" }, + { "jump_if_false", "pre_starter" }, + { "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + { "jump_if_true", "after_battle" }, + -- has a starter, has not fought yet: just the taunt. The battle is + -- the coordinate trigger in onStep, not this talk (#219) + { "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, + { "jump", "end" }, + + { "label", "after_battle" }, + { "show_text", "_OaksLabRivalFedUpWithWaitingText" }, + { "jump", "end" }, + + -- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler): + -- Oak has already escorted you in (three balls on the table) -> he + -- waves you on to choose; not yet escorted (very early game) -> the + -- "Gramps isn't around" line. #218 + { "label", "pre_starter" }, + { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, + { "jump_if_false", "gramps_gone" }, + { "show_text", "_OaksLabRivalGoAheadAndChooseText" }, + { "jump", "end" }, + + { "label", "gramps_gone" }, + { "show_text", "_OaksLabRivalGrampsIsntAroundText" }, }, }, @@ -213,9 +223,16 @@ return { -- OaksLabScript8 / OaksLabRivalChallenge) onStep = function(game, ow, x, y) local flags = game.save.flags - -- Oak blocks the exit mats (4,11)/(5,11) until you take a starter + -- Oak stops you leaving without a starter at the bookshelf row (cell + -- y == 6): this is the same wYCoord == 6 coordinate script the rival + -- challenge just below uses (scripts/OaksLab.asm, both gated on + -- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one + -- corridor length later on the exit mat. At y>=6 only the x=4,5 + -- corridor is walkable and the up-push keeps the player above y=7, so + -- this only ever fires at y=6 in the corridor -- matching the rival + -- trigger's shape. (#232) if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER - and y == 11 and (x == 4 or x == 5) then + and y >= 6 then ow.runner:run({ { "show_text", "_OaksLabOakDontGoAwayYetText" }, { "move_player", "up", 1 }, @@ -254,10 +271,18 @@ return { -- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout table.insert(rows, { "heal_party" }) table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }) - -- win: sulk text then exit; loss jumps to the walk-out (taunt was - -- already shown in-battle via Rival1WinText) + -- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong + -- POKéMON!" gloat, then BOTH win and loss print the shared exit line + -- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to + -- toughen it up!\012! Gramps! Smell you later!") before Blue + -- marches out. A loss skips only the gloat (that taunt was already + -- shown in-battle via Rival1WinText), never the exit line (#231). The + -- jump_if_false convergence point is the exit line: base+6 indexes the + -- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and + -- LOSS jumps straight to SmellYouLater (both then walk-out + hide). table.insert(rows, { "jump_if_false", base + 6 }) table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }) + table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) table.insert(rows, { "move_npc_to", 1, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) ow.runner:run(rows, { npc = rival }) diff --git a/data/scripts/story.lua b/data/scripts/story.lua index aa161946..3ed7cf3e 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -395,7 +395,93 @@ M.SS_ANNE_CAPTAINS_ROOM = { -- MrFujisHouse.asm; the teleport back to his house is a warp) -- ------------------------------------------------------------------- +-- scripts/PokemonTower7F.asm: after each Rocket loses, +-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle), +-- prints its AfterBattle text (DisplayTextID), then +-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the +-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it +-- (HideObject). Without this the beaten grunts stood in the corridor +-- forever (#200). +local POKEMON_TOWER_7F_ROCKETS = { + { index = 1, name = "POKEMONTOWER7F_ROCKET1", + beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0", + after = "_PokemonTower7FRocket1AfterBattleText" }, + { index = 2, name = "POKEMONTOWER7F_ROCKET2", + beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1", + after = "_PokemonTower7FRocket2AfterBattleText" }, + { index = 3, name = "POKEMONTOWER7F_ROCKET3", + beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2", + after = "_PokemonTower7FRocket3AfterBattleText" }, +} + +-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y") +-- at the engagement (both the talk and the sight walk-up leave the player on +-- the tile the vanilla table indexes). Each list is that entry's +-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact +-- tables are one per grunt (dbmapcoord bytes decoded back to x,y). +local POKEMON_TOWER_7F_EXITS = { + [1] = { -- ROCKET1 @ (9,11), faces RIGHT + ["9,12"] = { "right", "down", "down", "down", "down", "down", "left" }, + ["10,11"] = { "down", "right", "down", "down", "down", "down" }, + ["11,11"] = { "down", "down", "down", "down", "down" }, + ["12,11"] = { "down", "down", "down", "down", "down" }, + }, + [2] = { -- ROCKET2 @ (12,9), faces LEFT + ["12,10"] = { "left", "down", "down", "down", "down", "down", "down" }, + ["11,9"] = { "down", "down", "down", "left", "down", "down" }, + ["10,9"] = { "down", "down", "down", "down", "down" }, + ["9,9"] = { "down", "down", "down", "down", "down" }, + }, + [3] = { -- ROCKET3 @ (9,7), faces RIGHT + ["9,8"] = { "right", "down", "down", "down", "down", "down", "down" }, + ["10,7"] = { "down", "down", "down", "down", "down" }, + ["11,7"] = { "down", "down", "down", "down", "down" }, + ["12,7"] = { "down", "down", "down", "down", "down" }, + }, +} +-- The vanilla table only lists the sight/talk tiles the map geometry allows; +-- a plain down-walk keeps any other engagement tile (e.g. talking from the +-- side) safe -- it still clears the corridor and despawns the grunt. +local POKEMON_TOWER_7F_EXIT_FALLBACK = + { "down", "down", "down", "down", "down" } + M.POKEMON_TOWER_7F = { + -- Repair saves that beat a grunt before the exit walk was ported: in + -- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is + -- still gone on re-entry. hide_object below writes objectToggles for new + -- wins; this hides any grunt whose BEAT flag is already set. + onEnter = function(game, ow) + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do + if game.save.flags[r.beat] then + Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name) + end + end + end, + -- runVictoryHook fires after every trainer win on the map (both the sight + -- and talk paths route through engageTrainer -> checkVictoryRewards), so + -- this walks off whichever grunt was just beaten but is still standing. + -- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer + -- pushes right after, giving the vanilla order: EndBattle, AfterBattle, + -- walk, despawn. + onVictory = function(game, ow) + if ow.runner:isRunning() then return end + for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do + local npc = ow:npcByIndex(r.index) + if npc and game.save.flags[r.beat] then + local key = ow.player.cellX .. "," .. ow.player.cellY + local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key] + or POKEMON_TOWER_7F_EXIT_FALLBACK + ow.runner:run({ + { "show_text", r.after }, -- DisplayTextID + { "walk_npc", r.index, dirs }, -- MoveSprite + { "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject + }, { npc = npc }) + return + end + end + end, talk = { TEXT_POKEMONTOWER7F_MR_FUJI = { { "face_player" }, -- 1 diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 43a98f34..695545b1 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -528,7 +528,9 @@ M.MT_MOON_B2F = { } -- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text): --- Y50, once; declining at the rope walks you back out. +-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off +-- the exhibit rope they crossed heading north (#151); the museum floor has no +-- ledges, so a plain scriptMove("down",1) is the correct primitive. local function museumClerk(game, ow, done, onDecline) local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") @@ -563,7 +565,7 @@ M.MUSEUM_1F = { if y == 4 and (x == 9 or x == 10) and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then museumClerk(game, ow, nil, function() - ow:scriptMove(ow.player, "right", 1) + ow:scriptMove(ow.player, "down", 1) end) return true end diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 5f8d7f54..5d1adbd4 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -524,8 +524,17 @@ M.GAME_CORNER = { game.data.text._GameCornerRocketAfterBattleText or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", function() - hideRocket() - done() + -- #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) end)) end) end, @@ -606,40 +615,62 @@ local function activePrizes() return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES end +-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize +-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates +-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and +-- with no case prints RequireCoinCaseText and returns without ever opening a +-- window; only with the case does it print ExchangeCoinsForPrizesText and then +-- show the prizes. #194: the port used to open the window unconditionally and +-- skip both text boxes. local function prizeCounter(game, ow, npc, done) local ListMenu = require("src.ui.ListMenu") local Commands = require("src.script.Commands") - local items = {} - for _, p in ipairs(activePrizes()) do - local label - if p.kind == "mon" then - label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) - else - label = game.data.items[p.item].name - end - table.insert(items, { label = label, right = tostring(p.cost), value = p }) + local TextBox = require("src.render.TextBox") + local t = game.data.text + -- IsItemInBag COIN_CASE: without the case, deny and open no window + -- (COIN_CASE is a numeric count in save.inventory, nil when absent). + if not game.save.inventory.COIN_CASE then + game.stack:push(TextBox.new(game, + t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done)) + return end - local list - list = ListMenu.new(game, "PRIZES (COINS)", items, { - footer = ("COINS %d"):format(game.save.coins or 0), - onChoose = function(item) - local p = item.value - if (game.save.coins or 0) < p.cost then - list.footer = "Not enough coins!" - return + -- ExchangeCoinsForPrizesText plays before the prize window opens. + game.stack:push(TextBox.new(game, + t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.", + function() + local items = {} + for _, p in ipairs(activePrizes()) do + local label + if p.kind == "mon" then + label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) + else + label = game.data.items[p.item].name + end + table.insert(items, + { label = label, right = tostring(p.cost), value = p }) end - game.save.coins = game.save.coins - p.cost - if p.kind == "mon" then - Commands.give_pokemon({ save = game.save, game = game }, - p.species, p.level) - else - game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1 - end - list.footer = ("Got it! COINS %d"):format(game.save.coins) - end, - onCancel = done, - }) - game.stack:push(list) + local list + list = ListMenu.new(game, "PRIZES (COINS)", items, { + footer = ("COINS %d"):format(game.save.coins or 0), + onChoose = function(item) + local p = item.value + if (game.save.coins or 0) < p.cost then + list.footer = "Not enough coins!" + return + end + game.save.coins = game.save.coins - p.cost + if p.kind == "mon" then + Commands.give_pokemon({ save = game.save, game = game }, + p.species, p.level) + else + game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1 + end + list.footer = ("Got it! COINS %d"):format(game.save.coins) + end, + onCancel = done, + }) + game.stack:push(list) + end)) end M.GAME_CORNER_PRIZE_ROOM = { diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 9ee5856f..dec82576 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = { -- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears) -- ------------------------------------------------------------------- +-- ownBall/otherBall keep their spots for signature symmetry; only ownBall +-- is ever hidden (Gen1 removes just the chosen ball). local function dojoBall(species, ownBall, otherBall, askKey) return function(game, ow, npc, done) local t = text(game) local flags = game.save.flags + -- Already took one prize: the OTHER ball is still on the mat, so + -- talking to it now gives the "Better not get greedy..." refusal + -- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText), + -- not a silent no-op (#197). if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then - done() + push(game, t._FightingDojoBetterNotGetGreedyText + or "Better not get\ngreedy...", done) return end if not flags.EVENT_BEAT_KARATE_MASTER then @@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey) 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) - Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall) push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) end) end @@ -171,6 +180,21 @@ M.FIGHTING_DOJO = { "FIGHTINGDOJO_HITMONLEE_POKE_BALL", "_FightingDojoHitmonchanPokeBallText"), }, + -- The two dojo posters on the north wall (FightingDojo.asm bg_events at + -- the top of the room, cells (4,0)/(5,0) directly above the prize balls) + -- print _EnemiesOnEverySideText. The map extractor dropped the dojo + -- bg_events (signs = {}), so wire the poster read here, the same + -- facing-up + coord shape the mansion switches use. Reachable only once + -- a claimed prize frees the ball cell below the poster (#197). + onInteract = function(game, ow, fx, fy) + if ow.player.facing ~= "up" then return false end + if fy == 0 and (fx == 4 or fx == 5) then + push(game, text(game)._EnemiesOnEverySideText + or "Enemies on every\nside!") + return true + end + return false + end, } -- ------------------------------------------------------------------- diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 5027640e..a8f3f764 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -196,14 +196,24 @@ local function inCoords(coords, x, y) return false end --- coordinate block: show a line and push the player back one step +-- coordinate block: show a line and shove the player back one step. pokered +-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity- +-- CheckGymOpenScript), which runs the normal overworld step pipeline including +-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge +-- when one sits in front: the tile below the Viridian Gym door is a down-ledge +-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get +-- checkLedgeHop == false and fall back to the plain forced step, unchanged. local function stepGate(opts) return function(game, ow, x, y) if not inCoords(opts.coords, x, y) then return false end if not opts.blocked(game) then return false end require("src.core.Sound").play(game.data, "Denied") - push(game, text(game)[opts.text] or opts.fallback, - function() ow:scriptMove(ow.player, opts.push, 1) end) + push(game, text(game)[opts.text] or opts.fallback, function() + ow.player.facing = opts.push + if not ow:checkLedgeHop(opts.push) then + ow:scriptMove(ow.player, opts.push, 1) + end + end) return true end end diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index a31bbbb0..65859d8e 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -114,9 +114,12 @@ return { -- Fighting Dojo Karate Master (scripts/FightingDojo.asm -- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER, -- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is - -- only him (data/maps/objects/FightingDojo.asm). + -- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the + -- concede + prize offer he speaks after the win (his header supplies the + -- "Hwa! Arrgh! Beaten!" won line first; #197). ["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER", - deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) }, + deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3), + dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } }, -- Elite Four progress flags (their rooms' door logic isn't ported, but -- the flags make the Hall of Fame checkable) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index cc4b7133..32589b29 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -183,7 +183,22 @@ end -- the image a battler pic actually draws with this frame function BattleState:picImage(img) - if self.grayPics then return grayImage(img) end + local PaletteFX = require("src.render.PaletteFX") + -- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that + -- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone + -- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the + -- WHOLE finished frame through the shade shader a second time (keyed on the + -- red channel). A pic baked with the species' SGB color is then remapped + -- again and loses its warm mid shades -- REDMON's reds 1.0/0.839 both land in + -- the c0 bucket, collapsing CHARMANDER's body into the white paper and + -- leaving only the outline. Emit the raw DMG-gray build instead (exactly + -- what the SE_WAVY_SCREEN grayPics path already does) so the downstream remap + -- recolors 255->c0/170->c1/85->c2/0->c3 and all four shades survive; cool + -- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode + -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. + local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" + or PaletteFX.mode == "classic" + if self.grayPics or mono then return grayImage(img) end return fadeImage(img, self:activeBgp()) end @@ -666,16 +681,53 @@ local function shownHP(b) return math.floor(shown) end +-- Parse a battle message into its rendered lines. The extractor marks +-- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking +-- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted / +-- EXP.ALL exp lines end in the CONT code in the ROM (data/generated/text.lua +-- _BoostedText/_WithExpAllText = "...\011"). Each entry is { codes, cont }, +-- cont true when the line was preceded by \v. Splitting before Font.encode +-- keeps the control chars out of the glyph stream. The box then types into +-- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives +-- instead of drawing it off-screen at y=144 (#216). function BattleState:startMessage(item) self.current = item self.lines = {} self.total = 0 - for chunk in (item.text .. "\n"):gmatch("(.-)\n") do + local text = item.text or "" + local pos, cont = 1, false + while true do + local npos = text:find("[\n\v]", pos) + local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos) local codes = Font.encode(chunk) - table.insert(self.lines, codes) + self.lines[#self.lines + 1] = { codes = codes, cont = cont } self.total = self.total + #codes + if not npos then break end + cont = text:sub(npos, npos) == "\v" + pos = npos + 1 end + self.shown = {} -- up to two visible lines of revealed glyph codes + self.lineIndex = 0 + -- self.charIndex counts glyphs typed across the WHOLE message (drivers read + -- it against self.total); the current line's revealed count is #shown[last] self.charIndex = 0 + self.msgWaiting = nil + self.scrollPx = nil + self:beginMsgLine() +end + +-- Start typing the next line into the rolling window. When the box already +-- shows two lines, drop the top one and set the pixel scroll-up +-- (ScrollTextUpOneLine), mirroring TextBox:beginLine. +function BattleState:beginMsgLine() + self.lineIndex = self.lineIndex + 1 + local ln = self.lines[self.lineIndex] + self.codes = ln and ln.codes or {} + if #self.shown >= 2 then + table.remove(self.shown, 1) + self.scrollPx = 8 + end + self.shown[#self.shown + 1] = {} end function BattleState:updateQueue() @@ -823,8 +875,32 @@ function BattleState:updateQueue() end self:startMessage(item) end - if self.charIndex < self.total then - self.charIndex = math.min(self.total, self.charIndex + 2) + local input = self.game.input + -- a \v CONT wait holds the box until A/B, then scrolls the next line in + -- (home/text.asm ContText); this keeps a 3rd line on-screen (#216) + if self.msgWaiting then + if input:wasPressed("a") or input:wasPressed("b") then + self.msgWaiting = nil + self:beginMsgLine() + end + return true + end + local cur = self.shown[#self.shown] + if #cur < #self.codes then + -- battle typewriter cadence: two glyphs per fixed step (as before) + for _ = 1, 2 do + if #cur >= #self.codes then break end + cur[#cur + 1] = self.codes[#cur + 1] + self.charIndex = self.charIndex + 1 + end + elseif self.lineIndex < #self.lines then + -- current line finished, more lines remain: \v waits for A/B + ▼ before + -- scrolling, \n advances now (beginMsgLine scrolls if the box is full) + if self.lines[self.lineIndex + 1].cont then + self.msgWaiting = true + else + self:beginMsgLine() + end else local item = self.current -- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO @@ -841,7 +917,6 @@ function BattleState:updateQueue() end)) return true end - local input = self.game.input if not (item and item.choice) and (input:wasPressed("a") or input:wasPressed("b")) then self.current = nil @@ -2715,12 +2790,15 @@ function BattleState:enemyMonFainted() -- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL -- pass beats the traded boost (wBoostExpByExpAll checks first), -- and _ExpPointsText prints wExpAmountGained -- the raw share, - -- captured before the max-level cap (experience.asm:92-100) + -- captured before the max-level cap (experience.asm:92-100). + -- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011" + -- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls + -- the amount line in, so it stays on-screen instead of at y=144 (#216). local tail = "%d EXP. Points!" if announce == "expAll" then - tail = "with EXP.ALL,\n" .. tail + tail = "with EXP.ALL,\v" .. tail elseif mon.traded then - tail = "a boosted\n" .. tail + tail = "a boosted\v" .. tail end self:sayNext(("%s gained\n" .. tail):format(name, gained)) end @@ -2733,6 +2811,17 @@ function BattleState:enemyMonFainted() require("src.core.Sound").play(game.data, "Level_Up") return StatBox.new(game, mon) end) + -- After PrintStatsBox, experience.asm reloads the active battler's + -- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the + -- higher current HP. Experience.lua:84 already raised mon.hp by + -- (newMaxHP - oldMaxHP); the party mon and the battler share one table + -- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the + -- new max instantly while the battler's shownHP numerator lags at the + -- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the + -- new current HP (house convention: potions drain the bar too, see + -- itemUsed) so the bar grows instead. Only the active player battler + -- shares its table with the HUD; other party mons (EXP.ALL) have no bar. + if mon == self.player.mon then self:drainNext() end for _, moveId in ipairs(Experience.movesLearnedAt( self.data.pokemon[mon.species], lv)) do self:learnMove(mon, moveId) @@ -4114,7 +4203,12 @@ function BattleState:drawHUDs(slide) -- the HUD clears with the send-out text (ClearScreenArea, -- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws -- it after the grow-in + cry - local barData = self:colorMode() and {} or self.data -- gray fill when zoned + -- In colorized modes the zone pass (drawZonePass over BATTLE_ZONES pal 0/1) + -- recolors the bar's DMG gray fill by region, so drawHPBar must skip its + -- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill + -- double-applies and the zone shade shader maps the whole bar to black (#229). + local grayFill = self:colorMode() + local barData = self.data local fx = self.fx local hudShake = (fx and fx.hudShakeX) or 0 -- FaintEnemyPokemon clears the enemy HUD area; it stays blank through @@ -4139,7 +4233,8 @@ function BattleState:drawHUDs(slide) end hudTile(0x73, 8, 16) drawHPBar(barData, 2, 2, - { hp = shownHP(self.enemy), stats = self.enemy.mon.stats }) + { hp = shownHP(self.enemy), stats = self.enemy.mon.stats }, + nil, grayFill) hudTile(0x74, 8, 24) for i = 2, 9 do hudTile(0x76, i * 8, 24) end hudTile(0x78, 80, 24) @@ -4187,7 +4282,7 @@ function BattleState:drawHUDs(slide) end drawHPBar(barData, 10, 9, { hp = shownHP(self.player), stats = self.player.mon.stats }, - 1) -- wHPBarType 1: the $6D cap + 1, grayFill) -- wHPBarType 1: the $6D cap Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80) hudTile(0x73, 144, 80) hudTile(0x77, 144, 88) @@ -4200,16 +4295,27 @@ function BattleState:drawTextArea() Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) if self.phase == "messages" and self.current then - local shown = 0 - for li, codes in ipairs(self.lines) do - -- battle text uses every other tile row (hlcoord *,14 / *,16) - local y = 112 + (li - 1) * 16 - for i = 1, #codes do - if shown >= self.charIndex then break end - Font.drawCode(codes[i], 8 + (i - 1) * 8, y) - shown = shown + 1 + -- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle + -- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates + -- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into + -- view instead of drawing off-screen at y=144 (#216). + if self.scrollPx and self.scrollPx > 0 then + self.scrollPx = self.scrollPx - 2 + if self.scrollPx <= 0 then self.scrollPx = nil end + end + local off = self.scrollPx or 0 + local ys = { 112, 128 } + for li, line in ipairs(self.shown or {}) do + local y = (ys[li] or 128) + off + for i = 1, #line do + Font.drawCode(line[i], 8 + (i - 1) * 8, y) end end + -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the + -- box, bottom-right of the box like TextBox / home/text.asm + if self.msgWaiting and self.frame % 60 < 30 then + Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) + end elseif self.phase == "menu" and self.demo then -- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the -- standard menu, with the '▶' hand drawn by the scripted keystrokes diff --git a/src/core/Data.lua b/src/core/Data.lua index 9b62fe8f..18ed98f2 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -102,10 +102,41 @@ function Data:seedDefaults() -- after-battle rows so Blaine's SetEventRange deactivation and talk -- after-text work like the other gyms (scripts/CinnabarGym.asm). self:seedCinnabarGymTrainerHeaders() + -- #197: the Fighting Dojo Karate Master is text_asm, so the extractor + -- writes no header for him -- seed one so he engages on sight and has + -- his defeat / re-talk lines (same idea as the Cinnabar seed above). + self:seedFightingDojoKarateMaster() -- #189: 1F cabin door order vs rooms map (survey zoom) require("src.world.SsAnneLayout").apply(self.maps) end +-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has +-- no def_trainers row (DisplayTextID routes to his ASM script), so the +-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed +-- object index [1] so he behaves like the real leader: +-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers +-- spots the player in his DOWN line and he challenges on sight (#197), +-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!", +-- * after = the "Stay and train at Karate with us!" re-talk line. +-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by +-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and +-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if +-- this header also set it, the reward's flag guard would early-return and +-- swallow the prize dialogue. trainerDefeated tracks him via +-- defeatedTrainers[npc.id] like the leaders. +function Data:seedFightingDojoKarateMaster() + local headers = self.trainer_headers + if not headers then return end + headers.FightingDojo = headers.FightingDojo or {} + if headers.FightingDojo[1] then return end + headers.FightingDojo[1] = { + range = 4, + battle = "_FightingDojoKarateMasterText", + won = "_FightingDojoKarateMasterDefeatedText", + after = "_FightingDojoKarateMasterStayAndTrainWithUsText", + } +end + function Data:seedCinnabarGymTrainerHeaders() local headers = self.trainer_headers if not headers or headers.CinnabarGym then return end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 2023fa48..4ea0fba1 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -31,7 +31,12 @@ local function indexOf(list, value) end local function levelForWire(v) - return v == ANY and nil or v + -- ANY ("use each mon's real level") goes on the wire as nil (no forced + -- level). An explicit guard, not `v == ANY and nil or v`: that idiom's + -- true branch is nil, so it falls through to `or v` and returned the + -- literal "ANY" string, which then crashed math.floor in unpackMon (#204). + if v == ANY then return nil end + return v end local function forceLevelLabel(v) @@ -493,6 +498,14 @@ function LinkState:updateTrade(input) if t.stage == "done" then local sent = t.party[t.myPick] local received, evoTo = t:apply(self.game) + -- Autosave the instant the swap commits into game.save.party, matching the + -- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM + -- (engine/menus/save.asm) right after every trade so the trade is on the + -- cartridge before the animation runs. Without this the received mon + -- lives only in memory until a manual START-menu save, so a force-quit + -- would lose it and a reset would clone the sent mon (#222). Guarded so + -- headless LinkBattle-style fake games with no writeSave are unaffected. + if self.game.writeSave then self.game:writeSave() end local name = received.nickname or self.game.data.pokemon[received.species].name Runtime.emit("link.ended", { reason = "done" }) self.net:close() @@ -510,7 +523,13 @@ function LinkState:updateTrade(input) ("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), function() if evoTo then - require("src.pokemon.Evolution").evolve(game, received, evoTo) + -- via="TRADE": a trade evolution cannot be B-cancelled + -- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213). + -- Re-save once the evolution movie finishes so the evolved + -- species (not the pre-evo landed by t:apply) is what persists, + -- keeping disk in step with the autosave above (#222). + require("src.pokemon.Evolution").evolve(game, received, evoTo, + function() if game.writeSave then game:writeSave() end end, "TRADE") end end)) end, diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index f3e12c8c..f26d2c75 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy -- serialize a mon instance for the wire (plain data only). ppUps rides -- along because the real cable transmitted it and its absence silently --- capped a PP-Upped move at base PP on the receiving side. +-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride +-- along too: pokered's trade sends the whole party block including each +-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT), +-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a +-- mon as traded (boosted EXP, high-level disobedience). Omitting them made +-- a received mon show the receiver as its OT (#215). function Protocol.packMon(mon) local moves = {} for _, mv in ipairs(mon.moves) do @@ -59,6 +64,8 @@ function Protocol.packMon(mon) dvs = mon.dvs, statExp = mon.statExp, moves = moves, + ot = mon.ot, + otId = mon.otId, extra = plainCopy(mon.extra), } end @@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts) local Stats = require("src.pokemon.Stats") local Growth = require("src.pokemon.Growth") local strict = opts and opts.strict + -- forceLevel comes from an "auto-level" ruling. The picker's ANY choice + -- ("use each mon's real level", Gen1's only mode) is a string sentinel on + -- the LinkState/Tournament side (see levelForWire) that must mean "no + -- forced level" here. Coerce once so a non-numeric level string -- the ANY + -- sentinel, an old peer, or a mod (#204) -- can never reach math.floor + -- below: tonumber("ANY") == nil, i.e. keep the packed real level, while + -- tonumber(50)/tonumber("50") both give 50. + local forceLevel = opts and tonumber(opts.forceLevel) or nil local def = data.pokemon[packed.species] if not def then if strict then return nil, "unknown POKéMON" end @@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts) -- ignored and everyone rebuilds at the same fixed level instead, so a -- Lv12 and a Lv100 party can battle on equal footing. Both sides pass -- the identical forceLevel for a given match, so this stays symmetric. - if opts and opts.forceLevel then - level = math.max(2, math.min(100, math.floor(opts.forceLevel))) + if forceLevel then + level = math.max(2, math.min(100, math.floor(forceLevel))) end local dvs = {} for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do @@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts) -- current HP/status (a different level's numbers, possibly mid-fight) -- isn't meaningful anymore -- auto-level starts everyone full and fresh, -- same as a standardized tournament format would - local forced = opts and opts.forceLevel + local forced = forceLevel local hp = forced and stats.hp or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))) local status = forced and nil or packed.status + -- preserve the sender's original-trainer identity (party_struct MON_OTID + + -- wPartyMonOT on a real cable), clamped/typed like every other field so a + -- tampered packet can't inject a bad ID or a huge name. Left nil when the + -- packet omits them (a v1/old peer) -- no worse than before for that legacy + -- path, and once ot is set the load-time stampOT backfill (mon.ot or ...) + -- becomes a no-op so the sender's identity survives save/reload (#215). + local otId = packed.otId + and math.max(0, math.min(65535, math.floor(packed.otId))) or nil + local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil return { species = packed.species, level = level, @@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts) hp = hp, status = status, nickname = packed.nickname, + ot = ot, + otId = otId, moves = moves, -- a namespace whose mod this install lacks survives untouched, so the -- mon keeps it for the trip home diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua index 4f7b0564..ab15e5af 100644 --- a/src/link/Tournament.lua +++ b/src/link/Tournament.lua @@ -57,7 +57,12 @@ local function levelLabel(v) end local function levelForWire(v) - return v == ANY and nil or v + -- ANY ("use each mon's real level") goes on the wire as nil (no forced + -- level). An explicit guard, not `v == ANY and nil or v`: that idiom's + -- true branch is nil, so it falls through to `or v` and returned the + -- literal "ANY" string, which then crashed math.floor in unpackMon (#204). + if v == ANY then return nil end + return v end local function forceLevelLabel(v) diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index f7685c7d..d711ff3a 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -106,11 +106,55 @@ function Evolution.apply(game, mon, newSpecies, via) }) end +-- After the "evolved into" text, Gen1 re-runs the level-up learn check on +-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the +-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon +-- evolving at exactly a learnset level gains that move (GYARADOS learns +-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12). +-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move +-- list opens the forget prompt. mon.species is already the new species +-- (Evolution.apply ran before the congrats text). onDone runs once the +-- learn list is exhausted, replacing the caller's direct onDone. +function Evolution.learnEvolutionMoves(game, mon, onDone) + local Experience = require("src.battle.Experience") + local def = game.data.pokemon[mon.species] + -- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use + -- Pokemon.movesAtLevel (<= level), which would over-grant older moves. + local moves = Experience.movesLearnedAt(def, mon.level) + local i = 0 + local function nextStep() + i = i + 1 + local moveId = moves[i] + if not moveId then + if onDone then onDone() end + return + end + for _, mv in ipairs(mon.moves) do + if mv.id == moveId then return nextStep() end + end + local mdef = game.data.moves[moveId] + if not mdef then return nextStep() end + local name = mon.nickname or def.name + if #mon.moves < 4 then + table.insert(mon.moves, { id = moveId, pp = mdef.pp }) + Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) + game.stack:push(TextBox.new(game, + ("%s learned\n%s!"):format(name, mdef.name), nextStep)) + else + -- LearnMoveFromLevelUp with a full moveset: the forget UI + Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) + end + end + nextStep() +end + -- Play the evolution movie (flashing forms), then apply + text. -- Headless (no real graphics) falls back to the plain text flow. function Evolution.evolve(game, mon, newSpecies, onDone, via) if love.image and love.image.newImageData then - Screens.push(game, "EvolutionState", mon, newSpecies, onDone) + -- forward `via` so EvolutionState can keep trade evolutions + -- non-cancelable (LINK_STATE_TRADING) while others accept B (#213) + Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via) return end Music.play(game.data, Music.special(game.data, "evolution")) @@ -120,7 +164,9 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via) :format(oldName, oldName, game.data.pokemon[newSpecies].name) game.stack:push(TextBox.new(game, msg, function() Music.restoreMap(game.data) - if onDone then onDone() end + -- re-run the evolved species' level-up learn check before onDone + -- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12) + Evolution.learnEvolutionMoves(game, mon, onDone) end)) end diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index c10fad8f..ebc24532 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -77,7 +77,17 @@ end -- one-pixel sliver. The fill is tinted with the SGB bar palettes at -- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else -- red). -function HudTiles.drawHPBar(data, tx, ty, mon, barType) +-- +-- grayFill (#229): when the caller will colorize this bar with an SGB +-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 = +-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip +-- the per-pixel tint -- the DMG hardware bar is ONE gray shade recolored by +-- the region palette (engine/gfx/palettes.asm SetPal_Battle, +-- data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel repaint. +-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has +-- red channel 0, so the tint zeroes the whole bar's red and the zone's +-- red-channel-keyed shade shader then maps every pixel to color 3 = black. +function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill) local x, y = tx * 8, ty * 8 HudTiles.tile(0x71, x, y) HudTiles.tile(0x62, x + 8, y) @@ -86,15 +96,17 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType) px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp)) end local tint - local PaletteFX = require("src.render.PaletteFX") - local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" - local colors = PaletteFX.pal(data, name) - if colors then - local c = colors[3] -- GB color 2 is the fill shade - -- the fill pixels are the 2/3-gray shade; divide so they land on - -- the palette color exactly (the black outline stays black) - tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170), - math.min(1, c[3] / 170), 1 } + if not grayFill then + local PaletteFX = require("src.render.PaletteFX") + local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" + local colors = PaletteFX.pal(data, name) + if colors then + local c = colors[3] -- GB color 2 is the fill shade + -- the fill pixels are the 2/3-gray shade; divide so they land on + -- the palette color exactly (the black outline stays black) + tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170), + math.min(1, c[3] / 170), 1 } + end end for i = 0, 5 do local seg = math.min(8, math.max(0, px - i * 8)) diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 64da0727..31d13702 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = { } -- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same --- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the --- boot ROM colorizes the background blue instead of red -- so "OG RED" for a --- Blue playthrough is white -> light blue -> dark blue -> black, mirroring --- GBC_BG channel-for-channel so the blue reads at the same brightness. The --- OBJ (sprite) palette stays the same green, matching how Red and Blue share --- the green-character look on a Game Boy Color. +-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot +-- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue +-- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from +-- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table +-- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte +-- hardware capture. The earlier code mirrored GBC_BG channel-for-channel +-- (0x8484FF/0x3A3A94) and reused Red's green sprites for both versions on the +-- premise that Red and Blue "share the green-character look"; both premises +-- are wrong -- Blue's background is a genuinely different blue and its +-- characters are pink (#155). Lightest shade first, like GBC_BG. PaletteFX.GBC_BG_BLUE = { - { 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 }, + { 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 }, +} +-- Blue's OBJ palette (OBP0) is the red/pink ramp -- the very same colors OG +-- RED uses for its BACKGROUND (GBC_BG), just applied to objects instead. +PaletteFX.GBC_OBJ_BLUE = { + { 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 }, } -- The active game's OG boot-ROM background palette: blue for a Blue @@ -69,6 +78,16 @@ function PaletteFX.ogBg() return PaletteFX.GBC_BG end +-- The active game's OG boot-ROM object palette (OBP0): Blue's pink ramp for a +-- Blue playthrough, Red's green otherwise. Returns the colors AND a +-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys +-- its baked-image cache by (image path, group): a shared group would collide a +-- Red bake with a Blue one and one version would show the other's colors. +function PaletteFX.ogObj() + if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end + return PaletteFX.GBC_OBJ, "gbcobj" +end + local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 } function PaletteFX.shader() @@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode) end -- Whether the active mode bakes a per-OBJ palette onto overworld sprites --- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the --- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and --- NPCs stay green over the red background, exactly like Pokemon Red on a --- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no --- palette of its own, so the characters tint with the whole-map region --- palette along with the terrain (the Super Game Boy never colored Pokemon --- Red's sprites separately -- baking a per-sprite palette there was the --- "reds coloring on the player/NPCs" bug). RED++ colors sprites through --- the usesGbcPack() path in SpriteRenderer instead. +-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters +-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red +-- background, pink over Blue's blue background), so the player and NPCs carry a +-- fixed object color instead of tinting with whatever region palette their +-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ +-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it +-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE +-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2 +-- = light-blue and the character clashed with the grass it should blend into). +-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette; +-- only OG RED short-circuits BG to the one global red palette. An EARLIER +-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto +-- characters -- that was the "reds coloring on the player/NPCs" bug; the object +-- palette is GBC_OBJ (green), so baking it here is the fix, not that +-- regression. RED++ colors sprites through the usesGbcPack() path in +-- SpriteRenderer instead. function PaletteFX.usesSpriteObp(mode) - return (mode or PaletteFX.mode) == "ogred" + mode = mode or PaletteFX.mode + return mode == "ogred" or mode == "gbc" end -- ------- post-zone sprite redraw (GBC mode) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 2b9b6dd0..a3a616a7 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -46,18 +46,30 @@ Renderer.UPRIGHT_MARGIN = 160 -- non-square "pixels", and movement judder (issue #87). Always derive the -- crisp integer scale from the drawable pixel size; draw with (pixels/dpi) -- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged. +-- +-- `dpi` here must be the factor LOVE actually applies to every draw call +-- (getDPIScale), NOT the drawable/unit size ratio pw/ww. On a normal device +-- those are equal (getPixelDimensions == getDimensions * getDPIScale), but on +-- the AYN Thor dual-screen surface in forced landscape they diverge: LOVE +-- reports pw/ww ≈ 1 while its real transform is 1.5, so scaling by pw/ww lands +-- each GB pixel on Sp*(getDPIScale/(pw/ww)) physical pixels -- a fractional, +-- stretched/non-square count (issue #208). Prefer getDPIScale so draws land +-- on whole physical pixels through LOVE's actual transform; fall back to pw/ww +-- (then 1) only when getDPIScale is unavailable. Since getDPIScale == pw/ww +-- on every normal device, #87's behavior is byte-identical there. local function displayMetrics() local ww, wh = love.graphics.getDimensions() local pw, ph = ww, wh if love.graphics.getPixelDimensions then pw, ph = love.graphics.getPixelDimensions() end - local dpi = 1 - if ww > 0 and pw > 0 then - dpi = pw / ww - elseif love.graphics.getDPIScale then + local dpi + if love.graphics.getDPIScale then dpi = love.graphics.getDPIScale() end + if (not dpi or dpi < 1e-6) and ww > 0 and pw > 0 then + dpi = pw / ww + end if not dpi or dpi < 1e-6 then dpi = 1 end return ww, wh, pw, ph, dpi end @@ -99,6 +111,18 @@ function Renderer:fitScale() return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT))) end +-- The LOVE-unit draw scale endFrame uses for the UI blit: the integer +-- framebuffer scale (fitScale) divided by the live coordinate->pixel factor, +-- so a GB pixel lands on fitScale() whole PHYSICAL pixels once LOVE applies +-- its own transform (fitScale() == drawScale() * dpi). Exposed so #208's +-- regression can assert GB pixels stay square on divergent-DPI surfaces +-- without reaching into endFrame's locals; endFrame recomputes the same value +-- inline (`S = Sp / dpi`). +function Renderer:drawScale() + local _, _, _, _, dpi = displayMetrics() + return self:fitScale() / dpi +end + -- world-pass canvas size in world pixels: enough to fill the window at s'. -- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so -- the projected ground plane still covers the whole window with no diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 09f43bfb..6f30b6f8 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage() local colors, group = PaletteFX.spriteObp(self.def, self.seed) if colors then return getObpImage(self.def.image, colors, group) end elseif PaletteFX.usesSpriteObp() then - return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj") + -- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj + -- returns colors + a version-distinct cache group so the two never + -- collide in obpCache) -- see issue #155 + return getObpImage(self.def.image, PaletteFX.ogObj()) end return self.image end @@ -141,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) image = getObpImage(self.def.image, colors, group) end elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then - -- OG RED (GBC boot-ROM look): every OBJ wears the one global green - -- object palette. The red BG zone shader still runs over the world - -- canvas, so the baked sprite is queued for a post-zone redraw - -- (PaletteFX.markSpriteRedraw) that restores its green pixels on top. - image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj") + -- OG RED (GBC boot-ROM look): every OBJ wears the one global object + -- palette -- green over Red's red background, pink over Blue's blue + -- background (PaletteFX.ogObj, #155). The BG zone shader still runs over + -- the world canvas, so the baked sprite is queued for a post-zone redraw + -- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on + -- top. + image = getObpImage(self.def.image, PaletteFX.ogObj()) redraw = true end -- single-frame sprites (item balls, fossils...) have one fixed pose; diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index c125669e..7f609881 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -214,11 +214,10 @@ local function useOn(game, battle, id, target, list, moveIndex) and ow.map.id ~= "AGATHAS_ROOM" then list:close() consume(game, id) - require("src.core.Sound").play(game.data, "Teleport_Exit1") - ow.player.surfing = false - -- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path); - -- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim) - ow:warpToHealPoint(nil, { arrive = "teleport" }) + -- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE + -- the last Pokémon Center town door like Fly (#196), via the shared + -- departure helper -- the same path Dig/Teleport take from the party menu + ow:beginTeleportOut() else showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" }) @@ -303,7 +302,8 @@ local function pickTargetAndUse(game, battle, id, list) -- the ETHERs and PP UP open the move menu after picking a mon -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP" - require("src.ui.Screens").push(game, "PartyMenu", { + local def = game.data.items[id] + local opts = { pickOnly = true, onSwitch = function(mon) if not wantsMove then @@ -326,7 +326,17 @@ local function pickTargetAndUse(game, battle, id, list) end, })) end, - }) + } + -- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon + -- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on + -- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM -> + -- party_menu.asm TM/HM type). Stones and other pickOnly items keep the + -- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate + -- strictly on def.machine. #210 + if def and def.machine then + opts.tmhm = { move = def.machine.move, kind = def.machine.kind } + end + require("src.ui.Screens").push(game, "PartyMenu", opts) end local function useItem(game, battle, id, list) diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 6d3e21a1..111a50ab 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -1,8 +1,11 @@ -- The evolution movie (engine/movie/evolution.asm): the mon's pic -- flashes back and forth with the evolved form, speeding up, then the -- new form appears with its cry and the congratulations text. --- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1 --- has no cancel; the flash always completes). +-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during +-- the flash: holding B aborts the evolution -- the mon keeps its species +-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The +-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING), +-- which skip that poll and cannot be cancelled (#213). local Font = require("src.render.Font") local Music = require("src.core.Music") @@ -14,7 +17,10 @@ EvolutionState.isOpaque = true -- SGB: SetPal_PokemonWholeScreen for the mon on display function EvolutionState:sgbPalettes(game) local P = require("src.render.PaletteFX") - local species = self.done and self.newSpecies or self.mon.species + -- a cancelled evolution keeps the old species (never applied), so only + -- colorize with the new form once it has actually evolved + local species = (self.done and not self.canceled) and self.newSpecies + or self.mon.species local c = P.monPal(game.data, species) if c then return { P.whole(c) } end return P.wholeNamed(game.data, "MEWMON") @@ -29,17 +35,22 @@ local function frontSprite(game, species) return ok and img or nil end -function EvolutionState.new(game, mon, newSpecies, onDone) +function EvolutionState.new(game, mon, newSpecies, onDone, via) local self = setmetatable({}, EvolutionState) self.game = game self.mon = mon self.newSpecies = newSpecies self.onDone = onDone + self.via = via + -- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the + -- B-cancel poll; level-up, stone and rare-candy evos are all cancelable. + self.cancelable = (via ~= "TRADE") self.oldName = mon.nickname or game.data.pokemon[mon.species].name self.oldSprite = frontSprite(game, mon.species) self.newSprite = frontSprite(game, newSpecies) self.t = 0 self.done = false + self.canceled = false Music.play(game.data, Music.special(game.data, "evolution")) return self end @@ -47,11 +58,28 @@ end function EvolutionState:update(dt) self.t = self.t + 1 if self.done then return end + local game = self.game + -- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for + -- a cancelable evolution, aborts when B is held -- the mon keeps its + -- species (Evolution.apply never runs) and _StoppedEvolvingText prints. + if self.cancelable and game.input:isDown("b") then + self.done = true + self.canceled = true + local TextBox = require("src.render.TextBox") + -- mirrors data/generated/text.lua _StoppedEvolvingText + game.stack:push(TextBox.new(game, + ("Huh? %s\nstopped evolving!"):format(self.oldName), + function() + Music.restoreMap(game.data) + game.stack:pop() -- the evolution screen itself + if self.onDone then self.onDone() end + end)) + return + end if self.t >= FLASH_FRAMES then self.done = true - local game = self.game local Evolution = require("src.pokemon.Evolution") - Evolution.apply(game, self.mon, self.newSpecies) + Evolution.apply(game, self.mon, self.newSpecies, self.via) require("src.core.Sound").playCry(game.data, self.newSpecies) local TextBox = require("src.render.TextBox") local newName = game.data.pokemon[self.newSpecies].name @@ -61,7 +89,12 @@ function EvolutionState:update(dt) function() Music.restoreMap(game.data) game.stack:pop() -- the evolution screen itself - if self.onDone then self.onDone() end + -- Gen1 re-runs the level-up learn check on the evolved species + -- after the "evolved into" text (evos_moves.asm EvolveMon -> + -- learn_move.asm LearnMoveFromLevelUp, #12). Pop the evo screen + -- first so the "learned MOVE!" text / forget prompt push onto the + -- overworld / battle-return, not this state. + Evolution.learnEvolutionMoves(game, self.mon, self.onDone) end)) end end @@ -73,7 +106,8 @@ function EvolutionState:draw() -- accelerating flash between the two forms local sprite if self.done then - sprite = self.newSprite + -- a cancelled evolution settles back on the original form + sprite = self.canceled and self.oldSprite or self.newSprite else local period = math.max(4, 28 - math.floor(self.t / 40) * 6) local showNew = math.floor(self.t / period) % 2 == 1 diff --git a/src/ui/FlyMenu.lua b/src/ui/FlyMenu.lua index 632a8c2b..3e512bb3 100644 --- a/src/ui/FlyMenu.lua +++ b/src/ui/FlyMenu.lua @@ -11,9 +11,13 @@ function FlyMenu.new(game) local visited = game.save.visited or {} local seen = {} for _, mapId in ipairs(game.data.field.flyOrder or {}) do - -- towns only (dungeon escape spots share the table), each listed once + -- towns only (dungeon escape spots share the table), each listed once. + -- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow + -- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape + -- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203). local def = game.data.maps[mapId] - if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then + if visited[mapId] and def and not seen[mapId] + and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then seen[mapId] = true table.insert(items, { value = mapId, diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 474fbdb7..c5ff5e7c 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -132,6 +132,10 @@ function PartyMenu.new(game, opts) self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel self.pickOnly = opts.pickOnly + -- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's + -- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the + -- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210 + self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle self.party = opts.party -- link battles pass their clamped copies @@ -178,8 +182,15 @@ function PartyMenu:update(dt) elseif action == "switch" then self.swapFrom = self.index elseif action == "fly" then + -- FLY opens the TOWN MAP with a cursor over the visited fly towns, + -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). + -- flyTo (OverworldController) validates the fly-warp + runs the + -- departure/warp, so we just hand it the chosen mapId (#195). + local ow = self.game.overworld self.game.stack:pop() -- close the party menu - Screens.push(self.game, "FlyMenu") + Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) + if ow then ow:flyTo(mapId) end + end }) return elseif action == "flash" then -- FLASH lights dark tunnels -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then @@ -304,21 +315,15 @@ function PartyMenu:update(dt) -- 1/5 of the user's max HP to a chosen teammate self.softboiledFrom = self.index elseif action == "escape" then - -- DIG / TELEPORT both warp to the last Pokémon Center town - -- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport - -- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap + -- DIG / TELEPORT warp to the last Pokémon Center TOWN (wLastBlackoutMap, + -- special_warps.asm escape warp). pokered's .dig/.teleport spin the + -- player up (LeaveMapAnim), white/fade out, then land it; this port + -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut + -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares + -- the exact departure; the fade + warp fire when the spin ends. local ow = self.game.overworld - local heal = self.game.save.lastHeal - local Transition = require("src.render.Transition") self.game.stack:pop() - if ow and heal then - self.game.stack:push(Transition.whiteFlash(self.game, nil, function() - require("src.core.Sound").play(self.game.data, "Teleport_Exit1") - -- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets - -- BIT_FLY_WARP); blackouts must not pass arrive="teleport" - ow:warpToHealPoint(nil, { arrive = "teleport" }) - end)) - end + if ow then ow:beginTeleportOut() end return end self.submenu = nil @@ -436,6 +441,30 @@ function PartyMenu:update(dt) end end +-- The bottom-of-screen context message for the current menu state +-- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_): +-- the party menu always prints a message in the bottom text box. With the +-- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?") +-- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap / +-- item / TM-HM ids print their own strings, which draw() handles inline. +-- Pure (no side effects) so drivers can assert it. #147 +function PartyMenu:bottomMessage() + if self.swapFrom then + return "Move to where?" + elseif self.softboiledFrom or self.pickOnly then + return "Use on which one?" + elseif self.tmhm then + return self.game.data.text._PartyMenuUseTMText + or "Use TM on which\nPOKéMON?" + elseif self.battle then + return self.game.data.text._PartyMenuBattleText + or "Bring out which\nPOKéMON?" + else + return self.game.data.text._PartyMenuNormalText + or "Choose a POKéMON." + end +end + function PartyMenu:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) @@ -462,16 +491,34 @@ function PartyMenu:draw() -- PrintLevel overwrites the tile with the third digit Font.draw(tostring(mon.level), 104, y) end - if mon.hp <= 0 then - Font.draw("FNT", 136, y) - elseif mon.status then - Font.draw(mon.status, 136, y) + if self.tmhm then + -- TM/HM teaching menu (engine/menus/party_menu.asm PrintPartyMenu): + -- the second row shows the inline "ABLE" / "NOT ABLE" learnability + -- strings in place of the HP bar and status, decided by CanLearnTM. + -- The learnset scan mirrors ItemEffects.use so the display can never + -- disagree with the actual teach. #210 + local can = false + for _, m in ipairs(def.tmhm or {}) do + if m == self.tmhm.move then can = true break end + end + -- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge + if can then + Font.draw("ABLE", 120, y + 8) + else + Font.draw("NOT ABLE", 88, y + 8) + end + else + if mon.hp <= 0 then + Font.draw("FNT", 136, y) + elseif mon.status then + Font.draw(mon.status, 136, y) + end + -- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor) + love.graphics.setColor(1, 1, 1, 1) + HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8) end - -- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor) - love.graphics.setColor(1, 1, 1, 1) - HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon) - love.graphics.setColor(0, 0, 0, 1) - Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8) if i == self.index then Font.drawCode(Theme.cursor, 0, y) end @@ -483,8 +530,34 @@ function PartyMenu:draw() Font.draw("Move to where?", 8, 136) elseif self.softboiledFrom then Font.draw("Use on which one?", 8, 136) + elseif self.tmhm then + -- "Use TM on which\nPOKeMON?" in the standard bottom text box + -- (party_menu.asm keeps the message box for the TM/HM menu); box + line + -- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210 + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + local prompt = self.game.data.text._PartyMenuUseTMText + or "Use TM on which\nPOKéMON?" + local ly = 112 + for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do + Font.draw(line, 8, ly) + ly = ly + 16 + end elseif self.pickOnly then Font.draw("Use on which one?", 8, 136) + else + -- default field party menu (StartMenu) and the battle voluntary-switch + -- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText + -- in the standard bottom text box (party_menu.asm PartyMenuMessage), not + -- plain bottom-row text. Box + line geometry match the #210 TM/HM case and + -- TextBox's default (rows 12-17, text on rows 14/16). #147 + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + local ly = 112 + for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do + Font.draw(line, 8, ly) + ly = ly + 16 + end end if self.submenu then local n = #self.subItems diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 65b93fa0..39814e18 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -106,8 +106,11 @@ local function sell(game) onChoose = function(item) local def = game.data.items[item.value] -- only key items and HMs are unsellable (pokemart.asm IsKeyItem / - -- IsItemHM); zero-price items like ETHER sell for ¥0 - if (def and def.keyItem) or item.value:find("^HM_") then + -- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id + -- (nil def) has no price, so treat it as unsellable too rather than + -- indexing nil below -- guards saves that already picked up a bogus + -- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11). + if not def or def.keyItem or item.value:find("^HM_") then list.footer = txt(game, "_PokemartUnsellableItemText", "I can't put a\nprice on that.") return diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index ddba6e69..bb993650 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -5,6 +5,13 @@ -- A on page 2) closes. local Font = require("src.render.Font") +-- status_screen.asm PrintMonType prints the type's DISPLAY name from the +-- TypeNames table, not the constant: species types are stored as pokered +-- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it +-- won't collide with the PSYCHIC move), which would overflow the TYPE field. +-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the +-- battle move-type box already do (#214). +local TypeChart = require("src.battle.TypeChart") local SummaryMenu = {} SummaryMenu.__index = SummaryMenu @@ -99,10 +106,10 @@ function SummaryMenu:draw() -- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10) drawLineBox(19, 9, 8, 6) Font.draw("TYPE1/", 80, 72) - Font.draw(def.types[1] or "", 88, 80) + Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80) if def.types[2] then Font.draw("TYPE2/", 80, 88) - Font.draw(def.types[2], 88, 96) + Font.draw(TypeChart.displayName(def.types[2]), 88, 96) end Font.draw("IDNo/", 80, 104) -- the trainer ID is rolled at new game (SaveData.newGame) and diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 3d78be58..59048c56 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -7,6 +7,11 @@ -- the selected name in a banner up top, and the player's current -- location blinking. List mode (townMap data missing): up/down through -- an ordered list of fly towns instead. B closes. +-- +-- Fly mode (opts.fly + opts.onFly, LoadTownMap_Fly): the same Kanto map, +-- but the cursor cycles ONLY the visited fly destinations (Up/Down, in fly +-- order), the banner reads "To ", and A calls onFly(mapId) to depart. +-- This is what the party-menu FLY field move opens (#195). local Font = require("src.render.Font") local Sound = require("src.core.Sound") @@ -80,7 +85,10 @@ local function buildLocations(game) local seen = {} for _, mapId in ipairs(field.flyOrder or {}) do local def = game.data.maps and game.data.maps[mapId] - if not seen[mapId] and def and Map.isOutdoor(def) then + -- accept the PLATEAU tileset too so Indigo Plateau shows on the + -- stale-asset list fallback, matching the fly-list filter (#203) + if not seen[mapId] and def + and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then seen[mapId] = true local loc = { name = mapId:gsub("_", " ") } table.insert(locs, loc) @@ -119,6 +127,42 @@ local function markerXY(loc) return loc.x * 8 + 16, loc.y * 8 + 8 end +-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly +-- (engine/menus/town_map.asm prints the destination as "To ") +function TownMap:bannerText(loc) + return (self.fly and "To " or "") .. loc.name +end + +-- Fly mode selection set (engine/menus/town_map.asm LoadTownMap_Fly): the +-- cursor cycles ONLY the visited fly destinations, in fly order, each landing +-- on its town square. Built from field.flyOrder filtered to visited outdoor +-- towns that have a fly-warp spot, deduped, reusing the grid loc so the cursor +-- lands on the town and its name shows in the banner. +local function buildFlyList(game, byMap) + local field = game.data.field or {} + local visited = game.save.visited or {} + local flyWarps = field.flyWarps or {} + local Map = require("src.world.Map") + local flyLocs, flyMapIds, seen = {}, {}, {} + for _, mapId in ipairs(field.flyOrder or {}) do + local def = game.data.maps and game.data.maps[mapId] + -- INDIGO_PLATEAU is a normal Fly spot (engine/menus/town_map.asm + -- LoadTownMap_Fly cycles it like any town), but its map uses tileset + -- "PLATEAU" not OVERWORLD, so Map.isOutdoor() alone dropped it from the + -- cursor even though it is visited and has a fly warp. Allow PLATEAU here + -- while the CAVERN/FACILITY dungeon escape spots that share flyOrder still + -- fail the gate and stay out (#203). + if not seen[mapId] and visited[mapId] and flyWarps[mapId] + and def and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then + seen[mapId] = true + local loc = byMap[mapId] or { name = mapId:gsub("_", " ") } + table.insert(flyLocs, loc) + flyMapIds[#flyLocs] = mapId + end + end + return flyLocs, flyMapIds +end + -- opts.nestSpecies: the Pokédex AREA screen (LoadTownMap_Nest) -- -- blink a nest icon on every map whose wild slots hold the species function TownMap.new(game, opts) @@ -152,6 +196,26 @@ function TownMap.new(game, opts) or "assets/generated/townmap/nest.png") self.nestIcon = ok and img or nil end + if opts.fly then + -- FLY picker (LoadTownMap_Fly): restrict the selectable set to the + -- visited fly towns so Up/Down cycle only those and A knows the mapId. + local flyLocs, flyMapIds = buildFlyList(game, self.byMap) + if #flyLocs > 0 then + self.fly = true + self.onFly = opts.onFly + self.locs = flyLocs + self.flyMapIds = flyMapIds + -- grid rendering needs coords on every entry; without them fall back to + -- the name list so the fly screen still works on stale asset builds + if self.mode == "grid" then + for _, loc in ipairs(flyLocs) do + if not (loc.x and loc.y) then self.mode = "list" break end + end + end + end + -- with nothing visited yet there is nowhere to fly: leave self.fly unset + -- so the screen degrades to a plain viewer (B closes) + end -- the player's current location (guard: overworld may not be running) local mapId = game.overworld and game.overworld.map and game.overworld.map.id self.playerLoc = mapId and self.byMap[mapId] or nil @@ -199,7 +263,19 @@ function TownMap:update(dt) self.game.stack:pop() return end - if self.nestSpecies then + if self.fly then + -- LoadTownMap_Fly: Up/Down cycle the visited destinations, A flies there, + -- B cancels (handled above). moveList walks self.locs, now the fly list. + if input:wasPressed("a") then + Sound.play(self.game.data, "Press_AB") + local mapId = self.flyMapIds[self.sel] + self.game.stack:pop() + if mapId and self.onFly then self.onFly(mapId) end + return + elseif input:wasPressed("up") then self:moveList(-1) + elseif input:wasPressed("down") then self:moveList(1) + end + elseif self.nestSpecies then if input:wasPressed("a") then Sound.play(self.game.data, "Press_AB") self.game.stack:pop() @@ -260,18 +336,28 @@ function TownMap:draw() love.graphics.setColor(1, 1, 1, 1) return end - -- the player's current location blinks (slow phase) + -- the player's current location blinks (slow phase). Paint it with a + -- palette-safe DARK shade (red 0), not red: this screen composites through + -- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on + -- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP + -- {165,214,255}, the exact light-blue used for the water and the town-square + -- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3 + -- {25,16,16} = a solid dark "you are here" dot, visible on land and water. if self.playerLoc and self.blink < 20 then local x, y = markerXY(self.playerLoc) - love.graphics.setColor(0.75, 0.1, 0.1, 1) + love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", x + 2, y + 2, 4, 4) love.graphics.setColor(1, 1, 1, 1) end - -- blinking cursor on the selected location + -- blinking cursor on the selected location. markerXY is the 8x8 cell's + -- top-left; the cursor asset is a 16x16 hollow frame centered on its own + -- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm + -- draws the box cursor CENTERED on the selected location). Drawing it at + -- the cell top-left put the square in the frame's top-left quadrant (#152). if selected and self.blink % 16 < 10 then local x, y = markerXY(selected) if self.bg.cursor then - love.graphics.draw(self.bg.cursor, x, y) + love.graphics.draw(self.bg.cursor, x - 4, y - 4) else love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7) @@ -281,7 +367,7 @@ function TownMap:draw() -- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name) love.graphics.rectangle("fill", 0, 0, 160, 8) love.graphics.setColor(0, 0, 0, 1) - if selected then Font.draw(selected.name, 8, 0) end + if selected then Font.draw(self:bannerText(selected), 8, 0) end love.graphics.setColor(1, 1, 1, 1) return end @@ -294,7 +380,9 @@ function TownMap:draw() drawSquare(loc) end if self.playerLoc and self.blink < 20 then - love.graphics.setColor(0.75, 0.1, 0.1, 1) + -- palette-safe dark, same red-channel shade-remap reason as the primary + -- grid path above (#152); stale-asset builds hit this fallback square + love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2, self.playerLoc.y * 8 + 2, 4, 4) end @@ -317,7 +405,10 @@ function TownMap:draw() end Font.draw(loc.name, 24, y) if loc == self.playerLoc and self.blink < 20 then - -- blinking marker on the player's current town + -- blinking marker on the player's current town; force the palette-safe + -- dark shade explicitly so the red-channel shade-remap keeps it + -- visible regardless of Font.draw's leftover color (#152) + love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4) end end @@ -327,7 +418,7 @@ function TownMap:draw() -- name banner across the top Font.drawBox(0, 0, 20, 3) love.graphics.setColor(0, 0, 0, 1) - if selected then Font.draw(selected.name, 8, 8) end + if selected then Font.draw(self:bannerText(selected), 8, 8) end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/world/Map.lua b/src/world/Map.lua index 2b966af4..14b25b2c 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -204,6 +204,16 @@ function Map:isWalkableCell(cx, cy) end function Map:isGrassCell(cx, cy) + -- Off-map cells never count as tall grass (issue #217). cellTile + -- border-extends out-of-bounds coordinates with the map's borderBlock, + -- and some border blocks (e.g. ROUTE_1's block 11) have the grass tile + -- ($52 = 82) in their bottom row -- filler scenery, never standable + -- grass. During a map-connection seam step crossConnection parks the + -- player one cell before the entry point (cellY = -1 crossing Viridian + -- City -> Route 1), so without this guard the feet-overdraw painted an + -- animated grass tuft over the player's head for the whole step. pokered + -- only ever reads $52 from loaded map tiles, not the border filler. + if not self:inBounds(cx, cy) then return false end local grass = self.tileset.grassTile return grass ~= nil and self:cellTile(cx, cy) == grass end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d3624c77..061089da 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -222,17 +222,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.map.renderer:rebuild() self.cutBlocks[mapId] = nil end - -- Silph Co card key doors: the .blk layouts ship with the doorways - -- open; each floor's map script stamps the closed door block on load - -- until its unlock event is set (scripts/SilphCo2F.asm - -- SilphCo2FGateCallbackScript et al., closed blocks $54/$5f/$20) + -- Silph Co card key doors + Rocket Hideout elevator gates: the .blk + -- layouts ship with the doorways open; each floor's map script stamps + -- the closed door block on load until its unlock event is set + -- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed + -- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm + + -- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over + -- the lift doorway). A door opens on its single `event`, or on `events` + -- when every listed flag must be set (Rocket Hideout B4F's lift gate + -- needs both guard trainers beaten -- CheckBothEventsSet). local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors", "closedDoors") local floorDoors = closedDoors and closedDoors[mapId] if floorDoors then local stamped = false for _, door in ipairs(floorDoors) do - local want = Game.save.flags[door.event] and door.open or door.block + local open + if door.events then + open = true + for _, ev in ipairs(door.events) do + if not Game.save.flags[ev] then open = false break end + end + else + open = Game.save.flags[door.event] + end + local want = open and door.open or door.block if self.map:blockAt(door.bx, door.by) ~= want then self.map:setBlock(door.bx, door.by, want) stamped = true @@ -749,6 +763,27 @@ function OverworldState:update(dt) end end + -- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite + -- spins UP out of the map before the fade (player_animations.asm + -- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the + -- mirror of Fly's flyAnim lead-in above. Only when the spin finishes does + -- warpToHealPoint push the fade + warp, so the arrival spin-down lands the + -- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames + -- decrements in lockstep in Player:update, so the rising spin ends here too. + if self.teleportOut then + self.teleportOut.frames = self.teleportOut.frames - 1 + if self.teleportOut.frames <= 0 then + local onDone = self.teleportOut.onDone + self.teleportOut = nil + self.player.spinning = false + self.player.spinFrames = nil + self.player.spinRise = nil + self.player.inputLocked = false + self:warpToHealPoint(onDone, { arrive = "teleport" }) + return + end + end + -- delayed one-shot SFX (the teleport-in spin's second note) if self.delaySfx then self.delaySfx.frames = self.delaySfx.frames - 1 @@ -787,7 +822,7 @@ function OverworldState:update(dt) -- escort then walks an extra tile before PlayerEntryMovementRLE, and -- the player lands on desk Oak. local scripted = self.runner:isRunning() or #self.scriptMoves > 0 - or self.engaging or self.emote + or self.engaging or self.emote or self.teleportOut if not scripted and not self.transitioning then self:checkTrainerSight() -- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and @@ -795,7 +830,7 @@ function OverworldState:update(dt) -- direction handling (JoypadOverworld runs the map script first) -- -- the player can never start another step after being spotted. scripted = self.runner:isRunning() or #self.scriptMoves > 0 - or self.engaging or self.emote + or self.engaging or self.emote or self.teleportOut end if not scripted and not self.transitioning then self:handleInput() @@ -854,6 +889,21 @@ function OverworldState:dirHeld() or input:isDown("left") or input:isDown("right") end +-- The warp cell the player WARPED IN on is inert until they physically step +-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a +-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where +-- warpEntryCell/justWarped are set and onStepComplete where they clear). +-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the +-- blocked-step collision warp (handleInput) -- must consult this, or a corner +-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces +-- floors every input frame (issue #230). +function OverworldState:onWarpArrivalCell() + if self.justWarped then return true end + local entry = self.warpEntryCell + return entry ~= nil and self.player.cellX == entry.x + and self.player.cellY == entry.y +end + function OverworldState:handleInput() local input = Game.input @@ -875,10 +925,11 @@ function OverworldState:handleInput() if self:checkBoulderPush(dir) then return end end local result, why = self.player:tryMove(dir, self.map, self.entities) - if result == "blocked" then - -- a collision while standing on a warp square fires the warp - -- when the extra check passes (CheckWarpsCollision: route-gate - -- doorways, dock entrances, ...) + -- a collision while standing on a warp square fires the warp when the + -- extra check passes (CheckWarpsCollision: route-gate doorways, dock + -- entrances, ...) -- but never on the inert cell we just warped in on + -- (issue #230), which the completed-step path guards the same way. + if result == "blocked" and not self:onWarpArrivalCell() then local w = Warp.onCollision(self.map, Game.data.field.warpCarpets, self.player.cellX, self.player.cellY, dir) if w then @@ -1009,6 +1060,11 @@ function OverworldState:checkEdgeExit(dir) local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir) if w then + -- ...but not while still standing on the warp cell we just arrived on + -- (issue #230): fall through so pushing into the edge bonks (SFX + + -- walk-in-place) instead of instantly re-warping. A real step onto an + -- exit-carpet edge cleared warpEntryCell first, so those still fire. + if self:onWarpArrivalCell() then return false end self:takeWarp(w.def) return true end @@ -1256,6 +1312,37 @@ function OverworldState:flyTo(mapId) self.flyDest = { map = mapId, x = spot.x, y = spot.y } end +-- Dig / Teleport / Escape Rope departure animation, then land OUTSIDE the +-- last Pokemon Center door like Fly (#196). pokered's _LeaveMapAnim +-- (engine/overworld/player_animations.asm) plays SFX_TELEPORT_EXIT_1 and +-- spins the player while it rises up off the map (PlayerSpinWhileMovingUp) +-- before the palettes fade; Fly's bird lead-in (flyTo/flyAnim) is the +-- analogous departure this mirrors. When the spin finishes (the teleportOut +-- countdown in OverworldState:update), warpToHealPoint pushes the fade + warp +-- with arrive="teleport" so the sprite spins back DOWN in front of the town +-- PC door. Shared by the party-menu DIG/TELEPORT action and BagMenu's +-- ESCAPE ROPE so all three animate identically. +function OverworldState:beginTeleportOut(onDone) + if not Game.save.lastHeal then + -- a save that has never visited a Pokemon Center has no heal point to + -- warp to; skip the animation entirely (matches the old guard that did + -- nothing when lastHeal was absent) instead of spinning into a nil warp + if onDone then onDone() end + return + end + require("src.core.Sound").play(Game.data, "Teleport_Exit1") + self.player.surfing = false + self.player.inputLocked = true + -- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so + -- spinRise lifts the sprite (Player:pose) while spinFrames counts down + self.player.spinning = true + self.player.spinTimer = 0 + self.player.spinFrames = 48 + self.player.spinTotal = 48 + self.player.spinRise = true + self.teleportOut = { frames = 48, onDone = onDone } +end + function OverworldState:npcAtCell(cx, cy) for _, npc in ipairs(self.npcs) do if (npc.cellX == cx and npc.cellY == cy) or @@ -1474,7 +1561,17 @@ function OverworldState:tryHiddenObject(fx, fy) -- Pokémon Center PCs and other PC tiles for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then - self:openPC() + if self.map.id == "REDS_HOUSE_2F" then + -- The player's bedroom PC is the one location in Red/Blue whose PC + -- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm), + -- which runs the PlayerPC predef directly -- item storage, no + -- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other + -- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228) + require("src.core.Sound").play(Game.data, "Turn_On_PC") + Screens.push(Game, "PlayerPC") + else + self:openPC() + end return true end end @@ -2007,8 +2104,12 @@ function OverworldState:talkTo(npc) return end - -- item balls (object_event item argument) - if d.item then + -- item balls (object_event item argument). A payload id of "0" is + -- pokered's ITEM_NONE sentinel: the ROM object sets the 0x80 "has item" + -- bit but names item 0, so it is a plain text object, not an item ball + -- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats + -- the string "0" as truthy, so screen it out and fall through to text. + if d.item and d.item ~= "0" and d.item ~= 0 then if not require("src.inventory.Bag").add(Game.save, d.item, 1) then Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!")) return @@ -3257,11 +3358,32 @@ function OverworldState:warpToHealPoint(onDone, opts) -- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear -- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm) Game.save.forcedBike = nil - if opts and opts.arrive == "teleport" then + local map, x, y = heal.map, heal.x, heal.y + local teleport = opts and opts.arrive == "teleport" + if teleport then self.arriveWarp = "teleport" + -- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN + -- door, like Fly (#196) -- NOT the interior heal cell a blackout returns + -- to. pret routes escape-warp and blackout both through wLastBlackoutMap + -- (both appear inside in front of the nurse), but this port has decided + -- the escape-warp destination is the town PC door. Prefer the canonical + -- Fly landing (field.flyWarps, one tile south of the PC door warp), else + -- the remembered outdoor door cell; fall back to the interior heal cell + -- only for an old save with no recorded outdoor. + local out = heal.outdoor + if out then + local fw = (Game.data.field.flyWarps or {})[out.id] + map = out.id + x = fw and fw.x or out.x + y = fw and fw.y or out.y + end end - self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone) - if heal.outdoor then + self:startWarpTo(map, x, y, "down", onDone) + -- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at + -- the remembered town door. The teleport branch already lands ON that + -- outdoor map, so startWarpTo remembers it on the next exit; re-pointing + -- here would wrongly steer exits away from where the player now stands. + if heal.outdoor and not teleport then self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y) end end diff --git a/src/world/Player.lua b/src/world/Player.lua index b6730ffb..6a76b378 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -61,16 +61,26 @@ function Player:tryMove(dir, map, entities) if self.facing ~= dir then self.facing = dir self.turnTimer = self.turnFrames or TURN_FRAMES + self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle return "turned" end if self.turnTimer > 0 then return nil end local ok, why = Collision.canMove(map, entities, self, dir) if not ok then + -- Gen1: a blocked step still animates the player walking in place -- + -- the collision path spends the step's worth of frames running + -- UpdateSprites before returning control, so the legs cycle without + -- the cell changing (home/overworld.asm collision handling; issue + -- #230). Re-armed every frame the direction is held into the wall; + -- Player:update ticks the walk clock while it counts down, so releasing + -- returns to the standing pose within a step's length. + self.bumpFrames = self.stepFrames or STEP_FRAMES return "blocked", why end local tx, ty = Collision.target(self.cellX, self.cellY, dir) self.targetX, self.targetY = tx, ty self.moving = true + self.bumpFrames = nil -- a real step supersedes any in-place bonk self.progress = 0 -- the bicycle doubles walking speed (8 frames per step) local save = require("src.core.Game").save @@ -97,9 +107,19 @@ function Player:update() if self.spinFrames <= 0 then self.spinFrames = nil self.spinDrop = nil + self.spinRise = nil -- teleport-out departure lift (#196) self.spinning = false end end + -- wall-bonk walk-in-place (issue #230): while pushing into a wall the + -- collision path keeps the walk clock running without moving the cell, + -- so the sprite animates against the wall. Guarded on not-moving so a + -- real step (which clears bumpFrames and advances animClock itself + -- below) can never double-tick the leg cadence. + if not self.moving and self.bumpFrames and self.bumpFrames > 0 then + self.bumpFrames = self.bumpFrames - 1 + self.animClock = (self.animClock or 0) + 1 + end if not self.moving then return false end local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES self.progress = self.progress + 1 @@ -132,7 +152,12 @@ function Player:facingCell() end function Player:walkPhase() - if not self.moving and not self.stepLanded then return 0 end + -- moving, the land-frame after a completed step, or an active wall-bonk + -- (issue #230) animate; a standing sprite otherwise + if not self.moving and not self.stepLanded + and not (self.bumpFrames and self.bumpFrames > 0) then + return 0 + end -- walk frame during the middle of each 16-frame animation cycle local p = (self.animClock or self.progress) % 16 return (p >= 4 and p < 12) and 1 or 0 @@ -183,6 +208,13 @@ function Player:pose() -- (EnterMapAnim PlayerSpinWhileMovingDown) if self.spinFrames and self.spinDrop then py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64)) + elseif self.spinFrames and self.spinRise then + -- Dig/Teleport/Escape-Rope departures spin the sprite UP out of the + -- map before the fade (LeaveMapAnim PlayerSpinWhileMovingUp) -- the + -- mirror of the arrival spin-down: the lift grows from 0 as spinFrames + -- counts down to 0 (#196), opposite sign to spinDrop above. + local total = self.spinTotal or 64 + py = py - math.floor((total - self.spinFrames) * 24 / total) end end local sprite = (self.surfing and self.surfSprite) diff --git a/tests/drivers/battle_boosted_exp_bug216_test.lua b/tests/drivers/battle_boosted_exp_bug216_test.lua new file mode 100644 index 00000000..e128d11b --- /dev/null +++ b/tests/drivers/battle_boosted_exp_bug216_test.lua @@ -0,0 +1,173 @@ +-- Driver: reproduce #216 - "Boosted EXP text cut off". +-- +-- A traded mon's EXP gain prints a 3-line message: +-- " gained\na boosted\v EXP. Points!" +-- (engine/battle/experience.asm _GainedText/_BoostedText/_ExpPointsText; +-- _BoostedText ends in the CONT code \v = char 11, "a boosted\011" in the +-- extracted data/generated/text.lua). The in-battle message box only has +-- room for two lines (rows y=112 and y=128), so before the fix the third +-- line was drawn at y=144 -- one row past the 160x144 screen -- and was +-- never seen: a single A press dismissed the whole message and the wild win +-- popped straight back to the overworld, so the boosted amount was invisible. +-- +-- Gen1-correct behavior (home/text.asm ContText): the box scrolls a 2-line +-- window, waiting for A/B on the \v (drawing the blinking ▼ arrow), then +-- scrolls "a boosted" up to the top row and types " EXP. Points!" on the +-- bottom row (y=128) -- a VISIBLE row. +-- +-- The driver builds a deterministic traded RATTATA, KOs a weak wild PIDGEY +-- with no level-up, then renders the battle into a clean offscreen canvas +-- while capturing Font.drawCode, and asserts the encoded "EXP. Points!" +-- glyph run lands on a visible row (y <= 130), not off-screen at y=144. +-- Fails on the buggy build (amount only ever drawn at y=144); passes once +-- the box scrolls. +-- +-- Run: +-- SHOT_DIR=/tmp/bug216 POKEPORT_IDENTITY=bug216 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/battle_boosted_exp_bug216_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local Font = require("src.render.Font") + local BattleState = require("src.battle.BattleState") + + -- deterministic traded RATTATA :L8; exp pinned to the L8 floor so a single + -- weak wild KO (~22 boosted exp) stays below the L9 threshold (no level-up + -- to muddy the message). DVs pinned to max so numbers are stable per run. + local rattata = Pokemon.new(game.data, "RATTATA", 8, function(_, b) return b end) + local def = game.data.pokemon.RATTATA + rattata.exp = Growth.expForLevel(def.growthRate, 8, game.data.growth_rates) + rattata.traded = true -- the flag real trades/link set (Commands.lua / Protocol.lua) + game.save.party = { rattata } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + -- weak enemy: 1 HP so the first move KOs, speed 1 so RATTATA always moves + -- first, rng pinned low so every accuracy roll hits (Damage.accuracyRoll: + -- rng(0,255) < acc) + local battle = BattleState.newWild(game, "PIDGEY", 2) + battle.onFinish = function() end + battle.rng = function(a, _) return a end + battle.enemy.mon.hp = 1 + battle.enemy.mon.stats.speed = 1 + ow:pushBattle(battle) + + -- Render the battle into a clean offscreen 160x144 canvas while recording + -- every Font.drawCode(code, x, y). Returns true iff `targetCodes` appear + -- as a contiguous left-to-right run on a row at y <= maxY. The off-screen + -- amount row (y=144) is excluded, so the buggy build never counts as + -- "shown". BattleState:draw runs its own canvas pipeline internally and + -- restores the caller's canvas (see battle_hpbar_gbc_bug229_test.lua). + local canvas = love.graphics.newCanvas(160, 144) + local function seqVisible(targetCodes, maxY) + local rec = {} + local orig = Font.drawCode + Font.drawCode = function(code, x, y) + rec[#rec + 1] = { code, x, y } + return orig(code, x, y) + end + love.graphics.setCanvas(canvas) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + local okDraw = pcall(function() battle:draw() end) + love.graphics.setCanvas() + Font.drawCode = orig + if not okDraw then return false end + -- group glyphs by row, sort left-to-right, join codes into a delimited + -- string, and search each visible row for the target subsequence + local byY = {} + for _, g in ipairs(rec) do + if g[3] <= maxY then + byY[g[3]] = byY[g[3]] or {} + table.insert(byY[g[3]], g) + end + end + local tparts = {} + for _, c in ipairs(targetCodes) do tparts[#tparts + 1] = tostring(c) end + local needle = "," .. table.concat(tparts, ",") .. "," + for _, list in pairs(byY) do + table.sort(list, function(a, b) return a[2] < b[2] end) + local cs = {} + for _, g in ipairs(list) do cs[#cs + 1] = tostring(g[1]) end + if ("," .. table.concat(cs, ",") .. ","):find(needle, 1, true) then + return true + end + end + return false + end + + local amountCodes = Font.encode("EXP. Points!") + + -- mash through the intro to the FIGHT menu, then FIGHT -> move slot 1 + -- (TACKLE) -> KO (mirrors battle_levelup_hpbar_bug224_test.lua) + for _ = 1, 300 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(3) + end + if battle.phase ~= "menu" then error("bug216: never reached the FIGHT menu") end + U.tap(game, "a") -- FIGHT + for _ = 1, 60 do + if battle.phase == "moveSelect" then break end + U.wait(1) + end + if battle.phase ~= "moveSelect" then error("bug216: never reached move select") end + U.tap(game, "a") -- TACKLE -> KO + + -- Phase A: advance to the boosted-EXP message (contains both "boosted" + -- and "EXP. Points!"). Break the frame it becomes current, before any A + -- press could scroll past it. + local function isBoosted() + local c = battle.current + return c and c.text and c.text:find("EXP. Points!", 1, true) + and c.text:find("boosted", 1, true) + end + local reached = false + for _ = 1, 900 do + if isBoosted() then reached = true break end + if game.stack:top() ~= battle then break end + U.tap(game, "a") + U.wait(2) + end + if not reached then + error("bug216: never reached the boosted-EXP message (battle ended early)") + end + U.log("boosted message: " .. + (tostring(battle.current.text):gsub("[\n\v]", "|"))) + + -- let the first two lines type out, then capture the cut-off + -- "gained / a boosted" box (both builds show this; the buggy build has no + -- visible third row) + U.wait(40) + U.shot(game, DIR .. "/bug216_boosted.png") + + -- Phase B: the amount must become visible on a real row. Each iteration + -- renders offscreen and checks; then taps A -- which scrolls the CONT + -- window on the fixed build, and (once fully typed) dismisses the message + -- on the buggy build. + local shown = false + for _ = 1, 120 do + if seqVisible(amountCodes, 130) then shown = true break end + if game.stack:top() ~= battle then break end + U.tap(game, "a") + U.wait(4) + end + + if not shown then + error("bug216: 'EXP. Points!' amount never shown on a visible row " .. + "(drawn off-screen at y=144); the box did not scroll to the amount") + end + + U.shot(game, DIR .. "/bug216_amount.png") + + -- no level-up muddied the message + if battle.player.mon.level ~= 8 then + error("bug216: expected level 8, got " .. tostring(battle.player.mon.level)) + end + + U.log("bug216 OK: boosted EXP amount shown on a visible row, level still 8") + U.wait(4) +end diff --git a/tests/drivers/battle_hpbar_gbc_bug229_test.lua b/tests/drivers/battle_hpbar_gbc_bug229_test.lua new file mode 100644 index 00000000..c066b907 --- /dev/null +++ b/tests/drivers/battle_hpbar_gbc_bug229_test.lua @@ -0,0 +1,104 @@ +-- Regression test for issue #229 ("GSC palette black full health"). +-- +-- In RED++ ("Gen 2 / GSC-style") COLORS mode the full-health (green-band) +-- in-battle HP bar rendered as a solid black rectangle. Root cause is in +-- src/render/HudTiles.lua drawHPBar: it pre-tinted the fill with GREENBAR's +-- fill color {0,189,0} (red channel 0), zeroing the red channel of every bar +-- pixel; the battle zone shade-remap shader (PaletteFX.shader, keyed ONLY on +-- the red channel) then mapped every zeroed-red pixel to color 3 = black. +-- Red/orange bands survived because REDBAR {247,0,0} / YELLOWBAR {247,165,0} +-- keep a nonzero red channel. SGB ('gbc') mode was unaffected because +-- PaletteFX.pack({}) returns nil there, so the fill was already drawn gray. +-- +-- Gen1-correct behavior: the DMG hardware bar is one gray shade recolored by +-- the SGB region palette (home/pokemon.asm DrawHPBar + engine/gfx/palettes.asm +-- SetPal_Battle + data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel +-- repaint. This driver forces RED++, enters a full-HP wild battle, screenshots +-- the intro + action menu, then renders the battle into a clean 160x144 canvas +-- and asserts the player AND enemy HP-bar fill bands are green (not black). +-- +-- Run: +-- SHOT_DIR=/tmp/bug229 POKEPORT_IDENTITY=bug229 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/battle_hpbar_gbc_bug229_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "." + local PaletteFX = require("src.render.PaletteFX") + + -- Set the SAVED option, not just the live mode: Game:applyOptions re-reads + -- save.options.colors, so a bare setMode would get reverted to the default. + game.save.options = game.save.options or {} + game.save.options.colors = "redpp" + PaletteFX.setMode("redpp") + + -- BULBASAUR :L5 is 19/19 -> full HP -> green band (matches the issue shot). + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "RATTATA", 3) -- full HP -> green + battle.onFinish = function() end + ow:pushBattle(battle) + + U.wait(220) + U.shot(game, DIR .. "/bug229_redpp_intro.png") + + -- Advance the intro text to the action menu deterministically (phase flips + -- to "menu" at BattleState:1111/1243); stop before a menu tap enters FIGHT. + for _ = 1, 40 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + U.wait(4) + U.shot(game, DIR .. "/bug229_redpp_menu.png") + + -- Programmatic assertion: render the battle into a clean offscreen 160x144 + -- canvas -- BattleState:draw runs its own SGB zone pass internally, so this + -- is the real colorized output (verified pixel-identical to the on-screen + -- capture). Sample the CENTER fill rows of each bar (an 8px bar tile is + -- white frame rows / fill rows / white frame rows, so the middle rows are + -- the fill). Before the fix the fill is ~ (0,0,0); after the fix it is + -- ~ (0,0.74,0) (GREENBAR fill {0,189,0}/255). + if love and love.graphics and love.graphics.newCanvas and battle.phase == "menu" then + local canvas = love.graphics.newCanvas(160, 144) + love.graphics.setCanvas(canvas) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + battle:draw() + love.graphics.setCanvas() + local id = canvas:newImageData() + do local dbg = id:encode("png"); local f = io.open(DIR .. "/bug229_offscreen.png", "wb"); if f then f:write(dbg:getString()); f:close() end end + + local function green(r, g, b) return g > 0.4 and g > r and g > b end + -- returns worst (lowest-green) fill pixel across the center rows sampled + local function worstFill(x, ys) + local wr, wg, wb = 1, 1, 1 + for _, y in ipairs(ys) do + local r, g, b = id:getPixel(x, y) + if g < wg then wr, wg, wb = r, g, b end + end + return wr, wg, wb + end + -- player bar: drawHPBar tile (10,9) -> fill x 96..143; fill rows y 74..77 + local pr, pg, pb = worstFill(120, { 75, 76 }) + -- enemy bar: drawHPBar tile (2,2) -> fill x 32..79; fill rows y 18..21 + local er, eg, eb = worstFill(56, { 19, 20 }) + U.log(string.format("player fill rgb = %.2f %.2f %.2f", pr, pg, pb)) + U.log(string.format("enemy fill rgb = %.2f %.2f %.2f", er, eg, eb)) + + if not (green(pr, pg, pb) and green(er, eg, eb)) then + error(string.format( + "issue #229: RED++ HP bar fill not green (player %.2f/%.2f/%.2f enemy %.2f/%.2f/%.2f)", + pr, pg, pb, er, eg, eb)) + end + U.log("issue #229 PASS: green HP bar fill in RED++") + else + error("issue #229 driver: never reached the battle action menu") + end + + U.wait(4) +end diff --git a/tests/drivers/battle_levelup_hpbar_bug224_test.lua b/tests/drivers/battle_levelup_hpbar_bug224_test.lua new file mode 100644 index 00000000..2037314b --- /dev/null +++ b/tests/drivers/battle_levelup_hpbar_bug224_test.lua @@ -0,0 +1,125 @@ +-- Driver: reproduce #224 - "Level up health bar inches down". +-- +-- On a level-up during battle, Gen 1 (engine/battle/experience.asm) raises +-- the mon's current HP by (newMaxHP - oldMaxHP) and redraws the active +-- battler's HP bar UP to reflect the higher current HP. Our data layer is +-- correct (Experience.lua:84 applies the current-HP delta), but the on-screen +-- numerator - the battler's shownHP (the value the HUD bar/number use) - was +-- never advanced on level-up, while the denominator (mon.stats.hp) jumped +-- instantly. So the drawn fill FRACTION fell (e.g. 8/20 -> 8/22) instead of +-- rising to 10/22. +-- +-- Setup: a SQUIRTLE at L5 with 8/oldMax HP and exp one point below the level-6 +-- threshold; a single wild KO crosses it. The driver asserts the player HP +-- bar's shownHP rises to the new current HP DURING the level-up messages +-- (before the menu-phase safety net at BattleState.lua:1099 would mask it). +-- Fails on the buggy build (shownHP stuck at 8); passes once fixed. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local BattleState = require("src.battle.BattleState") + + -- deterministic mon: pin DVs to the max so the HP numbers are identical + -- across the before/after runs (Stats.randomDVs consumes rng(0,15)) + local squirtle = Pokemon.new(game.data, "SQUIRTLE", 5, function(_, b) return b end) + local def = game.data.pokemon.SQUIRTLE + local oldHP = 8 + squirtle.hp = oldHP -- partly depleted so the bar is well under full + -- one point below the level-6 exp threshold: a single kill levels up once + squirtle.exp = Growth.expForLevel(def.growthRate, 6, game.data.growth_rates) - 1 + game.save.party = { squirtle } + local oldMax = squirtle.stats.hp + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + -- weak enemy: 1 HP so the first TACKLE KOs; speed pinned to 1 so SQUIRTLE + -- always moves first (no enemy turn to muddy the HP math); rng pinned to + -- the low end so every roll hits (Damage.accuracyRoll: rng(0,255) < acc) + local battle = BattleState.newWild(game, "SLOWPOKE", 2) + battle.onFinish = function() end + battle.rng = function(a, _) return a end + battle.enemy.mon.hp = 1 + battle.enemy.mon.stats.speed = 1 + ow:pushBattle(battle) + + -- mash through the intro to the FIGHT menu + for _ = 1, 240 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(3) + end + if battle.phase ~= "menu" then error("bug224: never reached the FIGHT menu") end + + -- HP box at L5: the menu safety net snaps shownHP to mon.hp, so 8/oldMax + U.shot(game, DIR .. "/bug224_menu.png") + + -- FIGHT -> first move (TACKLE, slot 1) -> KO + U.tap(game, "a") -- FIGHT + for _ = 1, 60 do + if battle.phase == "moveSelect" then break end + U.wait(1) + end + if battle.phase ~= "moveSelect" then error("bug224: never reached move select") end + U.tap(game, "a") -- TACKLE + + -- Monitor the level-up messages. The bar must animate from oldHP toward + -- the new current HP while phase == 'messages' (before finish/menu-snap). + local maxShown = oldHP + local caughtUp = false + local shotGrew, shotRisen = false, false + for _ = 1, 600 do + U.wait(1) + U.tap(game, "a") -- advance text / dismiss the stat box (never skips the + -- time-based drain), so we reach the HP-bar redraw + if battle.phase == "messages" and battle.player.mon.level >= 6 then + local sh = battle.player.shownHP or battle.player.mon.hp + maxShown = math.max(maxShown, sh) + if not shotGrew then + -- first L6 messages frame: buggy build already shows the shrunk bar + -- (8/newMax) here, and it never recovers + U.shot(game, DIR .. "/bug224_grew.png") + shotGrew = true + end + if not shotRisen and sh >= oldHP + 1.0 then + -- the bar has climbed at least a full HP: capture it mid-rise + U.shot(game, DIR .. "/bug224_hpbar.png") + shotRisen = true + end + if sh >= squirtle.hp - 0.5 then + caughtUp = true + break + end + end + if game.stack:top() ~= battle then break end -- battle finished/popped + end + + -- data-layer sanity (Experience.lua): one level gained, max HP grew, and + -- current HP rose by the max-HP delta + if battle.player.mon.level ~= 6 then + error("bug224: expected level 6, got " .. tostring(battle.player.mon.level)) + end + local newMax = squirtle.stats.hp + if not (newMax > oldMax) then + error("bug224: max HP did not grow (oldMax=" .. tostring(oldMax) .. + ", newMax=" .. tostring(newMax) .. "); repro is meaningless") + end + local expectHP = math.min(newMax, oldHP + (newMax - oldMax)) + if squirtle.hp ~= expectHP then + error("bug224: current HP wrong: got " .. tostring(squirtle.hp) .. + ", expected " .. tostring(expectHP)) + end + + -- THE BUG: the on-screen bar must have risen to the new current HP during + -- the messages phase. On the buggy build shownHP stays stuck at oldHP. + if not caughtUp then + error("bug224: HP bar did not rise on level-up - shownHP stuck at " .. + tostring(maxShown) .. " (oldHP=" .. tostring(oldHP) .. + "), mon.hp=" .. tostring(squirtle.hp) .. "/" .. tostring(newMax)) + end + + U.log("bug224 OK: L6, HP " .. tostring(squirtle.hp) .. "/" .. tostring(newMax) .. + ", bar rose from " .. tostring(oldHP) .. " to " .. tostring(maxShown)) +end diff --git a/tests/drivers/battle_mono_sprite_bug207_test.lua b/tests/drivers/battle_mono_sprite_bug207_test.lua new file mode 100644 index 00000000..9585e398 --- /dev/null +++ b/tests/drivers/battle_mono_sprite_bug207_test.lua @@ -0,0 +1,150 @@ +-- Regression test for issue #207 ("Back of sprite only showing outline"). +-- +-- In the forced-mono display modes (OG / OG INV / CLASSIC) a warm-palette +-- mon's battle pic (e.g. CHARMANDER, SGB palette REDMON) rendered as a bare +-- black outline on white, its two mid shades gone, while cool-palette mons +-- (SQUIRTLE, CYANMON) kept full shading. Root cause is a double shade-remap: +-- BattleState bakes each pic ONCE with the species' SGB colors, then draws it +-- onto the UI canvas; in these modes BattleState exposes no SGB zones, so +-- Renderer:endFrame invents a whole-screen GRAYS zone (PaletteFX.ensureZones) +-- and runs the ENTIRE colored battle frame through PaletteFX.shader() a SECOND +-- time. That shader keys the DMG shade off the red channel +-- (r>0.83?c0:r>0.5?c1:r>0.17?c2:c3); REDMON's two mid shades have red 1.0 and +-- 0.839 -- BOTH > 0.83 -- so they collapse into shade 0 (the white paper), +-- leaving only the near-black outline as shade 3. CYANMON's reds (0.678, +-- 0.451) land in the c1/c2 buckets, which is why blue mons were unaffected. +-- +-- Gen1-correct behavior: mon pics are 2bpp 4-shade tiles (gfx/pokemon/back, +-- gfx/pokemon/front); all four shades must be visible, and the same grayscale +-- palette that renders SQUIRTLE renders CHARMANDER. The fix draws the pics as +-- raw DMG grays in these modes so the whole-screen remap recolors 255->c0, +-- 170->c1, 85->c2, 0->c3 -- all four shades survive. +-- +-- This driver forces OG, gives the player a CHARMANDER (the failing warm +-- palette), enters a wild SQUIRTLE battle, and screenshots the exact action +-- menu the reporter shows. The assertion replays the on-screen two-stage +-- pipeline into a clean 160x144 canvas (BattleState:draw, then the whole-screen +-- GRAYS remap) and asserts the player CHARMANDER back-pic interior contains +-- mid-gray shades (not outline-only), with the enemy SQUIRTLE front interior as +-- an always-passing control. +-- +-- Run: +-- SHOT_DIR=/tmp/bug207 POKEPORT_IDENTITY=bug207 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/battle_mono_sprite_bug207_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "." + local PaletteFX = require("src.render.PaletteFX") + + -- Set the SAVED option, not just the live mode: Game:applyOptions re-reads + -- save.options.colors, so a bare setMode would get reverted to the default. + game.save.options = game.save.options or {} + game.save.options.colors = "og" + PaletteFX.setMode("og") + + -- CHARMANDER :L5 -- SGB palette REDMON, the warm palette that collapsed. + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "CHARMANDER", 5) } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "SQUIRTLE", 5) + battle.onFinish = function() end + ow:pushBattle(battle) + + U.wait(220) + U.shot(game, DIR .. "/bug207_og_intro.png") + + -- Advance the intro text to the action menu deterministically (phase flips + -- to "menu" once "Go! CHARMANDER!" has swapped in the species back pic). + for _ = 1, 40 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + U.wait(4) + -- The reporter's exact screen: the FIGHT/PKMN/ITEM/RUN action menu with the + -- player's CHARMANDER back pic fully visible bottom-left. + U.shot(game, DIR .. "/bug207_og_menu.png") + + if not (love and love.graphics and love.graphics.newCanvas + and battle.phase == "menu") then + error("issue #207 driver: never reached the battle action menu") + end + + -- Replay the on-screen forced-mono pipeline into an offscreen 160x144 canvas. + -- Stage 1 is BattleState's own colorized frame (its internal SGB zone pass + -- plus the mon pics drawn via picImage). Stage 2 is Renderer:endFrame's + -- whole-screen remap for a state with no SGB zones: ensureZones -> whole(GRAYS), + -- then blit sends GRAYS through the shade shader over the FULL frame. This is + -- pixel-faithful to the presented window (the U.shot captures above), but in + -- clean 160x144 canvas space so the sample boxes are resolution-independent. + local g = love.graphics + local shader = PaletteFX.shader() + local prev = g.getCanvas() + local a = g.newCanvas(160, 144) + local b = g.newCanvas(160, 144) + g.setCanvas(a) + g.clear(1, 1, 1, 1) -- battle letterbox is white (letterboxWhite) + g.setColor(1, 1, 1, 1) + battle:draw() + g.setCanvas(b) + g.clear(0, 0, 0, 1) + g.setShader(shader) + PaletteFX.sendColors(shader, PaletteFX.GRAYS) + g.setColor(1, 1, 1, 1) + g.draw(a, 0, 0) + g.setShader() + g.setCanvas(prev) + local id = b:newImageData() + do + local png = id:encode("png") + local f = io.open(DIR .. "/bug207_og_offscreen.png", "wb") + if f then f:write(png:getString()); f:close() end + end + + -- Count mid-gray pixels in a box. The forced-mono frame is neutral gray: + -- shade 0 = white (~1.0), shade 3 = black (~0.0), and the two MID shades are + -- ltgray (170/255 = 0.667) and dkgray (85/255 = 0.333). A mid shade exists + -- only when all four DMG shades survived the remap; an outline-only pic has + -- pure white + pure black and zero mid pixels. + local function midCount(x0, y0, x1, y1) + local n = 0 + for y = y0, y1 do + for x = x0, x1 do + local r = id:getPixel(x, y) + if r > 0.18 and r < 0.82 then n = n + 1 end + end + end + return n + end + + -- Player CHARMANDER back pic: hlcoord 1,5 (x=8), feet at y=96 -- interior + -- box well inside the body, clear of the white matte and the near-black + -- outline, and clear of the bottom-right HUD/HP bar. + local backMid = midCount(12, 50, 52, 92) + -- Enemy SQUIRTLE front pic: 7x7 slot at hlcoord 12,0 (x~96) -- control that + -- always keeps its mid shades (CYANMON reds land in the c1/c2 buckets). + local enemyMid = midCount(104, 8, 148, 48) + U.log(string.format("player back mid-gray px = %d ; enemy front mid-gray px = %d", + backMid, enemyMid)) + + if enemyMid <= 20 then + error(string.format( + "issue #207 driver: control failed -- enemy SQUIRTLE front has no " + .. "mid-gray (%d); sample box or pipeline is wrong", enemyMid)) + end + if backMid <= 20 then + error(string.format( + "issue #207: OG-mode CHARMANDER back pic is outline-only -- interior " + .. "has %d mid-gray px (expected the full 4-shade grayscale, like the " + .. "enemy front's %d)", backMid, enemyMid)) + end + U.log(string.format( + "issue #207 PASS: OG-mode CHARMANDER back keeps its mid shades " + .. "(%d mid-gray px, control enemy %d)", backMid, enemyMid)) + + U.wait(4) +end diff --git a/tests/drivers/blues_house_bug11_test.lua b/tests/drivers/blues_house_bug11_test.lua new file mode 100644 index 00000000..b28341dc --- /dev/null +++ b/tests/drivers/blues_house_bug11_test.lua @@ -0,0 +1,115 @@ +-- Driver: issue #11 -- Blue's House wall Town Map phantom pickup + the +-- sell crash it causes. +-- +-- In pokered the framed Town Map on the wall of Blue's House +-- (data/maps/objects/BluesHouse.asm BLUESHOUSE_TOWN_MAP) is a plain +-- text object -- talking to it prints _BluesHouseTownMapText +-- ("It's a big map! This is useful!") and nothing enters the bag. +-- The ROM object carries the 0x80 "has item payload" bit with a payload +-- id of 0 (ITEM_NONE), which our extractor copies through as item="0". +-- The engine's item-ball branch treated the truthy string "0" as a real +-- item, so pressing A picked up a bogus item "0" -- and selling that +-- unknown id later hard-crashed ShopMenu.sell (nil item def). +-- +-- This driver reproduces both halves and asserts the correct Gen1 +-- behavior, so it fails while the bug exists and passes once fixed. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Bag = require("src.inventory.Bag") + local Screens = require("src.ui.Screens") + + local pass, fail = 0, 0 + local function check(ok, label) + if ok then pass = pass + 1; U.log("PASS", label) + else fail = fail + 1; U.log("FAIL", label) end + end + + -- defensive: keep the pre-fix " found 0!" box from erroring on a + -- nil player name (the box never appears after the fix) + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "RED" + + -- ---- Part 1: talking to the wall Town Map must not pick anything up ---- + U.teleport(game, "BLUES_HOUSE", 2, 6, "up") + U.wait(5) + local wallmap + for _, n in ipairs(game.overworld.npcs) do + if n.def and n.def.name == "BLUESHOUSE_TOWN_MAP" then wallmap = n end + end + check(wallmap ~= nil, "wall Town Map object present") + if wallmap then + game.overworld:talkTo(wallmap) + U.wait(8) + U.shot(game, DIR .. "/bh_1_wallmap.png") + -- correct Gen1 behavior: nothing enters the bag + check(game.save.inventory["0"] == nil, "no phantom item '0' in bag") + check((game.save.inventory["0"] == nil) and (game.save.bagOrder == nil + or not (function() + for _, id in ipairs(game.save.bagOrder) do + if id == "0" then return true end + end + return false + end)()), "bag order has no '0' entry") + -- dismiss whatever box is up + for _ = 1, 4 do U.tap(game, "b"); U.wait(3) end + end + + -- ---- Part 2: selling an unknown id must not crash ---------------------- + -- Seed a corrupted bag (a save that already picked up "0" before the fix, + -- or any legacy/unknown id) and drive the mart SELL path over it. + while game.stack:top() do game.stack:pop() end + U.teleport(game, "PEWTER_MART", 2, 5, "left") + U.wait(5) + game.save.money = 3000 + -- known bag state regardless of whether part 1 leaked a phantom "0" + game.save.inventory = {} + game.save.bagOrder = nil + Bag.add(game.save, "0", 1) + check(game.save.inventory["0"] == 1, "seeded unknown item '0' in bag") + + -- open the clerk's BUY/SELL/QUIT menu exactly as the mart interaction does + Screens.push(game, "ShopMenu", {}) + U.wait(4) + U.tap(game, "down") -- BUY -> SELL + U.wait(4) + U.tap(game, "a") -- open the SELL list + U.wait(6) + local sellList = game.stack:top() + check(sellList and sellList.items ~= nil, "SELL list opened") + U.shot(game, DIR .. "/bh_2_sell_list.png") + + if sellList and sellList.items then + -- find the bogus "0" row + local item, idx + for i, it in ipairs(sellList.items) do + if it.value == "0" then item, idx = it, i end + end + check(item ~= nil, "bogus '0' row present in sell list") + if item then + sellList.index = idx + local footerBefore = sellList.footer + -- Invoke the real onChoose the ListMenu would call on A. Before the + -- fix this throws at math.floor(def.price/2) with def=nil; we catch + -- it so the run reports the crash instead of hanging on love's error + -- screen. + local ok, err = pcall(sellList.onChoose, item, sellList) + if not ok then + fail = fail + 1 + U.log("FAIL", "selling '0' CRASHED: " .. tostring(err)) + else + check(true, "selling '0' did not crash") + -- nothing may be sold: the guard returns before any QuantityBox + check(game.save.inventory["0"] == 1, "unknown item still in bag (not sold)") + check(sellList.footer ~= footerBefore + and tostring(sellList.footer):find("price") ~= nil, + "sell footer shows the unsellable message") + end + U.wait(4) + U.shot(game, DIR .. "/bh_3_sell_choose.png") + end + end + + U.log("RESULT", ("pass=%d fail=%d"):format(pass, fail)) + if fail == 0 then U.log("RESULT", "ALL PASS") else U.log("RESULT", "HAS FAILURES") end +end diff --git a/tests/drivers/decline_push_bug151_test.lua b/tests/drivers/decline_push_bug151_test.lua new file mode 100644 index 00000000..fdd6d8d0 --- /dev/null +++ b/tests/drivers/decline_push_bug151_test.lua @@ -0,0 +1,141 @@ +-- Driver: forced-step shoves use the wrong primitive (issue #151). +-- +-- Two related defects, both a scripted "push the player back one step" that +-- reaches for the wrong movement primitive: +-- +-- A MUSEUM_1F ticket rope (data/scripts/story2.lua museumClerk onDecline): +-- declining the Y50 ticket must shove the player one cell SOUTH off the +-- exhibit rope (scripts/Museum1F.asm) -- the player crossed the rope +-- heading NORTH, so the shove is south. The bug shoved "right" onto the +-- counter tile (11,4)=tile 23, non-walkable, matching the report's "moved +-- onto the table". Correct landing is (10,5)=tile 1 (walkable floor). +-- +-- B VIRIDIAN_CITY gym lock (data/scripts/story5.lua stepGate/viridianGym- +-- Lock): the tile below the Gym door (32,8)=tile 44 is a DOWN-ledge +-- (44 -> 55, data/tilesets/ledge_tiles.asm); Gen1 shoves the player with a +-- SIMULATED JOYPAD down-press that runs the normal step pipeline including +-- HandleLedges (engine/overworld/ledges.asm), so the shove HOPS the ledge +-- and lands on (32,10)=tile 57. The bug used a raw scriptMove that +-- ignores ledges, planting the player standing on the ledge tile (32,9). +-- +-- Both cases assert the CORRECT Gen1 outcome, so this FAILS on the bug and +-- PASSES once the shove primitives are fixed. +-- +-- Run: +-- POKEPORT_DRIVER=tests/drivers/decline_push_bug151_test.lua \ +-- POKEPORT_IDENTITY=bug151 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local shotDir = os.getenv("POKEPORT_SHOTDIR") or "." + local function shot(name) U.shot(game, shotDir .. "/" .. name) end + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + + -- a party + starter flag so the overworld is fully usable + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + local Pokemon = require("src.pokemon.Pokemon") + if #game.save.party == 0 then + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + end + + local fails = 0 + local function expect(cond, ...) + if not cond then fails = fails + 1 end + U.log(cond and "PASS" or "FAIL", ...) + end + + -- Advance dialog until control returns to the overworld: tap A through + -- TextBoxes, tap B through a ChoiceBox (B always declines -- ChoiceBox:update + -- calls onChoose(false) on B). Bounded so a stuck box can't hang the run. + local function driveDialog(maxIters) + for _ = 1, (maxIters or 240) do + local top = game.stack:top() + if top == game.overworld then return true end + if getmetatable(top) == ChoiceBox then + U.tap(game, "b") + elseif getmetatable(top) == TextBox then + U.tap(game, "a") + else + -- unknown state: nudge with A so we never wedge + U.tap(game, "a") + end + U.wait(2) + end + return game.stack:top() == game.overworld + end + + -- Drain queued scriptMoves + any in-flight step, sampling p.hopFrames each + -- frame (a hop arc is set only by checkLedgeHop). Returns whether a hop was + -- ever seen while draining. + local function drainMoves(p, maxFrames) + local hopSeen = (p.hopFrames or 0) > 0 + for _ = 1, (maxFrames or 180) do + local ow = game.overworld + local pending = ow.scriptMoves and #ow.scriptMoves > 0 + if not pending and not p.moving and (p.hopFrames or 0) == 0 then break end + if (p.hopFrames or 0) > 0 then hopSeen = true end + U.wait(1) + end + return hopSeen + end + + -- ------------------------------------------------------------------ + -- Case A: MUSEUM_1F ticket-rope decline must shove SOUTH, not RIGHT. + -- ------------------------------------------------------------------ + do + game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = false + game.save.money = 1000 -- enough to buy, so declining is a real NO choice + U.teleport(game, "MUSEUM_1F", 10, 5, "up") + U.wait(6) + local p = game.overworld.player + shot("museum_decline_before.png") + -- step north onto the rope cell (10,4); the clerk stops us there + U.hold(game, "up", 24) + -- clerk dialog: advance the pitch, decline the YES/NO, clear "Come again!" + driveDialog(240) + drainMoves(p, 120) + shot("museum_decline_after.png") + expect(p.cellX == 10 and p.cellY == 5, + "A: declining shoves the player SOUTH to (10,5), got:", p.cellX, p.cellY) + end + + -- ------------------------------------------------------------------ + -- Case B: VIRIDIAN_CITY gym lock shove must HOP the down-ledge. + -- ------------------------------------------------------------------ + do + -- fresh badge state: no non-Earth badges, so the gym stays locked + game.save.inventory = game.save.inventory or {} + for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", + "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", + "VOLCANOBADGE" }) do + game.save.inventory[b] = nil + end + U.teleport(game, "VIRIDIAN_CITY", 31, 8, "right") + U.wait(6) + local p = game.overworld.player + shot("viridian_gymlock_before.png") + -- step east onto (32,8) directly below the locked Gym door + U.hold(game, "right", 24) + -- the "GYM's doors are locked..." box appears; clearing it fires the shove + local ow = game.overworld + local hopSeen = false + for _ = 1, 240 do + local top = game.stack:top() + if top == game.overworld then break end + if getmetatable(top) == TextBox then U.tap(game, "a") else U.tap(game, "a") end + if (p.hopFrames or 0) > 0 then hopSeen = true end + U.wait(2) + if (p.hopFrames or 0) > 0 then hopSeen = true end + end + if drainMoves(p, 180) then hopSeen = true end + shot("viridian_gymlock_after.png") + expect(hopSeen, "B: the gym-lock shove HOPS the ledge (hop arc seen)") + expect(p.cellX == 32 and p.cellY == 10, + "B: landed south of the ledge at (32,10), got:", p.cellX, p.cellY) + end + + if fails > 0 then error(fails .. " check(s) failed") end + U.log("all checks passed -- #151 shoves are Gen1-correct " + .. "(museum decline goes south, Viridian gym lock hops the ledge)") +end diff --git a/tests/drivers/dig_bug196_test.lua b/tests/drivers/dig_bug196_test.lua new file mode 100644 index 00000000..8bd6cf60 --- /dev/null +++ b/tests/drivers/dig_bug196_test.lua @@ -0,0 +1,193 @@ +-- Driver: regression coverage for #196 "Dig not working exactly as intended". +-- +-- Report (v0.1.23): using DIG, the character appears SPINNING INSIDE the +-- Pokemon Center in front of the nurse, and there is no spin-before-fade on +-- departure. Expected (reporter + triage cluster E + the file's own code +-- comments): the sprite begins to spin, the screen fades, and the player +-- fades in OUTSIDE the nearest town's Pokemon Center door -- exactly like Fly. +-- +-- Gen1/pokered references this exercises: +-- engine/overworld/player_animations.asm _LeaveMapAnim (SFX_TELEPORT_EXIT_1 +-- + PlayerSpinWhileMovingUp) for the departure spin-up; +-- EnterMapAnim (PlayerSpinWhileMovingDown) for the arrival spin-down; +-- engine/items/item_effects.asm ItemUseEscapeRope (Dig/Teleport share it). +-- +-- Two defects, two assertions: +-- FIX A: a DEPARTURE spin must appear on the origin (cave) map BEFORE the +-- warp -- ow.teleportOut set / player spinning while still in the cave. +-- FIX B: the landing map must be the town (VIRIDIAN_CITY) at the in-front-of +-- -door fly spot (23,26), NOT the interior VIRIDIAN_POKECENTER. +-- Pre-fix this driver documents the bug (lands in VIRIDIAN_POKECENTER, no +-- departure spin); post-fix it passes. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local failures = {} + local function check(cond, msg) + if cond then + U.log("PASS:", msg) + else + U.log("FAIL:", msg) + table.insert(failures, msg) + end + end + + -- ---- set up a heal record with a remembered Viridian town door ---- + -- Simulate having healed at the Viridian Pokemon Center: lastHeal points + -- at the INTERIOR (the buggy landing), but carries the outdoor town door + -- so the fix can relocate the escape-warp outside like Fly does. + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "bryan" + game.save.lastOutdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 } + game.save.lastHeal = { + map = "VIRIDIAN_POKECENTER", x = 3, y = 3, + outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 }, + } + -- field.flyWarps.VIRIDIAN_CITY = {23,26} is the canonical in-front-of-door + -- fly landing (one tile south of the PC door warp at 23,25). + local fw = game.data.field.flyWarps.VIRIDIAN_CITY + U.log("viridian flyWarp:", tostring(fw and fw.x), tostring(fw and fw.y)) + + -- ======================= LEG 1: DIG via the party menu ============== + -- A DIG-knowing party mon. Pokemon.movesAtLevel never grants DIG, so + -- inject the move directly (DIG needs no badge and works in CAVERN maps). + local digger = Pokemon.new(game.data, "NIDOKING", 40) + digger.moves = { { id = "DIG", pp = 10 } } + game.save.party = { digger } + + -- MT_MOON_1F tileset is CAVERN (in DIG_TILESETS); a walkable cave cell. + U.teleport(game, "MT_MOON_1F", 14, 34, "down") + local ow = game.overworld + U.log("start map:", tostring(ow and ow.map and ow.map.id), + "tileset:", tostring(ow and ow.map and ow.map.def and ow.map.def.tileset)) + U.shot(game, DIR .. "/dig_00_cave.png") + U.wait(4) + + -- open the party menu and pick DIG on slot 1 + -- (submenu order for a DIG-only mon: STATS / SWITCH / DIG) + Screens.push(game, "PartyMenu") + U.wait(5) + U.tap(game, "a") -- open the per-mon submenu + U.wait(2) + U.tap(game, "down") -- STATS -> SWITCH + U.wait(2) + U.tap(game, "down") -- SWITCH -> DIG + U.wait(2) + U.tap(game, "a") -- choose DIG + U.wait(2) + + -- ---- FIX A: watch for a DEPARTURE spin on the cave map before the warp ---- + -- A departure spin counts only if it happens while we are still in the cave + -- (map.id == MT_MOON_1F). The pre-fix code only spins on arrival, inside the + -- Center, so this stays false pre-fix. + local sawDepartureSpin = false + local spinShotTaken = false + local leftCave = false + for _ = 1, 240 do + local stillCave = ow.map and ow.map.id == "MT_MOON_1F" + -- departure-only markers: ow.teleportOut is the new pre-warp spin state, + -- and player.spinRise is the rising spin (the arrival uses spinDrop), so + -- neither can be confused with the interior arrival spin-down + if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then + sawDepartureSpin = true + if not spinShotTaken then + U.shot(game, DIR .. "/dig_01_spin.png") + spinShotTaken = true + end + end + if ow.map and ow.map.id ~= "MT_MOON_1F" then + leftCave = true + break + end + U.wait(1) + end + U.log("sawDepartureSpin:", tostring(sawDepartureSpin), + "leftCave:", tostring(leftCave)) + + -- ---- settle the arrival, then FIX B: where did we land? ---- + local landedMap + for _ = 1, 240 do + landedMap = ow.map and ow.map.id + -- wait until the transition settles onto a stable map that isn't the cave + if landedMap and landedMap ~= "MT_MOON_1F" and not ow.transitioning then + break + end + U.wait(1) + end + U.wait(8) + U.shot(game, DIR .. "/dig_02_land.png") + U.wait(4) + landedMap = ow.map and ow.map.id + U.log("DIG landed map:", tostring(landedMap), + "cell:", tostring(ow.player.cellX), tostring(ow.player.cellY)) + + check(sawDepartureSpin, + "DIG: departure spin appears in the cave before the fade (FIX A)") + check(landedMap == "VIRIDIAN_CITY", + "DIG: lands OUTSIDE at VIRIDIAN_CITY, not the interior (FIX B) -- got " + .. tostring(landedMap)) + check(ow.player.cellX == 23 and ow.player.cellY == 26, + "DIG: lands at the in-front-of-door fly spot 23,26 -- got " + .. tostring(ow.player.cellX) .. "," .. tostring(ow.player.cellY)) + + -- ======================= LEG 2: ESCAPE ROPE via the bag ============= + -- Same shared departure path, but driven through BagMenu's escape_rope + -- branch so src/ui/BagMenu.lua is exercised too. + game.save.party = { Pokemon.new(game.data, "PIDGEY", 8) } + game.save.bagOrder = nil + game.save.inventory = { ESCAPE_ROPE = 1 } + game.save.money = game.save.money or 3000 + + U.teleport(game, "MT_MOON_1F", 14, 34, "down") + ow = game.overworld + U.shot(game, DIR .. "/dig_03_rope_cave.png") + U.wait(4) + + Screens.push(game, "BagMenu") + U.wait(5) + U.tap(game, "a") -- choose ESCAPE ROPE (only item) -> USE / TOSS menu + U.wait(3) + U.tap(game, "a") -- USE (first option) -> escape_rope branch + U.wait(2) + + local ropeSpin = false + for _ = 1, 240 do + local stillCave = ow.map and ow.map.id == "MT_MOON_1F" + if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then + ropeSpin = true + end + if ow.map and ow.map.id ~= "MT_MOON_1F" then break end + U.wait(1) + end + local ropeLanded + for _ = 1, 240 do + ropeLanded = ow.map and ow.map.id + if ropeLanded and ropeLanded ~= "MT_MOON_1F" and not ow.transitioning then + break + end + U.wait(1) + end + U.wait(8) + U.shot(game, DIR .. "/dig_04_rope_land.png") + U.wait(4) + ropeLanded = ow.map and ow.map.id + U.log("ESCAPE ROPE landed map:", tostring(ropeLanded), + "cell:", tostring(ow.player.cellX), tostring(ow.player.cellY)) + + check(ropeSpin, + "ESCAPE ROPE: departure spin appears in the cave before the fade (FIX A)") + check(ropeLanded == "VIRIDIAN_CITY", + "ESCAPE ROPE: lands OUTSIDE at VIRIDIAN_CITY (FIX B) -- got " + .. tostring(ropeLanded)) + + if #failures == 0 then + U.log("RESULT bug196 PASS") + else + U.log("RESULT bug196 FAIL (" .. #failures .. "):") + for _, m in ipairs(failures) do U.log(" -", m) end + end +end diff --git a/tests/drivers/evolution_cancel_bug213_test.lua b/tests/drivers/evolution_cancel_bug213_test.lua new file mode 100644 index 00000000..ee7aa23f --- /dev/null +++ b/tests/drivers/evolution_cancel_bug213_test.lua @@ -0,0 +1,126 @@ +-- Driver: cancel an evolution with the B button (#213). +-- +-- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic +-- flash: holding B aborts the evolution (the mon keeps its species and +-- _StoppedEvolvingText prints). Trade evolutions (wLinkState == +-- LINK_STATE_TRADING) skip that poll and cannot be cancelled. +-- +-- Case 1 (level path, cancelable): open EvolutionState directly, wait a +-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and +-- assert the mon stays CATERPIE with "stopped evolving" text on screen. +-- Case 2 (control): let the flash run to completion with no input and +-- assert the mon becomes METAPOD with the "Congratulations!" text. +-- +-- SHOT_DIR=/tmp/evo213 POKEPORT_DRIVER=tests/drivers/evolution_cancel_bug213_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/evo213" + os.execute("mkdir -p " .. DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local Evolution = require("src.pokemon.Evolution") + + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + + local function top() return game.stack:top() end + local function evoTop() + local t = top() + return t and t.screenId == "EvolutionState" + end + local function waitFor(cond, max) + for _ = 1, max or 600 do + if cond() then return true end + U.wait(1) + end + return false + end + -- flatten a TextBox's paginated pages (list of line lists) to one string + local function pagesText(st) + if not st.pages then return nil end + local parts = {} + for _, page in ipairs(st.pages) do + if type(page) == "table" then + for _, line in ipairs(page) do + if type(line) == "string" then parts[#parts + 1] = line end + end + elseif type(page) == "string" then + parts[#parts + 1] = page + end + end + return table.concat(parts, " ") + end + local function findText(needle) + for _, st in ipairs(game.stack.states or {}) do + local blob = pagesText(st) + if blob and blob:find(needle, 1, true) then return st end + end + return nil + end + -- mash A (one tap every few frames) until cond holds; a single tap only + -- fast-forwards a still-typing TextBox, so mashing both finishes the + -- typewriter and then presses the close button + local function mashUntil(cond, max) + for _ = 1, max or 200 do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + return cond() + end + + U.teleport(game, "ROUTE_1", 5, 5, "down") + + -- === Case 1: hold B during the flash -> evolution aborts === + local mon = Pokemon.new(game.data, "CATERPIE", 7) + table.insert(game.save.party, 1, mon) + local done1 = false + Evolution.evolve(game, mon, "METAPOD", function() done1 = true end) + + if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end + U.wait(20) -- into the flash, well under FLASH_FRAMES=220 + U.log("case1 flash", "t=", top().t, "species=", mon.species) + U.shot(game, DIR .. "/evo213_1_evolving.png") + + U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel + + -- the flash aborts: EvolutionState is no longer the top (the stopped + -- text overlays it and then pops it) + if not waitFor(function() return not evoTop() end, 240) then + error("evolution did not abort on B: still on EvolutionState, species=" + .. tostring(mon.species)) + end + U.log("case1 aborted", "species=", mon.species) + U.wait(40) -- let "Huh? MON stopped evolving!" finish typing before the shot + U.shot(game, DIR .. "/evo213_2_stopped.png") + + assert(mon.species == "CATERPIE", + "B-cancel failed: mon evolved to " .. tostring(mon.species) + .. " (expected CATERPIE)") + assert(findText("stopped evolving"), "StoppedEvolvingText not shown") + + mashUntil(function() return done1 end, 80) -- close the stopped-evolving text + assert(done1, "cancel onDone never fired") + assert(mon.species == "CATERPIE", "species changed after cancel tail") + + -- === Case 2 (control): no input -> evolution completes === + local mon2 = Pokemon.new(game.data, "CATERPIE", 7) + table.insert(game.save.party, 1, mon2) + local done2 = false + Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end) + if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end + -- let the full flash run (FLASH_FRAMES=220) without pressing B + waitFor(function() return not evoTop() end, 400) + if not waitFor(function() return findText("evolved into") ~= nil end, 120) then + error("Congratulations text not shown (case2)") + end + U.wait(40) -- let the Congratulations text finish typing before the shot + U.shot(game, DIR .. "/evo213_3_congrats.png") + assert(mon2.species == "METAPOD", + "control failed: mon2 stayed " .. tostring(mon2.species) + .. " (expected METAPOD)") + mashUntil(function() return done2 end, 80) + + U.log("done", "case1=", mon.species, "case2=", mon2.species) + love.event.quit() +end diff --git a/tests/drivers/evolution_move_bug12_test.lua b/tests/drivers/evolution_move_bug12_test.lua new file mode 100644 index 00000000..d390389c --- /dev/null +++ b/tests/drivers/evolution_move_bug12_test.lua @@ -0,0 +1,143 @@ +-- Driver: evolution grants the new species' level-up move (#12). +-- +-- pokered engine/pokemon/evos_moves.asm (EvolveMon) re-runs the level-up +-- learn check on the *evolved* species after the "evolved into" text, via +-- the LearnMoveFromLevelUp predef (engine/pokemon/learn_move.asm). The +-- check is EXACT level equality (learnset entry level == mon.level), so a +-- mon evolving at exactly a learnset level gains that move. +-- +-- Data pins: GYARADOS.learnset has { level = 20, move = "BITE" } +-- (data/generated/pokemon.lua), so MAGIKARP->GYARADOS at level 20 must +-- learn BITE, while an evolution at level 21 must NOT (nothing at 21). +-- +-- Case 1 (repro/fix): Lv20 MAGIKARP knowing only SPLASH evolves; after the +-- congratulations text the mon must be GYARADOS and know BITE, and the +-- "GYARADOS learned BITE!" text must appear. This assertion FAILS on the +-- pre-fix build (no learn step) and PASSES once the fix runs the check. +-- Case 2 (control): Lv21 MAGIKARP evolves to GYARADOS and must NOT gain +-- BITE (guards the exact-level == rule against an over-broad <= fix). +-- +-- SHOT_DIR=/tmp/evo12 POKEPORT_DRIVER=tests/drivers/evolution_move_bug12_test.lua \ +-- POKEPORT_IDENTITY=bug12 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/evo12" + os.execute("mkdir -p " .. DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local Evolution = require("src.pokemon.Evolution") + + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + + local function top() return game.stack:top() end + local function evoTop() + local t = top() + return t and t.screenId == "EvolutionState" + end + local function waitFor(cond, max) + for _ = 1, max or 600 do + if cond() then return true end + U.wait(1) + end + return false + end + -- flatten a TextBox's paginated pages (list of line lists) to one string + local function pagesText(st) + if not st.pages then return nil end + local parts = {} + for _, page in ipairs(st.pages) do + if type(page) == "table" then + for _, line in ipairs(page) do + if type(line) == "string" then parts[#parts + 1] = line end + end + elseif type(page) == "string" then + parts[#parts + 1] = page + end + end + return table.concat(parts, " ") + end + local function findText(needle) + for _, st in ipairs(game.stack.states or {}) do + local blob = pagesText(st) + if blob and blob:find(needle, 1, true) then return st end + end + return nil + end + local function mashUntil(cond, max) + for _ = 1, max or 200 do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + return cond() + end + local function hasMove(mon, id) + for _, mv in ipairs(mon.moves) do + if mv.id == id then return true end + end + return false + end + + U.teleport(game, "ROUTE_1", 5, 5, "down") + + -- === Case 1: MAGIKARP @20 -> GYARADOS must learn BITE === + local mon = Pokemon.new(game.data, "MAGIKARP", 20) + mon.moves = { { id = "SPLASH", pp = 40 } } -- deterministic single slot + table.insert(game.save.party, 1, mon) + local done1 = false + Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end) + + if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end + -- let the full flash run (FLASH_FRAMES=220) with no input, then apply + waitFor(function() return not evoTop() end, 400) + if not waitFor(function() return findText("evolved into") ~= nil end, 120) then + error("Congratulations text not shown (case1)") + end + U.wait(40) -- let the congrats text finish typing before the shot + U.shot(game, DIR .. "/evo12_1_congrats.png") + + -- advance past congrats into the level-up learn flow; the fix pushes + -- "GYARADOS learned BITE!" here (pre-fix: onDone fires with no learn) + local sawLearned = false + mashUntil(function() + if findText("learned") then sawLearned = true; return true end + return done1 + end, 200) + U.wait(30) -- let the "learned BITE!" text type out (fix path) + U.shot(game, DIR .. "/evo12_2_learned.png") + + mashUntil(function() return done1 end, 120) + U.log("case1", "species=", mon.species, "hasBite=", hasMove(mon, "BITE"), + "sawLearned=", sawLearned) + + assert(mon.species == "GYARADOS", + "case1: mon stayed " .. tostring(mon.species) .. " (expected GYARADOS)") + assert(hasMove(mon, "BITE"), + "case1: GYARADOS did not learn BITE on evolution at level 20 (bug #12)") + assert(sawLearned, "case1: no 'learned' text shown for the evolution move") + + -- === Case 2 (control): MAGIKARP @21 -> GYARADOS must NOT gain BITE === + local mon2 = Pokemon.new(game.data, "MAGIKARP", 21) + mon2.moves = { { id = "SPLASH", pp = 40 } } + table.insert(game.save.party, 1, mon2) + local done2 = false + Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end) + if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end + waitFor(function() return not evoTop() end, 400) + if not waitFor(function() return findText("evolved into") ~= nil end, 120) then + error("Congratulations text not shown (case2)") + end + mashUntil(function() return done2 end, 200) + U.wait(20) + U.shot(game, DIR .. "/evo12_3_lv21_no_bite.png") + U.log("case2", "species=", mon2.species, "hasBite=", hasMove(mon2, "BITE")) + + assert(mon2.species == "GYARADOS", + "case2: mon2 stayed " .. tostring(mon2.species) .. " (expected GYARADOS)") + assert(not hasMove(mon2, "BITE"), + "case2: GYARADOS wrongly learned BITE at level 21 (over-grant; exact == broken)") + + U.log("done", "case1=", mon.species, "case2=", mon2.species) + love.event.quit() +end diff --git a/tests/drivers/fighting_dojo_bug197_test.lua b/tests/drivers/fighting_dojo_bug197_test.lua new file mode 100644 index 00000000..2fed34aa --- /dev/null +++ b/tests/drivers/fighting_dojo_bug197_test.lua @@ -0,0 +1,221 @@ +-- Driver: Fighting Dojo Karate Master bundle (#197). +-- Six sub-bugs live in FIGHTING_DOJO (scripts/FightingDojo.asm): +-- BUG1 no aggro -- the master has no trainer header so range=0 +-- 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 +-- 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 +-- inert (bg_events dropped by the extractor) +-- +-- Every scenario screenshots the moment and records a pass/fail; the driver +-- asserts once at the end, so a single run captures before-evidence for all +-- six while still failing red until the fixes land. +-- +-- SHOT_DIR=/tmp/dojo POKEPORT_DRIVER=tests/drivers/fighting_dojo_bug197_test.lua \ +-- POKEPORT_IDENTITY=bug197 POKEPORT_TOUCH=0 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 OW = require("src.world.OverworldController") + local Pokemon = require("src.pokemon.Pokemon") + local Commands = require("src.script.Commands") + + local failures = {} + local function check(cond, msg) + if cond then U.log("ok:", msg) else + table.insert(failures, msg) + U.log("FAIL:", msg) + end + return cond + end + + local function topIsTextBox() return getmetatable(game.stack:top()) == TextBox end + local function topIsChoice() return getmetatable(game.stack:top()) == ChoiceBox end + + local function currentPageText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local page = top.pages and top.pages[top.pageIndex] + if not page then return "" end + return table.concat(page, "\n") + end + + local function pageReady() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return false end + return top.waiting or top.done + end + + -- let the current page finish typing WITHOUT advancing past it + local function waitReadyPage() + for _ = 1, 200 do + if pageReady() then break end + U.wait(2) + end + return currentPageText() + end + + local function mashUntil(cond, cap) + for _ = 1, (cap or 200) do + if cond() then return true end + U.tap(game, "a") + U.wait(2) + end + return cond() + end + + -- advance text pages until a ready page contains `want`; stops before + -- blowing past a choice box + local function sawText(want) + return mashUntil(function() + if topIsChoice() then return false end + if not pageReady() then return false end + return currentPageText():find(want, 1, true) ~= nil + end, 150) + end + + local function npcByName(ow, name) + for _, n in ipairs(ow.npcs) do + if n.def and n.def.name == name then return n end + end + end + + local function resetDojo(x, y, facing, flags) + while game.stack:top() do game.stack:pop() end + -- the four blackbelts are not under test; retire them so only the + -- master (or the balls/poster) can react in each scenario + game.save.flags = { + 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 = {} + game.save.objectToggles = {} + game.save.itemsTaken = {} + game.save.inventory = game.save.inventory or {} + game.save.player.name = game.save.player.name or "RED" + -- one healthy mon: enough for a battle to construct, room for a prize + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + for k, v in pairs(flags or {}) do game.save.flags[k] = v end + game.stack:push(OW, "FIGHTING_DOJO", x, y, facing or "up") + U.wait(5) + return game.stack:top() + end + + ------------------------------------------------------------------ + -- BUG1/2/3 header seed sanity (deterministic, no battle needed) + ------------------------------------------------------------------ + local hdr = game.data:trainerHeader("FightingDojo", 1) + check(hdr ~= nil, "BUG1/2/3: Karate Master trainer header (index 1) exists") + check(hdr and (hdr.range or 0) > 0, "BUG1: master has a sight range") + check(hdr and hdr.won ~= nil, "BUG2: master has a won (defeat) text") + check(hdr and hdr.after ~= nil, "BUG3: master has an after (re-talk) text") + + ------------------------------------------------------------------ + -- BUG1: sight aggro. Stand directly below the master (5,3 faces DOWN, + -- range 4) with the four blackbelts pre-cleared so only he can engage. + ------------------------------------------------------------------ + local ow = resetDojo(5, 4, "up", {}) + U.shot(game, DIR .. "/dojo_1_before.png") + U.wait(20) -- the idle sight scan runs every frame + local engaged = ow.engaging or (ow.emote ~= nil) + U.log("aggro engaging:", tostring(ow.engaging), "emote:", tostring(ow.emote ~= nil)) + U.shot(game, DIR .. "/dojo_2_aggro.png") + check(engaged, "BUG1: Karate Master aggros on sight") + + ------------------------------------------------------------------ + -- BUG3: talk to the already-beaten master -> "Stay and train..." and + -- NOT the "I am the LEADER here!" pre-battle challenge. + ------------------------------------------------------------------ + -- talk from (4,3) facing right: beside the master (5,3), off his DOWN + -- sight line so he can't (post-fix) aggro before we set him defeated + ow = resetDojo(4, 3, "right", { EVENT_BEAT_KARATE_MASTER = true }) + game.save.defeatedTrainers["FIGHTING_DOJO_obj_1"] = true + local master = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER") + if check(master ~= nil, "BUG3: master npc present") then + ow:talkTo(master) + local first = waitReadyPage() + check(not first:find("LEADER", 1, true) and not first:find("Grunt", 1, true), + "BUG3: beaten master no longer shows the challenge (page1='" .. first .. "')") + check(sawText("Stay and train"), + "BUG3: beaten master says 'Stay and train at Karate with us!'") + U.shot(game, DIR .. "/dojo_3_retalk.png") + mashUntil(function() return game.stack:top() == ow end) + end + + ------------------------------------------------------------------ + -- BUG2: the post-battle prize speech (run the same reward path a win + -- takes; EVENT_BEAT_KARATE_MASTER must be unset so it prints). + ------------------------------------------------------------------ + ow = resetDojo(4, 9, "up", {}) + ow:checkVictoryRewards("OPP_BLACKBELT", 1) + U.wait(5) + check(sawText("prized"), + "BUG2: win shows the '...prized fighting POKeMON!' prize offer") + U.shot(game, DIR .. "/dojo_2_prize.png") + mashUntil(function() return game.stack:top() == ow end) + + ------------------------------------------------------------------ + -- BUG4 (verify-only): the Hitmonlee ball prompt is the Gen1 descriptor + -- ("You want the hard kicking HITMONLEE?"), not a Pokedex entry screen. + ------------------------------------------------------------------ + ow = resetDojo(4, 2, "up", { EVENT_BEAT_KARATE_MASTER = true }) + local leeBall = npcByName(ow, "FIGHTINGDOJO_HITMONLEE_POKE_BALL") + local chanBall = npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL") + check(leeBall ~= nil and chanBall ~= nil, "BUG5: both prize balls on the mat") + if leeBall then + ow:talkTo(leeBall) + check(sawText("hard kicking") or sawText("HITMONLEE"), + "BUG4: ball asks the Gen1 descriptor prompt (no dex entry)") + U.shot(game, DIR .. "/dojo_4_prompt.png") + ------------------------------------------------------------------ + -- BUG5: choose YES -> only the chosen ball vanishes; the other stays + -- and, when talked to, gives the "Better not get greedy..." refusal. + ------------------------------------------------------------------ + mashUntil(topIsChoice) + U.tap(game, "a") -- YES (index 1) + mashUntil(function() return game.stack:top() == ow end) + check(game.save.flags.EVENT_GOT_HITMONLEE == true, "BUG5: received HITMONLEE") + local leeGone = npcByName(ow, "FIGHTINGDOJO_HITMONLEE_POKE_BALL") == nil + local chanStays = npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL") ~= nil + check(leeGone, "BUG5: the chosen HITMONLEE ball is removed") + check(chanStays, "BUG5: the other (HITMONCHAN) ball stays on the mat") + U.shot(game, DIR .. "/dojo_5_onegone.png") + if chanStays then + ow:talkTo(npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL")) + check(sawText("greedy"), "BUG5: remaining ball gives the greedy refusal") + check(not game.save.flags.EVENT_GOT_HITMONCHAN, + "BUG5: talking the other ball does NOT hand a second POKeMON") + U.shot(game, DIR .. "/dojo_5_greedy.png") + mashUntil(function() return game.stack:top() == ow end) + end + end + + ------------------------------------------------------------------ + -- BUG6: the north-wall poster. A claimed prize frees its ball cell, so + -- stand on (4,1) facing up and read the poster above it. + ------------------------------------------------------------------ + ow = resetDojo(4, 1, "up", + { EVENT_BEAT_KARATE_MASTER = true, EVENT_GOT_HITMONLEE = true }) + Commands.hide_object({ game = game, save = game.save, overworld = ow }, + "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONLEE_POKE_BALL") + U.wait(3) + U.shot(game, DIR .. "/dojo_6_before.png") + ow:interact() + check(sawText("Enemies on every"), + "BUG6: the poster prints 'Enemies on every side!'") + U.shot(game, DIR .. "/dojo_6_poster.png") + mashUntil(function() return game.stack:top() == ow end) + + ------------------------------------------------------------------ + U.log("fighting_dojo_bug197_test: failures =", #failures) + for _, m in ipairs(failures) do U.log(" -", m) end + assert(#failures == 0, + "#197 unresolved:\n " .. table.concat(failures, "\n ")) + U.log("fighting_dojo_bug197_test: ok") +end diff --git a/tests/drivers/fly_indigo_bug203_test.lua b/tests/drivers/fly_indigo_bug203_test.lua new file mode 100644 index 00000000..ba4b79c1 --- /dev/null +++ b/tests/drivers/fly_indigo_bug203_test.lua @@ -0,0 +1,125 @@ +-- Driver: regression coverage for #203 "You can't fly to indigo plateau". +-- +-- pret/pokered engine/menus/town_map.asm LoadTownMap_Fly cycles EVERY visited +-- fly destination, and Indigo Plateau is a normal Fly spot once reached (its +-- special-warp lands the player on the Plateau exterior, data/maps/ +-- special_warps.asm). The port's fly-list filter gated each entry on +-- Map.isOutdoor(def), which is tileset == "OVERWORLD" -- but Indigo Plateau's +-- map uses tileset "PLATEAU", so it was silently dropped from the fly cursor +-- even though it is visited, has a fly warp, and sits in flyOrder. This driver +-- walks the party-menu FLY flow, asserts INDIGO_PLATEAU is a cyclable fly +-- destination, flies there, and lands on the Plateau exterior. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- Party slot 1 knows FLY. Pokemon.movesAtLevel never grants FLY, so inject + -- the move directly after construction (same setup as the #195 driver). + local flyer = Pokemon.new(game.data, "PIDGEOT", 40) + flyer.moves[1] = { id = "FLY", pp = 15 } + game.save.party = { flyer } + game.save.player.name = "bryan" + game.save.inventory = game.save.inventory or {} + game.save.inventory.THUNDERBADGE = true -- FLY submenu entry is badge-gated + game.save.visited = { + PALLET_TOWN = true, VIRIDIAN_CITY = true, INDIGO_PLATEAU = true, + } + + -- Exercise the real visited-marking path (OverworldController marks any + -- flyWarps map visited on entry) by standing on the Plateau exterior first, + -- then hop back to Pallet for a clean starting point. + U.teleport(game, "INDIGO_PLATEAU", 9, 6, "down") + U.wait(5) + assert(game.save.visited.INDIGO_PLATEAU, + "entering the Plateau exterior must mark INDIGO_PLATEAU visited") + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(5) + + -- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY) + Screens.push(game, "PartyMenu") + U.wait(5) + U.tap(game, "a") -- open the per-mon submenu + U.wait(2) + U.tap(game, "down") -- STATS -> SWITCH + U.wait(2) + U.tap(game, "down") -- SWITCH -> FLY + U.wait(2) + U.tap(game, "a") -- choose FLY + U.wait(5) + + local top = game.stack:top() + U.shot(game, DIR .. "/fly_map_screen.png") + U.wait(4) -- let the async png write land before any assert can quit us + + U.log("fly screen top screenId:", tostring(top and top.screenId), + "fly=", tostring(top and top.fly), "mode=", tostring(top and top.mode)) + local isFlyMap = top ~= nil and top.fly == true + and top.locs ~= nil and top.mode ~= nil + assert(isFlyMap, + "FLY must open the TOWN MAP in fly mode (LoadTownMap_Fly), got screen '" + .. tostring(top and top.screenId or "nil") .. "' fly=" + .. tostring(top and top.fly)) + + -- KEY ASSERTION (the #203 bug): INDIGO_PLATEAU must be a cyclable fly + -- destination. Before the fix the isOutdoor gate drops the PLATEAU-tileset + -- Plateau, so this list is missing it and the assert fails. + local hasIndigo = false + local listStr = {} + for i, id in ipairs(top.flyMapIds or {}) do + listStr[i] = id + if id == "INDIGO_PLATEAU" then hasIndigo = true end + end + U.log("fly destinations:", table.concat(listStr, ", ")) + assert(hasIndigo, + "INDIGO_PLATEAU must appear in the fly destination list (it is visited, " + .. "has a fly warp, and is in flyOrder); got { " + .. table.concat(listStr, ", ") .. " }") + + -- walk the cursor to INDIGO PLATEAU (banner reads "To INDIGO PLATEAU") + local function selectedMap() + return top.flyMapIds and top.flyMapIds[top.sel] + end + U.log("initial fly selection:", tostring(selectedMap())) + local guard = 0 + while selectedMap() ~= "INDIGO_PLATEAU" and guard < 12 do + U.tap(game, "down") + U.wait(2) + guard = guard + 1 + end + assert(selectedMap() == "INDIGO_PLATEAU", + "Up/Down must cycle the fly cursor to INDIGO_PLATEAU, landed on " + .. tostring(selectedMap())) + U.shot(game, DIR .. "/fly_indigo_selected.png") + U.wait(4) + + -- A flies to the highlighted destination: flyTo departs (48-frame bird), + -- then warps to the Plateau exterior. + U.tap(game, "a") + U.wait(3) + assert(game.overworld and game.overworld.flyDest + and game.overworld.flyDest.map == "INDIGO_PLATEAU", + "pressing A on the fly map must start the departure to INDIGO_PLATEAU, got " + .. tostring(game.overworld and game.overworld.flyDest + and game.overworld.flyDest.map)) + + -- past the bird sweep + warp transition: the player lands on the Plateau + local landed = false + for _ = 1, 240 do + if game.overworld and game.overworld.map + and game.overworld.map.id == "INDIGO_PLATEAU" then + landed = true + break + end + U.wait(1) + end + U.wait(6) + U.shot(game, DIR .. "/fly_landed_indigo.png") + U.wait(4) + assert(landed, "Fly must land the player on INDIGO_PLATEAU, ended on " + .. tostring(game.overworld and game.overworld.map and game.overworld.map.id)) + + U.log("RESULT bug203 PASS") +end diff --git a/tests/drivers/fly_townmap_bug195_test.lua b/tests/drivers/fly_townmap_bug195_test.lua new file mode 100644 index 00000000..1c12b228 --- /dev/null +++ b/tests/drivers/fly_townmap_bug195_test.lua @@ -0,0 +1,101 @@ +-- Driver: regression coverage for #195 "Fly doesn't show the map". +-- +-- pret/pokered engine/menus/town_map.asm LoadTownMap_Fly: choosing FLY from +-- the party field-move submenu opens the TOWN MAP with a blinking cursor that +-- cycles ONLY the visited fly destinations (Up/Down), A flies there, B cancels. +-- The port used to push a plain "FLY TO?" ListMenu (src/ui/FlyMenu.lua) +-- instead -- a text list, never the map (the how-it-is screenshot). This +-- driver walks Fly and asserts the TOWN MAP fly screen appears, that Up/Down +-- cycle the visited towns, and that A actually flies. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Screens = require("src.ui.Screens") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- Party slot 1 knows FLY. Pokemon.movesAtLevel never grants FLY, so inject + -- the move directly after construction. + local flyer = Pokemon.new(game.data, "PIDGEOT", 40) + flyer.moves[1] = { id = "FLY", pp = 15 } + game.save.party = { flyer } + game.save.player.name = "bryan" + game.save.inventory = game.save.inventory or {} + game.save.inventory.THUNDERBADGE = true -- FLY submenu entry is badge-gated + game.save.visited = { + PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true, + CERULEAN_CITY = true, CELADON_CITY = true, + } + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(5) + + -- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY) + Screens.push(game, "PartyMenu") + U.wait(5) + U.tap(game, "a") -- open the per-mon submenu + U.wait(2) + U.tap(game, "down") -- STATS -> SWITCH + U.wait(2) + U.tap(game, "down") -- SWITCH -> FLY + U.wait(2) + U.tap(game, "a") -- choose FLY + U.wait(5) + + local top = game.stack:top() + U.shot(game, DIR .. "/fly_map_screen.png") + U.wait(4) -- let the async png write land before any assert can quit us + + U.log("fly screen top screenId:", tostring(top and top.screenId), + "fly=", tostring(top and top.fly), "mode=", tostring(top and top.mode)) + local isFlyMap = top ~= nil and top.fly == true + and top.locs ~= nil and top.mode ~= nil + assert(isFlyMap, + "FLY must open the TOWN MAP in fly mode (LoadTownMap_Fly), got screen '" + .. tostring(top and top.screenId or "nil") .. "' fly=" + .. tostring(top and top.fly)) + + -- The cursor cycles ONLY the visited fly towns. Walk it to CELADON CITY. + local function selectedMap() + return top.flyMapIds and top.flyMapIds[top.sel] + end + U.log("initial fly selection:", tostring(selectedMap())) + local guard = 0 + while selectedMap() ~= "CELADON_CITY" and guard < 12 do + U.tap(game, "down") + U.wait(2) + guard = guard + 1 + end + assert(selectedMap() == "CELADON_CITY", + "Up/Down must cycle the fly cursor to CELADON_CITY, landed on " + .. tostring(selectedMap())) + U.shot(game, DIR .. "/fly_celadon_selected.png") + U.wait(4) + + -- A flies to the highlighted town: flyTo departs (48-frame bird), then warps. + U.tap(game, "a") + U.wait(3) + assert(game.overworld and game.overworld.flyDest + and game.overworld.flyDest.map == "CELADON_CITY", + "pressing A on the fly map must start the departure to CELADON_CITY, got " + .. tostring(game.overworld and game.overworld.flyDest + and game.overworld.flyDest.map)) + + -- past the bird sweep + warp transition: the player lands in Celadon + local landed = false + for _ = 1, 240 do + if game.overworld and game.overworld.map + and game.overworld.map.id == "CELADON_CITY" then + landed = true + break + end + U.wait(1) + end + U.wait(6) + U.shot(game, DIR .. "/fly_landed.png") + U.wait(4) + assert(landed, "Fly must land the player in CELADON_CITY, ended on " + .. tostring(game.overworld and game.overworld.map and game.overworld.map.id)) + + U.log("RESULT bug195 PASS") +end diff --git a/tests/drivers/gamecorner_rocket_bug198_test.lua b/tests/drivers/gamecorner_rocket_bug198_test.lua new file mode 100644 index 00000000..19c9c7f0 --- /dev/null +++ b/tests/drivers/gamecorner_rocket_bug198_test.lua @@ -0,0 +1,165 @@ +-- Driver: #198 Celadon Game Corner poster grunt exit. +-- The Rocket (GAMECORNER_ROCKET) guards the hideout poster at cell (9,5), +-- facing UP toward the poster / secret entrance at (9,4). After you beat +-- him he warns "Our hideout might be discovered! I better tell BOSS!" and, +-- in Gen1 (scripts/GameCorner.asm GameCornerRocketExitScript), walks UP one +-- tile into the poster (the hideout entrance) before HideObject despawns +-- him -- freeing (9,5) so the player can reach the poster switch. The bug +-- despawned him in place at (9,5) the instant the after-battle box closed. +-- +-- This driver talks to him, mashes through the battle to a win, advances +-- the after-battle text, then samples the grunt every frame: it must move +-- toward the poster (cellY/targetY north of its start) before it leaves +-- ow.npcs. Fails on the pre-fix instant-despawn, passes after the walk. +-- +-- SHOT_DIR=/tmp/gc198 POKEPORT_IDENTITY=bug198 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gamecorner_rocket_bug198_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") + + -- clean slate: the grunt must not already read as defeated/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" + + -- a tank that one-shots the whole party (OPP_ROCKET #7) so the mash win + -- is quick and deterministic regardless of type matchups + 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 } + + -- stand south of the grunt (9,6) facing up; grunt at (9,5) faces the + -- poster/secret entrance at (9,4) + U.teleport(game, "GAME_CORNER", 9, 6, "up") + local ow = game.overworld + + local function findGrunt() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "GAMECORNER_ROCKET" then return n end + end + return nil + 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 + + local grunt = findGrunt() + assert(grunt, "GAMECORNER_ROCKET not present at start") + local startX, startY = grunt.cellX, grunt.cellY + U.log("grunt start:", startX, startY, grunt.facing) + assert(startX == 9 and startY == 5, "grunt not at expected (9,5)") + U.shot(game, DIR .. "/gamecorner_rocket_0_before.png") + + -- Talk, then mash A through pre-battle text, the battle (select FIGHT + + -- first move), the won text ("Dang!"), until the after-battle "hideout" + -- text is on screen. Force-finish a stalled battle via onFinish("win") + -- (same safety valve as rival_walkoff_test) so the post-battle script + -- (which owns the exit walk) always runs. + U.tap(game, "a") + local sawAfter = false + for f = 1, 4000 do + if pageText():find("hideout", 1, true) then sawAfter = true break end + local top = game.stack:top() + if 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 stalled battle") + top.onFinish("win") + if game.stack:top() == top then game.stack:pop() end + end + elseif top ~= ow then + U.tap(game, "a") + elseif idle() and not findGrunt() then + break -- somehow already resolved + else + U.tap(game, "a") + end + U.wait(2) + end + U.log("saw after-battle text:", sawAfter, "defeated:", + tostring(game.save.defeatedTrainers["GAME_CORNER_obj_11"])) + U.shot(game, DIR .. "/gamecorner_rocket_1_afterbattle.png") + assert(sawAfter, "never reached the after-battle 'hideout' text") + + -- Dismiss the after-battle box. From here the fixed script queues a + -- one-tile scriptMove UP before hide_object; the buggy script removes + -- the grunt in place immediately. + U.tap(game, "a") + + -- Sample every logic frame. The scripted walk sets facing=up and + -- targetY=(startY-1) for ~16 frames, then lands cellY=startY-1 and the + -- onDone despawns him the same frame, so watch for either the in-motion + -- targetY or the transient landed cellY north of the start. + local walkedUp, walkShot = false, false + for _ = 1, 600 do + local g = findGrunt() + if g then + if g.cellY < startY or (g.targetY and g.targetY < startY) then + walkedUp = true + if not walkShot then + walkShot = true + U.shot(game, DIR .. "/gamecorner_rocket_2_walk.png") + end + end + else + if idle() then break end + 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 .. "/gamecorner_rocket_3_after.png") + + local toggles = game.save.objectToggles.GAME_CORNER + U.log("walkedUp:", walkedUp, "grunt gone:", findGrunt() == nil, + "toggle:", tostring(toggles and toggles.GAMECORNER_ROCKET)) + + -- CORRECT Gen1 behavior: he walks toward the poster before despawning. + assert(walkedUp, + "grunt never moved toward the poster before despawning (#198)") + assert(findGrunt() == nil, "GAMECORNER_ROCKET still present after exit") + assert(toggles and toggles.GAMECORNER_ROCKET == false, + "grunt objectToggle not hidden") + assert(game.save.defeatedTrainers["GAME_CORNER_obj_11"], + "grunt not recorded as defeated") + for _, n in ipairs(ow.npcs or {}) do + assert(not (n.cellX == startX and n.cellY == startY), + "an NPC still occupies the grunt's old tile (9,5)") + end + U.log("gamecorner_rocket_bug198_test: ok") +end diff --git a/tests/drivers/grass_overlay_bug150_test.lua b/tests/drivers/grass_overlay_bug150_test.lua new file mode 100644 index 00000000..4bda70ea --- /dev/null +++ b/tests/drivers/grass_overlay_bug150_test.lua @@ -0,0 +1,105 @@ +-- Visual + render-decision regression for #150 ("Grass transparency is off"). +-- +-- The reporter's should_be.png shows Red standing in Route 1 tall grass with a +-- GREEN cap that blends into the grass. The default SGB color mode instead +-- region-tints the player with the per-map SGB BG palette: Red's dark-gray cap +-- (DMG shade 2) maps to the ROUTE palette's shade-2 = light-blue (165,214,255) +-- (data/generated/palettes.lua ROUTE), so the character clashes with the grass. +-- On real GBC/SGB an OBJ carries its own object palette; OG RED already bakes +-- PaletteFX.ogObj() (green over Red, pink over Blue -- color/sprites.asm +-- ColorOverworldSprite) onto overworld characters. The fix makes SGB mode do +-- the same while leaving terrain on its per-map SGB palette. +-- +-- Gate (fails before the fix, passes after): +-- * PaletteFX.usesSpriteObp("gbc") == true +-- * the player SpriteRenderer bakes a distinct OBJ image in SGB mode +-- (resolveImage() ~= the raw grayscale sheet) -- the pixel path that +-- recolors the cap green +-- * that baked OBJ palette's shade-2 (the cap) is green, not ROUTE light-blue +-- Regression guard (must hold before AND after -- terrain is untouched): +-- * the ROUTE terrain palette still carries BOTH grass green (173,230,90) and +-- light-blue (165,214,255), so the grass field keeps its green+blue dither. +-- +-- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view) and +-- grass_bug150_ogred.png (OG RED reference -- green character, red terrain). +-- +-- Run: POKEPORT_DRIVER=tests/drivers/grass_overlay_bug150_test.lua \ +-- POKEPORT_IDENTITY=bug150 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local PaletteFX = require("src.render.PaletteFX") + local DIR = os.getenv("SHOT_DIR") or "." + + local fails = 0 + local function check(cond, msg) + if cond then U.log("ok: " .. msg) + else fails = fails + 1; U.log("FAIL: " .. msg) end + end + local function hasColor(pal, r, g, b) + if not pal then return false end + for i = 1, #pal do + if pal[i][1] == r and pal[i][2] == g and pal[i][3] == b then return true end + end + return false + end + + -- a party + starter flag so the overworld is fully usable + game.save.flags.EVENT_GOT_STARTER = true + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + + -- default SGB color mode. Set the SAVED option too: Game:applyOptions + -- re-reads save.options.colors, so a bare setMode() would get reverted. + game.save.options = game.save.options or {} + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + + U.teleport(game, "ROUTE_1", 10, 6, "down") + local ow = game.overworld + local p = ow.player + check(ow.map.id == "ROUTE_1", "on ROUTE_1") + check(ow.map:isGrassCell(p.cellX, p.cellY), + "player stands on a tall-grass cell (" .. p.cellX .. "," .. p.cellY .. ")") + + U.wait(40) -- let the grass/flower tile animation cycle + U.shot(game, DIR .. "/grass_bug150_sgb.png") + + -- === render-decision gate: fails before the fix, passes after ========= + check(PaletteFX.usesSpriteObp("gbc") == true, + "SGB mode bakes an OBJ palette onto overworld characters") + + -- the player's sprite must resolve to a baked OBJ image (not the raw + -- grayscale sheet) in SGB mode -- this is the exact path that colors the cap + local spr = p.sprite + check(spr and spr.image and spr:resolveImage() ~= spr.image, + "player sprite bakes a distinct OBJ image in SGB mode") + + -- the baked object palette's shade-2 (the cap) is a green, and specifically + -- NOT the ROUTE light-blue the region shader used to hand it + local obj = PaletteFX.ogObj() -- {white, brightgreen, darkgreen, black} + local cap = obj and obj[3] -- DMG shade 2 -> 3rd palette entry + check(cap and cap[2] > cap[1] and cap[2] > cap[3], + "OBJ cap color is green-dominant (g>r and g>b)") + check(cap and not (cap[1] == 165 and cap[2] == 214 and cap[3] == 255), + "OBJ cap color is NOT ROUTE light-blue (165,214,255)") + + -- === regression guard: terrain palette untouched ===================== + local terrain = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map)) + check(hasColor(terrain, 173, 230, 90), + "ROUTE terrain palette still contains grass green (173,230,90)") + check(hasColor(terrain, 165, 214, 255), + "ROUTE terrain palette still contains light-blue (grass keeps its blue dither)") + + -- OG RED reference for the human diff (green character over red terrain) + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + U.wait(20) + U.shot(game, DIR .. "/grass_bug150_ogred.png") + -- restore the default so the run doesn't end in a non-default mode + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + U.wait(2) + + if fails > 0 then error(fails .. " check(s) failed for #150") end + U.log("all #150 checks passed") +end diff --git a/tests/drivers/grass_seam_bug217_test.lua b/tests/drivers/grass_seam_bug217_test.lua new file mode 100644 index 00000000..de409036 --- /dev/null +++ b/tests/drivers/grass_seam_bug217_test.lua @@ -0,0 +1,63 @@ +-- Visual regression for #217: no phantom tall-grass tuft should be drawn +-- over the player's head while crossing the Viridian City -> Route 1 seam. +-- +-- The player walks south out of Viridian City's exit path (cellX = 20). On +-- the step off the south edge, crossConnection swaps in ROUTE_1 and parks the +-- player one cell before the entry point at cellY = -1 (off the top edge) for +-- the duration of the seam step. ROUTE_1's border block back-fills that +-- off-map row with the grass tile, so before the fix the "feet overdraw" +-- painted an animated grass tuft over the player's head for ~5 frames. +-- +-- Screenshots (paths come from SHOT_DIR): +-- grass_seam_during.png -- the seam step, map == ROUTE_1 and cellY < 0 +-- grass_seam_after.png -- one clean frame after the step lands (cellY >= 0) +-- +-- Run: POKEPORT_DRIVER=tests/drivers/grass_seam_bug217_test.lua \ +-- POKEPORT_IDENTITY=bug217 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + -- a party + starter flag so the overworld is fully usable + game.save.flags.EVENT_GOT_STARTER = true + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + + local shotDir = os.getenv("SHOT_DIR") or "." + -- south exit path column; four cells above the bottom edge (cellY 35) + U.teleport(game, "VIRIDIAN_CITY", 20, 31, "down") + local ow = game.overworld + + local shotDuring, shotAfter = false, false + for i = 1, 120 do + table.insert(game.input.pressQueue, "down") + game.input.state.down = true + coroutine.yield() + local p = ow.player + local inb = ow.map:inBounds(p.cellX, p.cellY) + U.log(("f=%d map=%-14s cell=(%d,%d) inb=%s grassCell=%s py=%d moving=%s") + :format(i, ow.map.id, p.cellX, p.cellY, tostring(inb), + tostring(ow.map:isGrassCell(p.cellX, p.cellY)), p.py, tostring(p.moving))) + + -- BEFORE the fix this is exactly the frame the phantom grass draws: + -- map is now ROUTE_1 but the player is still parked off the top edge. + if not shotDuring and ow.map.id == "ROUTE_1" and p.cellY < 0 then + game.input.state.down = false + U.shot(game, shotDir .. "/grass_seam_during.png") + shotDuring = true + game.input.state.down = true + end + + -- one clean frame after the seam step lands on the real map + if shotDuring and not shotAfter and ow.map.id == "ROUTE_1" + and p.cellY >= 0 and not p.moving then + game.input.state.down = false + U.shot(game, shotDir .. "/grass_seam_after.png") + shotAfter = true + break + end + end + game.input.state.down = false + U.wait(2) + + if not shotDuring then U.log("WARN: never captured the seam step frame") end + if not shotAfter then U.log("WARN: never captured a landed frame") end +end diff --git a/tests/drivers/link_any_level_test.lua b/tests/drivers/link_any_level_test.lua new file mode 100644 index 00000000..9dcc6b25 --- /dev/null +++ b/tests/drivers/link_any_level_test.lua @@ -0,0 +1,71 @@ +-- Driver: reproduce/verify #204 -- the PvP link/online battle crash when the +-- "level ruling" is left on ANY. +-- +-- The link level picker cycles a string sentinel "ANY" meaning "use each +-- mon's real level" (Gen1's link cable always fought at the real level, so +-- ANY is the project's name for "no forced level"). LinkState.levelForWire is +-- supposed to turn that sentinel into nil before it goes on the wire; a broken +-- `x and nil or y` idiom instead let the literal "ANY" reach opts.forceLevel, +-- and Protocol.unpackMon then crashed on math.floor("ANY") the instant newHost +-- unpacked the parties (report traceback: Protocol.lua unpackMon <- LinkBattle +-- unpackParty <- newHost <- LinkState.update). This drives that exact host +-- battle path in a real window with the bad value forceLevel = "ANY". +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Protocol = require("src.link.Protocol") + local Net = require("src.link.Net") + local LinkBattle = require("src.link.LinkBattle") + + -- a low-level host mon and a high-level foe: the ANY ruling must keep both + -- real levels (12 and 100), unlike an AUTO 50 ruling that forces them equal + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.player.name = "RED" + local foeParty = { Pokemon.new(game.data, "GEODUDE", 100) } + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(5) + + local netA = Net.loopbackPair() -- the guest end is unused: the intro is + -- local, and the crash (if present) fires + -- during newHost before any turn is taken + local opts = { + myParty = Protocol.packParty(game.save.party), + theirParty = Protocol.packParty(foeParty), + theirName = "BLUE", + seed = 24680, + forceLevel = "ANY", -- the exact value LinkState.levelForWire wrongly emitted + } + + local ok, battle = pcall(LinkBattle.newHost, game, netA, opts) + if not ok then + -- pre-fix: math.floor("ANY") throws inside unpackMon; the battle never + -- starts. Capture the still-in-overworld state and the error message + -- (the harness would otherwise just print "driver error" and exit, with + -- no on-screen error screen to shoot). + U.log("LINK_ANY_204: newHost CRASHED (bug present): " .. tostring(battle)) + U.shot(game, DIR .. "/link_any_204_crash.png") + U.log("LINK_ANY_204: done (crash reproduced)") + return + end + + -- post-fix: the battle constructs; push it and screenshot the intro/scene + game.stack:push(battle) + U.wait(20) + U.shot(game, DIR .. "/link_any_204_battle_intro.png") + U.wait(40) + U.shot(game, DIR .. "/link_any_204_battle_scene.png") + + local hostLvl = battle.player and battle.player.mon and battle.player.mon.level + local foeLvl = battle.enemy and battle.enemy.mon and battle.enemy.mon.level + U.log(("LINK_ANY_204: battle started; host lvl=%s foe lvl=%s (expect 12 / 100)") + :format(tostring(hostLvl), tostring(foeLvl))) + if hostLvl == 12 and foeLvl == 100 then + U.log("LINK_ANY_204: PASS -- real levels preserved under the ANY ruling") + else + U.log("LINK_ANY_204: FAIL -- levels not preserved (host=" .. + tostring(hostLvl) .. " foe=" .. tostring(foeLvl) .. ")") + end + U.log("LINK_ANY_204: done") +end diff --git a/tests/drivers/oak_early_battle_bug219_test.lua b/tests/drivers/oak_early_battle_bug219_test.lua new file mode 100644 index 00000000..68949927 --- /dev/null +++ b/tests/drivers/oak_early_battle_bug219_test.lua @@ -0,0 +1,162 @@ +-- Driver: regression coverage for #219 "Early Blue Battle". +-- +-- TEXT_OAKSLAB_RIVAL (data/scripts/oaks_lab.lua) is the rival's *talk* +-- handler. In pret/pokered scripts/OaksLab.asm OaksLabText8, talking to +-- the rival after you have a starter but before the lab battle only prints +-- _OaksLabRivalMyPokemonLooksStrongerText: the battle itself is a +-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6), +-- never a talk action. The buggy handler fell through from that line +-- straight into start_battle OPP_RIVAL1, so talking to Blue at the table +-- immediately launched the rival fight. +-- +-- Scenario A (the #219 regression): talk to the rival with a starter but +-- no lab battle yet. Correct: the "looks stronger" line shows and NO +-- battle starts. Fails before the fix (start_battle fires on talk). +-- Scenario B (guard against over-correction): step onto the coordinate +-- trigger (y >= 6). Correct: the onStep challenge still starts the +-- battle. Passes both before and after the fix. +-- +-- start_battle is stubbed to record the call and return "end" (halts the +-- script cleanly, no BattleState push, no yield) so the run never hangs +-- and needs no full party. We also read the raw text handed to +-- TextBox.new (before {PLAYER}/{RIVAL} substitution) so "stronger" is +-- detectable. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- Capture the raw text the next TextBox is built with. + local TextBox = require("src.render.TextBox") + local origNew = TextBox.new + local lastText + TextBox.new = function(g, text, ...) + lastText = text + return origNew(g, text, ...) + end + + -- Stub start_battle: record it and halt the script instead of pushing a + -- BattleState (which would need a real party) or yielding (which would + -- hang the driver). ScriptRunner resolves the live Commands.start_battle + -- when no mod overrides it, so this monkeypatch intercepts both the talk + -- handler and the onStep challenge. + local Commands = require("src.script.Commands") + local origStartBattle = Commands.start_battle + local battleStarted = false + Commands.start_battle = function(_ctx, _kind, _a, _b) + battleStarted = true + return "end" + end + + local function restore() + TextBox.new = origNew + Commands.start_battle = origStartBattle + end + + local function setFlags() + local flags = game.save.flags or {} + game.save.flags = flags + flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + flags.EVENT_GOT_STARTER = true + flags.EVENT_CHOSE_SQUIRTLE = true + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil + end + + -- ---- Scenario A: talk to the rival at the table. + -- fresh overworld in Oak's lab, player one cell right of the rival + -- (object 1 at cell 4,3) and facing him. + U.teleport(game, "OAKS_LAB", 5, 3, "left") + setFlags() + U.wait(6) + battleStarted = false + lastText = nil + + U.shot(game, DIR .. "/a_before.png") + + -- open the rival's textbox + U.tap(game, "left"); U.wait(2) + for _ = 1, 8 do + U.tap(game, "a") + for _ = 1, 30 do + if lastText then break end + U.wait(1) + end + if lastText then break end + end + local strongerSeen = lastText ~= nil and lastText:find("stronger") ~= nil + -- let the typewriter reveal the line, then shoot the box: the "looks + -- stronger" taunt, still overworld, no battle intro + U.wait(30) + U.shot(game, DIR .. "/a_after.png") + + -- dismiss the taunt box. Before the fix, closing it drops the script + -- into the buggy start_battle rows (battleStarted flips true); after the + -- fix it hits jump "end" and the stack settles back to the overworld. + -- Stop the moment either happens so we never re-open the box by talking + -- again (which would leave a stray TextBox on top and false-fail the + -- overworld check). + for _ = 1, 20 do + if battleStarted then break end + if game.stack:top() == game.overworld then break end + U.tap(game, "a") + U.wait(3) + end + U.wait(10) -- let any fall-through start_battle fire + + local aBattleStarted = battleStarted + local aOverworld = (game.stack:top() == game.overworld) + local aText = lastText or "" + local aPass = strongerSeen and (aBattleStarted == false) and aOverworld + U.log("SCENARIO A text:", aText) + U.log("SCENARIO A strongerSeen:", tostring(strongerSeen), + "battleStarted:", tostring(aBattleStarted), + "overworld:", tostring(aOverworld)) + U.log("SCENARIO A", aPass and "PASS" or "FAIL") + + -- close any open box before scenario B + for _ = 1, 10 do U.tap(game, "a"); U.wait(2) end + + -- ---- Scenario B: step onto the coordinate trigger (y >= 6). + -- Guards the real challenge path so the fix doesn't kill the lab battle. + U.teleport(game, "OAKS_LAB", 4, 5, "down") + setFlags() + U.wait(6) + battleStarted = false + do + local p = game.overworld.player + U.log("SCENARIO B start cell:", tostring(p.cellX), tostring(p.cellY), + "rival:", tostring(game.overworld:npcByIndex(1) ~= nil)) + end + -- walk down until the player crosses y == 6 (OaksLabRivalChallenges), + -- pausing to mash A so the "I'll take you on" box + rival walk advance + for _ = 1, 6 do + U.hold(game, "down", 8) + for _ = 1, 12 do + U.tap(game, "a") + U.wait(3) + if battleStarted then break end + end + if battleStarted then break end + end + do + local p = game.overworld.player + U.log("SCENARIO B end cell:", tostring(p.cellX), tostring(p.cellY), + "lastText:", tostring(lastText)) + end + U.shot(game, DIR .. "/b_after.png") + local bPass = (battleStarted == true) + U.log("SCENARIO B battleStarted:", tostring(battleStarted)) + U.log("SCENARIO B", bPass and "PASS" or "FAIL") + + -- restore hooks before any assert so a failure can't leave them installed + restore() + + U.log("RESULT bug219", (aPass and bPass) and "PASS" or "FAIL") + assert(aPass, + "Scenario A: talking to the rival must only show the 'looks stronger' " + .. "line and start no battle (strongerSeen/battleStarted=false/overworld); " + .. "text=" .. aText .. " battleStarted=" .. tostring(aBattleStarted)) + assert(bPass, + "Scenario B: stepping onto the coordinate trigger must still start the " + .. "lab battle") +end diff --git a/tests/drivers/oak_leave_block_bug232_test.lua b/tests/drivers/oak_leave_block_bug232_test.lua new file mode 100644 index 00000000..bb98a9b5 --- /dev/null +++ b/tests/drivers/oak_leave_block_bug232_test.lua @@ -0,0 +1,122 @@ +-- Driver: regression coverage for #232 "Hey! Don't go away yet!". +-- +-- The Oak leave-block in data/scripts/oaks_lab.lua onStep must halt a +-- pre-starter player at the bookshelf row (cell y == 6), the same +-- coordinate the sibling rival challenge below it uses. In +-- pret/pokered scripts/OaksLab.asm the "don't go away yet" intercept +-- and OaksLabRivalChallengesPlayerScript share the wYCoord == 6 +-- coordinate script slot (gated on EVENT_GOT_STARTER); the bookshelves +-- flank that corridor, so Oak stops you level with the shelves rather +-- than one full corridor later on the exit mat. +-- +-- Bug (before the fix): the guard read `y == 11 and (x == 4 or x == 5)`, +-- i.e. it only fired on the exit door mat. A pre-starter player walked +-- the whole corridor down through the shelves (y=6,7,8,9,10) and was only +-- halted at y=11, exactly the port screenshot the issue attached. +-- +-- OAKS_LAB is 10x12 cells (x 0-9, y 0-11). Live walkability: +-- y=5 .......... (open) +-- y=6 ####..#### (bookshelves solid at x=0-3/6-9, corridor at x=4,5) +-- y=7 ####..#### +-- y=10 .......... door mat warps at (4,11)/(5,11) +-- so at y>=6 only the x=4,5 corridor is walkable, and once Oak pushes the +-- player back to y=5 they can never reach y>=7 -- the trigger only ever +-- fires at y=6 in the corridor, matching the rival trigger's shape. +-- +-- We monkeypatch TextBox.new to detect the raw "Don't go" text and record +-- the player's cell Y at that instant (interceptY). No battle is involved +-- (EVENT_GOT_STARTER stays clear so the rival-challenge branch is skipped), +-- so nothing needs stubbing. + +return function(game) + io.stdout:setvbuf("no") -- LOVE block-buffers stdout; flush [driver] logs + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- Capture the raw text handed to TextBox and the Y it fired at. + local TextBox = require("src.render.TextBox") + local origNew = TextBox.new + local dontGoSeen = false + local interceptY = nil + TextBox.new = function(g, text, ...) + if type(text) == "string" and text:find("Don't go", 1, true) then + dontGoSeen = true + if game.overworld and game.overworld.player then + interceptY = interceptY or game.overworld.player.cellY + end + end + return origNew(g, text, ...) + end + + local function restore() + TextBox.new = origNew + end + + -- The pre-starter state right after the walk-in cutscene: FOLLOWED_OAK + -- set, no starter yet, no lab battle yet. + local function setFlags() + local flags = game.save.flags or {} + game.save.flags = flags + flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + flags.EVENT_GOT_STARTER = nil + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil + end + + -- story2.lua labWalkIn ends the follow-Oak cutscene at (5,3) facing down. + U.teleport(game, "OAKS_LAB", 5, 3, "down") + setFlags() + U.wait(6) + + local ow = game.overworld + U.log("start cell:", tostring(ow.player.cellX), tostring(ow.player.cellY), + "map:", ow.map.id) + U.shot(game, DIR .. "/bug232_start.png") + + -- Walk down toward the exit in bursts, mashing A to dismiss the "don't go + -- away" box each time it opens. Track how far down the player ever gets. + local maxY = ow.player.cellY + local shotIntercept = false + for _ = 1, 12 do + U.hold(game, "down", 8) + maxY = math.max(maxY, ow.player.cellY) + if dontGoSeen and not shotIntercept then + -- let the typewriter reveal the line before shooting the box + U.wait(20) + U.shot(game, DIR .. "/bug232_intercept.png") + shotIntercept = true + end + for _ = 1, 6 do + U.tap(game, "a") + U.wait(2) + maxY = math.max(maxY, ow.player.cellY) + end + maxY = math.max(maxY, ow.player.cellY) + U.log("iter cell:", tostring(ow.player.cellX), tostring(ow.player.cellY), + "map:", ow.map.id, "maxY:", maxY, "dontGoSeen:", tostring(dontGoSeen)) + if ow.map.id ~= "OAKS_LAB" then break end + end + + U.shot(game, DIR .. "/bug232_end.png") + + local finalMap = ow.map.id + U.log("RESULT dontGoSeen:", tostring(dontGoSeen), + "interceptY:", tostring(interceptY), "maxY:", maxY, + "finalMap:", finalMap, "finalY:", tostring(ow.player.cellY)) + + -- restore the hook before any assert so a failure can't leave it installed + restore() + + assert(dontGoSeen, + "Oak's 'don't go away yet' block must fire when a pre-starter player " + .. "walks toward the exit") + assert(interceptY == 6, + "Oak must intercept at the bookshelf row (cell y == 6), not the exit " + .. "mat; interceptY=" .. tostring(interceptY)) + assert(maxY <= 6, + "the player must never pass the bookshelves (y>=7) before Oak stops " + .. "them; maxY=" .. tostring(maxY)) + assert(finalMap == "OAKS_LAB", + "the pre-starter player must never warp out of the lab; finalMap=" + .. tostring(finalMap)) + U.log("RESULT bug232 PASS") +end diff --git a/tests/drivers/oak_rival_greeting_bug218_test.lua b/tests/drivers/oak_rival_greeting_bug218_test.lua new file mode 100644 index 00000000..7e71ea3f --- /dev/null +++ b/tests/drivers/oak_rival_greeting_bug218_test.lua @@ -0,0 +1,92 @@ +-- Driver: regression coverage for #218 "Blue mentions Oak being absent". +-- +-- TEXT_OAKSLAB_RIVAL (data/scripts/oaks_lab.lua) picks the pre-starter line. +-- pret/pokered scripts/OaksLab.asm gates that line on EVENT_FOLLOWED_OAK_INTO_LAB: +-- * flag CLEAR (Oak has not escorted you in yet) -> "Yo {PLAYER}! Gramps +-- isn't around!" (_OaksLabRivalGrampsIsntAroundText) +-- * flag SET (Oak present, three balls on the table) -> "Heh, I don't need +-- to be greedy... Go ahead and choose, {PLAYER}!" (_OaksLabRivalGoAheadAndChooseText) +-- The buggy handler jumped straight to GrampsIsntAround for any no-starter +-- state, so the rival wrongly claimed Oak was gone while Oak stood in the lab. +-- +-- We read the RAW text handed to TextBox.new (before pagination and {PLAYER}/ +-- {RIVAL} substitution) so the distinctive words "greedy"/"Gramps" survive. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- Capture the raw text the next TextBox is built with. + local TextBox = require("src.render.TextBox") + local origNew = TextBox.new + local lastText + TextBox.new = function(g, text, ...) + lastText = text + return origNew(g, text, ...) + end + + -- fresh overworld in Oak's lab, player standing one cell right of the + -- rival (object 1 at cell 4,3) and facing him. + local function setup(followed) + U.teleport(game, "OAKS_LAB", 5, 3, "left") + local flags = game.save.flags or {} + game.save.flags = flags + flags.EVENT_FOLLOWED_OAK_INTO_LAB = followed or nil + flags.EVENT_GOT_STARTER = nil + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil + U.wait(6) + end + + -- face the rival and press A until his text box opens (lastText set). + local function talkToRival() + lastText = nil + U.tap(game, "left"); U.wait(2) + for _ = 1, 5 do + U.tap(game, "a") + for _ = 1, 60 do + if lastText then return end + U.wait(1) + end + end + end + + -- close any open text box before the next scenario. + local function dismiss() + for _ = 1, 20 do U.tap(game, "a"); U.wait(2) end + end + + -- ---- Scenario A: Oak has escorted the player in, no starter chosen. + -- Correct Gen1 line: the greedy / "go ahead and choose" taunt. + setup(true) + U.shot(game, DIR .. "/a_before.png") + talkToRival() + U.wait(20) + U.shot(game, DIR .. "/a_after.png") + local aText = lastText or "" + local aPass = (aText:find("greedy") ~= nil) and (aText:find("Gramps") == nil) + U.log("SCENARIO A (FOLLOWED_OAK set) text:", aText) + U.log("SCENARIO A", aPass and "PASS" or "FAIL") + + dismiss() + + -- ---- Scenario B: very early game, Oak has not walked you in yet. + -- Correct Gen1 line: "Gramps isn't around". Guards the appended + -- jump_if_false path so the fix keeps the true-early-game text. + setup(false) + talkToRival() + U.wait(20) + U.shot(game, DIR .. "/b_after.png") + local bText = lastText or "" + local bPass = (bText:find("Gramps") ~= nil) + U.log("SCENARIO B (FOLLOWED_OAK clear) text:", bText) + U.log("SCENARIO B", bPass and "PASS" or "FAIL") + + -- restore before any assert so a failure can't leave the hook installed + TextBox.new = origNew + + U.log("RESULT bug218", (aPass and bPass) and "PASS" or "FAIL") + assert(aPass, + "Scenario A: rival must give the greedy/choose line while Oak is in the lab, got: " .. aText) + assert(bPass, + "Scenario B: rival must say Gramps isn't around before Oak escorts you, got: " .. bText) +end diff --git a/tests/drivers/oak_rival_smell_later_bug231_test.lua b/tests/drivers/oak_rival_smell_later_bug231_test.lua new file mode 100644 index 00000000..4c145ab0 --- /dev/null +++ b/tests/drivers/oak_rival_smell_later_bug231_test.lua @@ -0,0 +1,170 @@ +-- Driver: regression coverage for #231 "Missing Blue Dialogue (first rival +-- battle exit line)". +-- +-- The first lab rival battle is a coordinate trigger in +-- data/scripts/oaks_lab.lua onStep (OaksLabRivalChallengesPlayerScript, +-- wYCoord == 6). In pret/pokered scripts/OaksLab.asm the post-battle +-- OaksLabRivalEndBattleScript heals + flags, then on WIN prints the +-- "I picked the wrong POKéMON!" gloat, and on BOTH win and loss prints the +-- shared exit line _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON +-- fight to toughen it up!\012! Gramps! Smell you later!") before Blue +-- walks out. The buggy onStep sequence omitted that exit line entirely, so +-- Blue left the lab silently. +-- +-- Scenario WIN: after the battle Blue must gloat ("picked the wrong POKéMON") +-- AND say the exit line ("Smell you later"). +-- Scenario LOSS: Blue skips the gloat (that taunt was shown in-battle via +-- Rival1WinText) but must STILL say the exit line ("Smell you later"). +-- +-- Both scenarios FAIL before the fix (the exit line never appears) and PASS +-- after it. +-- +-- Mechanics: TextBox.new is hooked to APPEND every raw `text` arg (before +-- {PLAYER}/{RIVAL} substitution, so the token-free search substrings survive) +-- into `seen`; the whole SmellYouLater string, incl. the \012 page break, +-- arrives in one TextBox.new call. start_battle is stubbed to record the +-- result and RETURN NIL (not "end"): ScriptRunner then continues +-- synchronously into heal_party/set_flag/jump_if_false/show_text with NO +-- BattleState push and NO yield, so the run needs no party and cannot hang. +-- heal_party is a safe no-op on an empty party. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- accumulate every raw text a TextBox is built with + local TextBox = require("src.render.TextBox") + local origNew = TextBox.new + local seen = {} + TextBox.new = function(g, text, ...) + if type(text) == "string" then seen[#seen + 1] = text end + return origNew(g, text, ...) + end + + -- Stub start_battle: set the win/loss result the post-battle rows branch on + -- and return nil so ScriptRunner falls through to heal_party/set_flag/ + -- jump_if_false/show_text with no BattleState and no yield. ScriptRunner + -- resolves the live Commands.start_battle when no mod overrides it, so this + -- monkeypatch intercepts the onStep challenge (Commands.resolve). + local Commands = require("src.script.Commands") + local origStartBattle = Commands.start_battle + local winResult = true + Commands.start_battle = function(ctx, _kind, _a, _b) + ctx.lastBattleResult = winResult and "win" or "lose" + ctx.lastCheck = winResult -- Rival1: check = (result == "win") + return nil + end + + local function restore() + TextBox.new = origNew + Commands.start_battle = origStartBattle + end + + -- Set the pre-battle flag state AND clear the rival's object toggle. The + -- WIN sequence ends with hide_object OAKSLAB_RIVAL, which persists as + -- save.objectToggles.OAKS_LAB.OAKSLAB_RIVAL = false; without clearing it the + -- LOSS re-teleport spawns with the rival hidden and onStep (which returns + -- false when npcByIndex(1) is nil) never fires the challenge. Must run + -- BEFORE U.teleport, since the map spawns its objects on push. + local function resetSave() + local flags = game.save.flags or {} + game.save.flags = flags + flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + flags.EVENT_GOT_STARTER = true + flags.EVENT_CHOSE_SQUIRTLE = true + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil + if game.save.objectToggles and game.save.objectToggles.OAKS_LAB then + game.save.objectToggles.OAKS_LAB.OAKSLAB_RIVAL = nil + end + end + + local function seenHas(sub) + for _, t in ipairs(seen) do + if t:find(sub, 1, true) then return true end + end + return false + end + + -- Walk down onto the y>=6 trigger, then page through the post-battle boxes + -- and the walk-out. Bounded so the window always quits. Captures the exit + -- line box the first time "Smell you later" appears, and a final overworld + -- shot once Blue has despawned (hide_object at the end of the sequence). + local function drive(smellShot, endShot) + local grabbedSmell = false + for _ = 1, 500 do + if (not grabbedSmell) and seenHas("Smell you later") then + U.wait(18) -- let the typewriter reveal the line before the shot + U.shot(game, smellShot) + grabbedSmell = true + end + local rival = game.overworld:npcByIndex(1) + if rival == nil then break end -- rival walked out + hid: sequence done + -- before the trigger fires the player must step down onto y>=6; after, + -- A pages every text box (IllTakeYouOn, IPicked, SmellYouLater) + local p = game.overworld.player + if p and (p.cellY or 0) < 6 then + U.hold(game, "down", 8) + end + U.tap(game, "a") + U.wait(3) + end + U.wait(6) + U.shot(game, endShot) + return grabbedSmell + end + + -- ---- Scenario WIN + resetSave() + U.teleport(game, "OAKS_LAB", 4, 5, "down") + U.wait(6) + winResult = true + seen = {} + do + local p = game.overworld.player + U.log("WIN start cell:", tostring(p.cellX), tostring(p.cellY), + "rival:", tostring(game.overworld:npcByIndex(1) ~= nil)) + end + U.shot(game, DIR .. "/win_before.png") + drive(DIR .. "/win_smell.png", DIR .. "/win_end.png") + local winGloat = seenHas("picked the") + local winSmell = seenHas("Smell you later") + local winPass = winGloat and winSmell + U.log("WIN gloat(picked the):", tostring(winGloat), + "exit(Smell you later):", tostring(winSmell)) + U.log("WIN", winPass and "PASS" or "FAIL") + + -- ---- Scenario LOSS + resetSave() + U.teleport(game, "OAKS_LAB", 4, 5, "down") + U.wait(6) + winResult = false + seen = {} + do + local p = game.overworld.player + U.log("LOSS start cell:", tostring(p.cellX), tostring(p.cellY), + "rival:", tostring(game.overworld:npcByIndex(1) ~= nil)) + end + U.shot(game, DIR .. "/loss_before.png") + drive(DIR .. "/loss_smell.png", DIR .. "/loss_end.png") + local lossGloat = seenHas("picked the") + local lossSmell = seenHas("Smell you later") + -- loss must skip the win gloat but still print the shared exit line + local lossPass = lossSmell and (not lossGloat) + U.log("LOSS gloat(picked the):", tostring(lossGloat), + "exit(Smell you later):", tostring(lossSmell)) + U.log("LOSS", lossPass and "PASS" or "FAIL") + + -- restore hooks before any assert so a failure can't leave them installed + restore() + + U.log("RESULT bug231", (winPass and lossPass) and "PASS" or "FAIL") + assert(winPass, + "WIN: after the first lab rival battle Blue must gloat " + .. "('I picked the wrong POKéMON!') AND say the exit line " + .. "('Smell you later'); got gloat=" .. tostring(winGloat) + .. " exit=" .. tostring(winSmell)) + assert(lossPass, + "LOSS: after losing the first lab rival battle Blue must skip the gloat " + .. "but STILL say the exit line ('Smell you later'); got gloat=" + .. tostring(lossGloat) .. " exit=" .. tostring(lossSmell)) +end diff --git a/tests/drivers/ogblue_palette_bug155_test.lua b/tests/drivers/ogblue_palette_bug155_test.lua new file mode 100644 index 00000000..f6140237 --- /dev/null +++ b/tests/drivers/ogblue_palette_bug155_test.lua @@ -0,0 +1,107 @@ +-- Driver: OG BLUE (GBC boot-ROM) palette correctness for Pokemon Blue (#155). +-- +-- Pokemon Blue, like Red, ships no CGB code, so a Game Boy Color colorizes it +-- from the boot ROM's per-game auto-palette table. Blue's entry is NOT a +-- mirror of Red's and does NOT share Red's green characters: per Bulbapedia's +-- Generation-I GBC boot-ROM palette table (and the Gambatte hardware capture +-- attached to #155) Blue is a light-blue/blue BACKGROUND +-- with a PINK object (OBP0) palette -- the same red/pink ramp Red uses for its +-- BACKGROUND. The port had baked a fabricated "channel-swapped Red" BG and +-- kept Red's green sprites for both versions, so a Blue playthrough in COLORS = +-- OG rendered periwinkle terrain and a green player instead of blue terrain and +-- a pink player. +-- +-- These checks are pure-data (version-forced via GameVersion.set) so they run +-- even on a Red-only cache; the two screenshots force the Blue OG palette over +-- the overworld for visual before/after evidence. Ground-truth RGB below is +-- Bulbapedia BG 0xFFFFFF/0x63A5FF/0x0000FF/0x000000, OBP0 +-- 0xFFFFFF/0xFF8484/0x943A3A/0x000000. +-- +-- Run: SHOT_DIR=/tmp/ogblue POKEPORT_IDENTITY=bug155 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/ogblue_palette_bug155_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local PaletteFX = require("src.render.PaletteFX") + local GameVersion = require("src.core.GameVersion") + local DIR = os.getenv("SHOT_DIR") or "." + + local fails = 0 + local function expect(cond, ...) + if not cond then fails = fails + 1 end + U.log(cond and "PASS" or "FAIL", ...) + end + + -- deep-equal for a 4-color {r,g,b} palette table + local function palEq(a, b) + if type(a) ~= "table" or type(b) ~= "table" or #a ~= #b then return false end + for i = 1, #a do + local ca, cb = a[i], b[i] + if type(ca) ~= "table" or type(cb) ~= "table" then return false end + if ca[1] ~= cb[1] or ca[2] ~= cb[2] or ca[3] ~= cb[3] then return false end + end + return true + end + + local TRUE_BG = { {255,255,255}, {99,165,255}, {0,0,255}, {0,0,0} } + local TRUE_OBJ = { {255,255,255}, {255,132,132}, {148,58,58}, {0,0,0} } + + -- (1) the background constant is the real GBC Blue BG, not the periwinkle + -- 0x8484FF/0x3A3A94 mirror of Red + expect(palEq(PaletteFX.GBC_BG_BLUE, TRUE_BG), + "GBC_BG_BLUE == real GBC Blue BG 0x63A5FF/0x0000FF, got", + PaletteFX.GBC_BG_BLUE and PaletteFX.GBC_BG_BLUE[2] + and table.concat(PaletteFX.GBC_BG_BLUE[2], ",")) + + -- (2) a dedicated Blue OBJ (OBP0) constant exists and is the pink ramp + expect(PaletteFX.GBC_OBJ_BLUE ~= nil and palEq(PaletteFX.GBC_OBJ_BLUE, TRUE_OBJ), + "GBC_OBJ_BLUE == real GBC Blue OBP0 pink 0xFF8484/0x943A3A") + -- (3) ... and it is NOT the green Red OBJ palette + expect(PaletteFX.GBC_OBJ_BLUE ~= nil + and not palEq(PaletteFX.GBC_OBJ_BLUE, PaletteFX.GBC_OBJ), + "Blue OBJ is NOT the green Red GBC_OBJ") + + -- (4) version routing: as Blue, the OG helpers resolve Blue's palettes + local savedVer = GameVersion.get() + GameVersion.set("blue") + expect(palEq(PaletteFX.ogBg(), TRUE_BG), "ogBg() -> Blue BG when isBlue()") + expect(type(PaletteFX.ogObj) == "function", "PaletteFX.ogObj() helper exists") + if type(PaletteFX.ogObj) == "function" then + local c = PaletteFX.ogObj() + expect(palEq(c, TRUE_OBJ), "ogObj() -> Blue pink OBJ when isBlue()") + end + + -- (5) version routing: as Red, the OG helpers still resolve Red's palettes + -- (green player over the red field is correct and must not regress) + GameVersion.set("red") + expect(palEq(PaletteFX.ogBg(), PaletteFX.GBC_BG), "ogBg() -> Red BG when Red") + if type(PaletteFX.ogObj) == "function" then + expect(palEq(PaletteFX.ogObj(), PaletteFX.GBC_OBJ), + "ogObj() -> green Red OBJ when Red") + end + + -- Visual proof: force Blue's OG palette over the (Red-cache) overworld. The + -- terrain colors come from ogBg() and the player sprite from ogObj(), so the + -- shot exercises the exact code the fix touches. Before: periwinkle terrain + -- + green player; after: light-blue/blue terrain + pink player, matching + -- the Gambatte capture attached to #155. + GameVersion.set("blue") + game.save.options = game.save.options or {} + game.save.options.colors = "ogred" + PaletteFX.setMode("ogred") + + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { Pokemon.new(game.data, "SQUIRTLE", 5) } + + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(60) + U.shot(game, DIR .. "/ogblue_01_route1.png") + + U.teleport(game, "PALLET_TOWN", 5, 6, "down") + U.wait(60) + U.shot(game, DIR .. "/ogblue_02_pallet.png") + + GameVersion.set(savedVer) + + if fails > 0 then error(fails .. " check(s) failed") end + U.log("all checks passed") +end diff --git a/tests/drivers/party_bug147_message_test.lua b/tests/drivers/party_bug147_message_test.lua new file mode 100644 index 00000000..cff18bbe --- /dev/null +++ b/tests/drivers/party_bug147_message_test.lua @@ -0,0 +1,74 @@ +-- Driver: party menu bottom context message (#147). +-- Gen1 (pokered engine/menus/party_menu.asm PartyMenuMessage) always prints +-- a message in the bottom text box: "Choose a POKéMON." (PartyMenuNormalText) +-- in the field, "Bring out which POKéMON?" (PartyMenuBattleText) in battle. +-- The recomp handled only the swap / item / TM-HM ids and printed NOTHING for +-- the default field and battle voluntary-switch cases -- reporter's "NO TEXT +-- BOX". This driver opens the party menu in both contexts, screenshots each, +-- and asserts PartyMenu:bottomMessage() returns the correct Gen1 string. +-- POKEPORT_DRIVER=tests/drivers/party_bug147_message_test.lua \ +-- POKEPORT_IDENTITY=bug147 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 Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Screens = require("src.ui.Screens") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1; U.log("PASS", label) + else fail = fail + 1; U.log("FAIL", label) end + end + + game.save.party = { + Pokemon.new(game.data, "CHARMANDER", 12), + Pokemon.new(game.data, "SQUIRTLE", 10), + } + + -- FIELD case: party menu opened from the overworld (StartMenu path). + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + Screens.push(game, "PartyMenu") + U.wait(8) + U.shot(game, DIR .. "/party_field_message.png") + local pm = game.stack:top() + local fieldMsg = pm and pm.bottomMessage and pm:bottomMessage() + U.log("field bottomMessage:", tostring(fieldMsg)) + check("field message == 'Choose a POKéMON.'", + fieldMsg == "Choose a POKéMON.") + + -- back to the overworld before starting the battle + while game.stack:top() and game.stack:top() ~= ow do game.stack:pop() end + U.wait(2) + + -- BATTLE case: voluntary PKMN switch (BattleState:openParty). + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function mashUntil(cond, max) + for _ = 1, max or 80 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + check("reached battle menu", mashUntil(function() + return battle.phase == "menu" + end)) + + -- FIGHT/PKMN/ITEM/RUN: RIGHT to PKMN, then A to open the party + U.tap(game, "right"); U.wait(4) + U.tap(game, "a"); U.wait(12) + U.shot(game, DIR .. "/party_battle_message.png") + pm = game.stack:top() + local battleMsg = pm and pm.bottomMessage and pm:bottomMessage() + U.log("battle party open (onSwitch set):", pm and pm.onSwitch ~= nil) + U.log("battle bottomMessage:", tostring(battleMsg)) + check("battle message == 'Bring out which\\nPOKéMON?'", + battleMsg == "Bring out which\nPOKéMON?") + + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) +end diff --git a/tests/drivers/pc_bug228_test.lua b/tests/drivers/pc_bug228_test.lua new file mode 100644 index 00000000..f6a7019e --- /dev/null +++ b/tests/drivers/pc_bug228_test.lua @@ -0,0 +1,74 @@ +-- Driver: the bedroom PC (Red's House 2F) must open the player's item PC +-- directly (WITHDRAW/DEPOSIT/TOSS/LOG OFF), NOT the Pokemon Center multi-PC +-- main menu (SOMEONE'S PC / 's PC / LOG OFF). #228 +-- +-- pokered: the bedroom PC's hidden-object callback is OpenRedsPC +-- (engine/events/hidden_objects/players_pc.asm) which runs the PlayerPC +-- predef, versus the Pokemon Center PC callback which shows DisplayPCMainMenu. +-- +-- SHOT_DIR=/tmp/bug228 POKEPORT_DRIVER=tests/drivers/pc_bug228_test.lua \ +-- POKEPORT_IDENTITY=bug228 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Menu = require("src.ui.Menu") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + + -- Stand at (0,2) facing up so facingCell() = (0,1), the bedroom PC tile + -- (field.lua hiddenExtras.pcTiles.REDS_HOUSE_2F = {{facing="up",x=0,y=1}}). + U.teleport(game, "REDS_HOUSE_2F", 0, 2, "up") + -- teleport bypasses New Game, which seeds pcItems={POTION=1} + -- (src/core/SaveData.lua); seed it so the withdraw list has content. + game.save.pcItems = game.save.pcItems or { POTION = 1 } + + U.tap(game, "a") -- interact -> tryHiddenObject -> the bedroom PC + U.wait(8) + + local menu = game.stack:top() + check(getmetatable(menu) == Menu, "a PC menu opened on A-press") + U.shot(game, DIR .. "/pc_bug228_bedroom_menu.png") + + -- Inspect labels: the multi-PC main menu carries SOMEONE'S/BILL'S/'s + -- PC entries; the player's item PC does not. + local labels = {} + if menu and menu.items then + for _, it in ipairs(menu.items) do + labels[#labels + 1] = tostring(it.label) + end + end + U.log("labels: " .. table.concat(labels, " | ")) + + local function has(pat) + for _, l in ipairs(labels) do + if l:lower():find(pat, 1, true) then return true end + end + return false + end + + -- BUG present if the multi-PC main menu opened. + check(not has("someone"), "no SOMEONE'S PC entry (not the box-PC main menu)") + check(not has("bill"), "no BILL'S PC entry") + check(not has("prof.oak"),"no PROF.OAK's PC entry") + check(not has("'s pc"), "no 's PC entry (bedroom PC skips box storage)") + + -- CORRECT: the player's item PC opened first-row WITHDRAW ITEM. + check(has("withdraw item"), "player item PC opened (WITHDRAW ITEM present)") + check(menu and menu.items and menu.items[1] + and tostring(menu.items[1].label) == "WITHDRAW ITEM", + "first row is WITHDRAW ITEM") + + -- Visual proof: open the withdraw list to show the seeded POTION. + if menu and menu.items and menu.items[1] + and tostring(menu.items[1].label) == "WITHDRAW ITEM" then + U.tap(game, "a") -- WITHDRAW ITEM + U.wait(8) + U.shot(game, DIR .. "/pc_bug228_withdraw.png") + end + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") + love.event.quit(pass and 0 or 1) +end diff --git a/tests/drivers/prize_room_coincase_bug194_test.lua b/tests/drivers/prize_room_coincase_bug194_test.lua new file mode 100644 index 00000000..32a8b000 --- /dev/null +++ b/tests/drivers/prize_room_coincase_bug194_test.lua @@ -0,0 +1,127 @@ +-- Driver: #194 Celadon prize room must require the COIN CASE. +-- engine/menus/prize_menu.asm CeladonPrizeMenu gates the prize window on the +-- COIN CASE: IsItemInBag COIN_CASE first, and with no case it prints +-- RequireCoinCaseText and returns without ever opening a window; only with the +-- case does it print ExchangeCoinsForPrizesText and then show the prize list. +-- The port used to open "PRIZES (COINS)" unconditionally with no intro line. +-- +-- The three prize counters are bg-event signs at cells (2,2),(4,2),(6,2) in +-- GAME_CORNER_PRIZE_ROOM (data/generated/maps.lua). Stand south of vendor 1 +-- and press A: no-case -> require box and NO list; has-case -> exchange box, +-- then the prize list; cancel returns to the overworld (onCancel == done). +-- +-- SHOT_DIR=/tmp/prize194 POKEPORT_IDENTITY=bug194 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/prize_room_coincase_bug194_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 ListMenu = require("src.ui.ListMenu") + + -- a party so nothing else blocks overworld interaction + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) } + game.save.inventory = game.save.inventory or {} + game.save.coins = game.save.coins or 0 + + local function topMeta() return getmetatable(game.stack:top()) end + -- let a TextBox finish typing its current page (self.waiting) so shots + -- capture the full line instead of a mid-typewriter frame + local function settleText(maxFrames) + for _ = 1, maxFrames or 40 do + local top = game.stack:top() + if getmetatable(top) == TextBox and top.waiting then break end + U.wait(1) + 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 + + -- ===== CASE 1: NO COIN CASE -> require box, prize list NEVER opens ===== + game.save.inventory.COIN_CASE = nil + -- stand at (2,3) facing up; the faced cell is vendor sign 1 at (2,2) + U.teleport(game, "GAME_CORNER_PRIZE_ROOM", 2, 3, "up") + local ow = game.overworld + U.shot(game, DIR .. "/prize_room_0_before.png") + + U.tap(game, "a") + -- scan several frames: a ListMenu must NEVER appear without the case + local sawListNoCase, sawRequire = false, false + for _ = 1, 60 do + if topMeta() == ListMenu then sawListNoCase = true end + if topMeta() == TextBox and pageText():find("COIN CASE", 1, true) then + sawRequire = true + end + U.wait(1) + end + U.log("no-case: sawRequire", sawRequire, "sawList", sawListNoCase) + settleText(40) + U.shot(game, DIR .. "/prize_room_1_nocase_requiretext.png") + assert(sawRequire, + "no-case: 'A COIN CASE is required!' box never shown (#194)") + assert(not sawListNoCase, + "no-case: prize ListMenu opened without a COIN CASE (#194)") + + -- dismiss the require box; back to the overworld with no list ever opened + U.tap(game, "a") + for _ = 1, 30 do + if game.stack:top() == ow then break end + U.tap(game, "a") + U.wait(1) + end + assert(game.stack:top() == ow, "no-case: did not return to overworld") + + -- ===== CASE 2: HAS COIN CASE -> exchange box, then the prize list ===== + game.save.inventory.COIN_CASE = 1 + U.tap(game, "a") -- talk to the same vendor sign again + local sawExchange = false + for _ = 1, 60 do + if topMeta() == TextBox and + (pageText():find("exchange", 1, true) or + pageText():find("coins for prizes", 1, true)) then + sawExchange = true + break + end + U.wait(1) + end + U.log("has-case: sawExchange", sawExchange) + settleText(140) + U.shot(game, DIR .. "/prize_room_2_exchange.png") + assert(sawExchange, + "has-case: 'We exchange your coins for prizes.' never shown (#194)") + + -- advance past the exchange line; the prize list must then open + local sawList = false + for _ = 1, 60 do + if topMeta() == ListMenu then sawList = true break end + U.tap(game, "a") + U.wait(1) + end + U.log("has-case: sawList", sawList, "title", + (sawList and game.stack:top().title) or "-") + U.shot(game, DIR .. "/prize_room_3_menu.png") + assert(sawList, "has-case: prize ListMenu never opened after exchange text") + assert(game.stack:top().title == "PRIZES (COINS)", + "has-case: opened list is not the prize window") + + -- cancel returns to the overworld (onCancel == done) + U.tap(game, "b") + for _ = 1, 30 do + if game.stack:top() == ow then break end + U.wait(1) + end + assert(game.stack:top() == ow, "has-case: cancel did not return to overworld") + U.log("prize_room_coincase_bug194_test: ok") +end diff --git a/tests/drivers/reds_house_stairs_bug230_test.lua b/tests/drivers/reds_house_stairs_bug230_test.lua new file mode 100644 index 00000000..72b09af3 --- /dev/null +++ b/tests/drivers/reds_house_stairs_bug230_test.lua @@ -0,0 +1,115 @@ +-- Driver: Red's-house corner staircase warp (issue #230). +-- +-- REDS_HOUSE_1F/2F share an 8x8 layout with the stairs warp on the top-right +-- corner cell (7,1); the cell to its right is the map edge (widthCells-1==7), +-- so Warp.extraCheck's facingEdge branch answers "yes" to a right-bonk. Two +-- Gen1 invariants this driver pins: +-- +-- 1. The warp cell you ARRIVE on is inert until you physically step off it +-- (CheckWarpsNoCollision / the arrival-disable in the completed-step +-- path). Holding right into the east wall while standing on (7,1) must +-- NOT re-fire the collision warp -- pre-fix it ping-ponged 1F<->2F every +-- input frame forever. +-- 2. A genuine wall bonk still animates the walk cycle in place (the +-- collision path runs UpdateSprites), so player:walkPhase() must reach 1 +-- during the bonk while the cell stays put. +-- +-- The stairs must still warp normally once the player steps off (7,1) and +-- back onto it, so the guard cannot break legitimate staircases. +-- +-- Run: +-- POKEPORT_DRIVER=tests/drivers/reds_house_stairs_bug230_test.lua \ +-- POKEPORT_IDENTITY=bug230 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local shotDir = os.getenv("POKEPORT_SHOTDIR") or "." + local function shot(name) U.shot(game, shotDir .. "/" .. name) end + + local ow + local fails = 0 + local function expect(cond, ...) + if not cond then fails = fails + 1 end + U.log(cond and "PASS" or "FAIL", ...) + end + local function settle(mapId) + for _ = 1, 300 do + ow = game.overworld + if ow and ow.map.id == mapId and not ow.transitioning + and #ow.scriptMoves == 0 and not ow.player.moving then + break + end + U.wait(1) + end + U.wait(4) + ow = game.overworld + end + + -- 1) Arrive on the 2F stairs via a REAL warp so warpEntryCell/justWarped + -- are set. Teleporting straight onto (7,1) bypasses takeWarp and would + -- never set the arrival-inert state, so the bug could not reproduce -- + -- we must walk up onto the 1F stairs and let the warp carry us. + U.teleport(game, "REDS_HOUSE_1F", 7, 3, "up") + settle("REDS_HOUSE_1F") + U.hold(game, "up", 40) + settle("REDS_HOUSE_2F") + expect(ow.map.id == "REDS_HOUSE_2F", "arrived upstairs, map:", ow.map.id) + expect(ow.player.cellX == 7 and ow.player.cellY == 1, + "standing on the stairs cell (7,1), got:", + ow.player.cellX, ow.player.cellY) + shot("reds230_arrive.png") + + -- 2) Ping-pong guard (Fix 1) AND walk-in-place (Fix 2): hold right into the + -- east wall for 150 frames. Record every distinct floor id visited and + -- whether the sprite ever animates a walk frame. + local floors, order, seenPhase1 = {}, {}, false + do + local last + for _ = 1, 150 do + table.insert(game.input.pressQueue, "right") + game.input.state["right"] = true + coroutine.yield() -- Game:update runs here, processing this frame's input + local o = game.overworld + if o then + if o.map.id ~= last then table.insert(order, o.map.id); last = o.map.id end + floors[o.map.id] = true + if o.player:walkPhase() == 1 then seenPhase1 = true end + end + end + game.input.state["right"] = false + end + settle("REDS_HOUSE_2F") + shot("reds230_after_hold.png") + + local distinct = 0 + for _ in pairs(floors) do distinct = distinct + 1 end + expect(distinct == 1 and floors["REDS_HOUSE_2F"] == true, + "no floor ping-pong during the hold; distinct floors:", distinct, + "sequence:", table.concat(order, ">")) + expect(ow.map.id == "REDS_HOUSE_2F", "still upstairs after the hold, map:", + ow.map.id) + expect(ow.player.cellX == 7 and ow.player.cellY == 1, + "bonked in place, still at (7,1), got:", + ow.player.cellX, ow.player.cellY) + expect(not ow.player.moving and not ow.transitioning, + "settled after the hold, not mid-move/transition") + expect(seenPhase1, + "walk-in-place: player:walkPhase() reached 1 during the bonk") + + -- 3) Anti-over-fix: the guard clears the instant the player steps off the + -- warp cell, so stepping south (off (7,1)) then back north still takes + -- the stairs. The exact southern cell does not matter -- only that we + -- leave (7,1) and that re-entering it still fires the warp. + U.hold(game, "down", 20) + settle("REDS_HOUSE_2F") + expect(ow.player.cellX == 7 and ow.player.cellY >= 2, + "stepped south off the stairs cell, got:", + ow.player.cellX, ow.player.cellY) + U.hold(game, "up", 40) + settle("REDS_HOUSE_1F") + expect(ow.map.id == "REDS_HOUSE_1F", + "stairs still warp after stepping off and back on, map:", ow.map.id) + + if fails > 0 then error(fails .. " check(s) failed") end + U.log("all checks passed") +end diff --git a/tests/drivers/rocket_hideout_gate_bug199_test.lua b/tests/drivers/rocket_hideout_gate_bug199_test.lua new file mode 100644 index 00000000..21400417 --- /dev/null +++ b/tests/drivers/rocket_hideout_gate_bug199_test.lua @@ -0,0 +1,109 @@ +-- Driver: Rocket Hideout elevator gates (#199). +-- +-- Gen1 stamps a closed barred gate over the elevator doorway on every map +-- load, opening it only once the guarding Rockets are beaten: +-- scripts/RocketHideoutB1F.asm RocketHideoutB1FDoorCallbackScript +-- closed block $54 at (12,8), open block $0e; opens once +-- EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 is set (the guard by the lift; +-- EVENT_ENTERED_ROCKET_HIDEOUT is never SetEvent -- the SFX bug noted +-- in the source -- so it is not part of the effective open condition). +-- scripts/RocketHideoutB4F.asm RocketHideoutB4FDoorCallbackScript +-- closed block $2d at (12,5), open block $0e; opens once BOTH +-- EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 and _1 are set. +-- B2F/B3F call no door callback (spinner-tile floors), so they get no gate. +-- +-- The recomp shipped no stamping, so the .blk's open floor block ($0e/14) +-- showed through -- the reporter's "gate is open" screenshots. Pre-fix the +-- closed-gate asserts read 14 and fail; post-fix they read the barred block. +-- +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/rocket_hideout_gate_bug199_test.lua \ +-- POKEPORT_IDENTITY=bug199 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 OW = require("src.world.OverworldController") + + local failures = {} + local function check(cond, msg) + if cond then U.log("ok:", msg) else + table.insert(failures, msg); U.log("FAIL:", msg) + end + return cond + end + + -- swap map, wiping event flags to a known slate, then settle until the + -- map has loaded and the player is idle so the door callback has stamped + local function load(mapId, x, y, flags) + while game.stack:top() do game.stack:pop() end + game.save.flags = {} + for k, v in pairs(flags or {}) do game.save.flags[k] = v end + game.stack:push(OW, mapId, x, y, "down") + for _ = 1, 240 do + local ow = game.overworld + if ow and ow.map and ow.map.id == mapId and not ow.transitioning + and not (ow.player and ow.player.moving) then + break + end + U.wait(1) + end + U.wait(3) + return game.overworld + end + + local OPEN = 14 -- $0e floor block (doorway when unlocked) + local CLOSED_B1F = 84 -- $54 barred gate (bars in the north cell-row) + local CLOSED_B4F = 45 -- $2d barred gate (bars in the south cell-row) + + -- B1F: barred until the lift guard (trainer 4) is beaten + local ow = load("ROCKET_HIDEOUT_B1F", 24, 14) + U.shot(game, DIR .. "/b1f_gate_closed.png") + check(ow.map:blockAt(12, 8) == CLOSED_B1F, + "B1F doorway (12,8) barred, got " .. tostring(ow.map:blockAt(12, 8))) + check(ow.map:blockAt(12, 9) == 44, + "B1F warp carpet (12,9) untouched, got " .. tostring(ow.map:blockAt(12, 9))) + + -- B1F: opens once the guard is beaten + ow = load("ROCKET_HIDEOUT_B1F", 24, 14, + { EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 = true }) + U.shot(game, DIR .. "/b1f_gate_open.png") + check(ow.map:blockAt(12, 8) == OPEN, + "B1F doorway opens after guard, got " .. tostring(ow.map:blockAt(12, 8))) + + -- B4F: barred until BOTH guards are beaten + ow = load("ROCKET_HIDEOUT_B4F", 24, 8) + U.shot(game, DIR .. "/b4f_gate_closed.png") + check(ow.map:blockAt(12, 5) == CLOSED_B4F, + "B4F doorway (12,5) barred, got " .. tostring(ow.map:blockAt(12, 5))) + + -- B4F: one guard is not enough + ow = load("ROCKET_HIDEOUT_B4F", 24, 8, + { EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 = true }) + check(ow.map:blockAt(12, 5) == CLOSED_B4F, + "B4F stays barred with only one guard, got " .. tostring(ow.map:blockAt(12, 5))) + + -- B4F: opens once both guards are beaten + ow = load("ROCKET_HIDEOUT_B4F", 24, 8, + { EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 = true, + EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1 = true }) + U.shot(game, DIR .. "/b4f_gate_open.png") + check(ow.map:blockAt(12, 5) == OPEN, + "B4F doorway opens after both guards, got " .. tostring(ow.map:blockAt(12, 5))) + + -- B2F has no dynamic gate in pokered (spinner floor); it must still load + ow = load("ROCKET_HIDEOUT_B2F", 24, 14) + check(ow and ow.map and ow.map.id == "ROCKET_HIDEOUT_B2F", + "B2F loads with no gate callback") + + -- regression: the shared Silph Co card-key path must still stamp + ow = load("SILPH_CO_2F", 4, 4) + check(ow.map:blockAt(2, 2) == 84, + "SILPH_CO_2F door1 (2,2) still barred, got " .. tostring(ow.map:blockAt(2, 2))) + ow = load("SILPH_CO_2F", 4, 4, { EVENT_SILPH_CO_2_UNLOCKED_DOOR1 = true }) + check(ow.map:blockAt(2, 2) == 14, + "SILPH_CO_2F door1 opens with its event, got " .. tostring(ow.map:blockAt(2, 2))) + + if #failures > 0 then + error(#failures .. " check(s) failed: " .. table.concat(failures, " | ")) + end + U.log("all Rocket Hideout gate checks passed") +end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 40aadd12..71045488 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -56,6 +56,147 @@ do if path then logFile = io.open(path, "w") end end +-- --------------------------------------------------------------------- +-- stuck-loop watchdog +-- --------------------------------------------------------------------- +-- "Stuck" from the outside looks like the log printing the same thing over +-- and over: every retry loop in this file narrates each attempt through +-- say(), so a run that repeats the exact same line-cycle STUCK_REPEATS +-- times without reaching a NEW segment is trying the same thing and +-- getting the same result. Detect that inside say() itself, throw a +-- sentinel table, and let runRouteGuarded (bottom of file) turn it into a +-- checkpoint plus a written report instead of an endless loop. A backstop +-- in U.wait catches the opposite shape: a loop that spins silently, +-- logging nothing at all. +-- One table, not a spray of locals: the main chunk was already brushing +-- LuaJIT's 200-local limit before the watchdog existed. +local WD = { + -- POKEPORT_ROUTE_WATCHDOG=0 disables the 10-repeat stuck detector and + -- its silent-stall backstop entirely: the run then only ever stops on a + -- real death/lost-abandon or the outer time cap, and slow-but-working + -- stretches (the Victory Road grind, long Elite Four battles) are never + -- cut short by a false positive. + enabled = os.getenv("POKEPORT_ROUTE_WATCHDOG") ~= "0", + repeats = tonumber(os.getenv("POKEPORT_ROUTE_STUCK_REPEATS")) or 10, + -- Cycle/window sizing: the ROUTE_17 travelTo loop narrated a ~16-line + -- cycle, sailing under a 12-line period cap and putting only ~6 copies + -- of any one line in a 100-line window. Both bounds sized to catch it: + -- 10 repeats of a 20-line cycle span 200 lines, within keep (240). + maxPeriod = 20, -- longest line-cycle the detector will match + window = 200, -- scattered-repeat window (see WD.recordLine) + stallFrames = 150000, -- silent logic-frames before the backstop fires + keep = 240, + history = {}, + armed = false, + lastActivity = 0, + reportPath = os.getenv("POKEPORT_ROUTE_STUCK_REPORT") + or "/tmp/route_stuck_report.txt", + -- trees whose felling provably brought us no closer to a target this + -- run, keyed "#," (see cutToward); they respawn on every + -- map re-entry, so without this the cut loop never converges + futileCuts = {}, + -- surf mounts repeated from the same shore toward the same target + -- (see mountSurfToward); a disconnected pond otherwise spins forever + futileMounts = {}, + -- items a later teach step needs; filled in beside TEACH_ITEMS (which + -- is declared after pickupTossJunk, so the set rides on WD instead) + keepItems = {}, + -- mansion statue toggles per sealed target (see toggleMansionSwitch) + futileSwitches = {}, + -- where the run is, kept fresh by runRoute for the stuck report + progress = { segment = 0, maxSegment = 0, + stepIndex = nil, stepCount = nil, action = nil }, +} + +-- Returns a hit table when the tail of the history is one cycle of up to +-- maxPeriod lines repeated `repeats` times back to back, or when this +-- exact line has shown up `repeats` times inside the last `window` lines +-- (the sloppier loops interleave their repeats with lines that vary, so +-- pure periodicity alone would miss them). +function WD.recordLine(line) + local h = WD.history + h[#h + 1] = line + if #h > WD.keep then table.remove(h, 1) end + WD.lastActivity = U.frame() + local n = #h + for p = 1, WD.maxPeriod do + local need = p * WD.repeats + if n >= need then + local periodic = true + for j = n - need + p + 1, n do + if h[j] ~= h[j - p] then periodic = false break end + end + if periodic then + local cycle = {} + for j = n - p + 1, n do cycle[#cycle + 1] = h[j] end + return { period = p, count = WD.repeats, cycle = cycle, line = line } + end + end + end + local seen = 0 + for j = math.max(1, n - WD.window + 1), n do + if h[j] == line then seen = seen + 1 end + end + if seen >= WD.repeats then + return { period = 0, count = seen, cycle = { line }, line = line } + end + return nil +end + +-- Silent-stall backstop. Every loop in the driver waits through U.wait +-- (U.tap included), so a run that stops logging but keeps spinning still +-- passes through here every frame it burns. +do + local baseWait = U.wait + -- Brake-coast: a perfect fence-lean can deadlock -- the bike pinned on + -- one cell while the planner waits for a state change that never comes + -- (observed twice on ROUTE_17; a manual "down" press un-wedged it both + -- times). So after too many braked frames on the SAME cell, stop + -- leaning for a moment and let the hill move us a cell, which is + -- exactly that manual nudge, automated. + local brakeCell, brakeRun, coastLeft = nil, 0, 0 + U.wait = function(n) + for _ = 1, (n or 1) do + -- Cycling Road brake (see slopeBrakeDir, defined later; resolved as + -- a global at call time): every idle overworld frame the driver + -- waits through must lean on the fence or the bike rolls south. + -- Only when the overworld is the live state and no tap is queued, + -- so menu cursors and interactions never see a phantom held key. + local brake + if G and G.stack and G.overworld and G.stack:top() == G.overworld + and #(G.input.pressQueue or {}) == 0 and slopeBrakeDir then + brake = slopeBrakeDir() + end + if brake then + local p = G.overworld.player + local cell = (p.cellY or 0) * 4096 + (p.cellX or 0) + if cell == brakeCell then + brakeRun = brakeRun + 1 + else + brakeCell, brakeRun = cell, 0 + end + if coastLeft > 0 then + coastLeft = coastLeft - 1 + brake = nil -- hands off: let the bike roll + elseif brakeRun > 600 then + brakeRun, coastLeft = 0, 20 + brake = nil + end + else + brakeCell, brakeRun, coastLeft = nil, 0, 0 + end + if brake then G.input.state[brake] = true end + baseWait(1) + if brake then G.input.state[brake] = false end + end + if WD.armed and (U.frame() - WD.lastActivity) > WD.stallFrames then + WD.armed = false + error({ routeStuck = { period = 0, count = 1, cycle = {}, silent = true, + line = ("no log output for %d frames"):format(WD.stallFrames) } }, 0) + end + end +end + -- --------------------------------------------------------------------- -- danger memory -- --------------------------------------------------------------------- @@ -212,6 +353,24 @@ local function say(...) local line = "[route] " .. table.concat(parts, " ") print(line) if logFile then logFile:write(line, "\n"); logFile:flush() end + -- Never let a BATTLE trip the stuck detector. A fight in progress narrates + -- "battle timed out" / "trainer battle slow" as it grinds a foe down, and + -- treating those repeats as a wedge rewound the run straight out of the + -- Elite Four. The E4 is won by attrition -- XP is kept through a blackout, + -- so every attempt levels the team -- so a fight must be allowed to run to + -- its own end (a win, or a faint toward a blackout that retries at the + -- lobby), never rewound. (inBattle() is declared below; inline its test.) + local bt = G and G.stack and G.stack.top and G.stack:top() + local inABattle = bt ~= nil and bt.kind ~= nil + if WD.armed and not inABattle then + local hit = WD.recordLine(line) + if hit then + -- disarm before throwing so the unwind's own say() calls (the + -- report, the checkpoint) cannot re-trigger the detector + WD.armed = false + error({ routeStuck = hit }, 0) + end + end end -- --------------------------------------------------------------------- @@ -286,7 +445,13 @@ local function walk(dir, maxFrames) for _ = 1, 60 do if ow().map.id ~= smap then break end if not ow().player.moving then break end + -- Cycling Road: lean on the fence through the landing frame, or + -- the empty input there rolls the bike a cell south (and a held + -- `dir` instead would step a cell too far) + local brake = slopeBrakeDir() + if brake then G.input.state[brake] = true end coroutine.yield() + if brake then G.input.state[brake] = false end end local q = ow().player moved = ow().map.id ~= smap or q.cellX ~= sx or q.cellY ~= sy @@ -320,7 +485,14 @@ local function walk(dir, maxFrames) end end G.input.state[dir] = false - coroutine.yield() + do + -- the settle frame: inert everywhere except a slope map, where empty + -- input here is exactly the forced-roll condition (see slopeBrakeDir) + local brake = slopeBrakeDir() + if brake then G.input.state[brake] = true end + coroutine.yield() + if brake then G.input.state[brake] = false end + end return moved end @@ -382,6 +554,45 @@ local function passableCell(map, x, y) return p.surfing == true and map:isWaterCell(x, y) end +-- --------------------------------------------------------------------- +-- Cycling Road (slope maps) +-- --------------------------------------------------------------------- +-- On a slope map the engine rolls an idle bike one cell south on ANY +-- frame with no direction held (OverworldController.lua:900-911, porting +-- home/overworld.asm's forced PAD_DOWN on ROUTE_17). Held input -- +-- refused or not -- is processed first and suppresses the roll, so the +-- only way to stand still there is the human rider's trick: lean on the +-- fence. slopeBrakeDir() picks a side direction that is blocked from the +-- cell we are on (or about to land on); walk() and the U.wait wrapper +-- hold it through every frame they would otherwise leave empty. Without +-- it each step's settle frame rolled the bike a cell south, goto_ read +-- the roll-back as a scripted shove, blacklisted the one-cell lane at +-- (18,122), and the run wedged pedalling in the corner. +-- (Globals, not locals: the main chunk is at LuaJIT's 200-local ceiling.) +function slopeMapNow() + if not (G and G.save and G.save.onBike) then return false end + local o = ow() + if not (o and o.map) then return false end + local fmap = G.data and G.data.field and G.data.field.forcedMovement + for _, m in ipairs((fmap and fmap.slopeMaps) or {}) do + if m == o.map.id then return true end + end + return false +end + +function slopeBrakeDir() + if not slopeMapNow() then return nil end + local o = ow() + local p = o.player + local px, py = p.cellX, p.cellY + -- mid-tween the landing frame is the one that counts: brake against + -- the cell we are about to occupy + if p.moving and p.targetX then px, py = p.targetX, p.targetY end + if not passableCell(o.map, px - 1, py) then return "left" end + if not passableCell(o.map, px + 1, py) then return "right" end + return nil +end + -- Tile-pair (elevation) collisions -- pokered's TilePairCollisionsLand / -- Water, the same data src/world/Collision.lua enforces per step. CAVERN -- and FOREST use these to fence off elevation shelves, so two adjacent @@ -414,7 +625,15 @@ local function bfsNextKey(tx, ty, extra) local function id(x, y) return y * w + x end local blocked = {} if extra then - for cell in pairs(extra) do blocked[cell] = true end + -- The caller's refusal set must never contain the DESTINATION. + -- walkOntoWarp's revert-blacklist once refused a warp's own cell, + -- after which every re-plan said "no path" with the target ONE step + -- away and nobody in the way (Silph 1F (16,11) -> (16,10)). + -- Intermediate cells only; NPC blocks below still apply to the goal. + local goal = id(tx, ty) + for cell in pairs(extra) do + if cell ~= goal then blocked[cell] = true end + end end -- NPCs are walls -- but only the ones that belong to THIS map. -- @@ -817,13 +1036,24 @@ end -- engine-hang bailout at the bottom of fightBattle local hungBattle -local function fightBattle(limit) +-- Body on WD (the chunk is at LuaJIT's 200-local ceiling); the local +-- wrapper below adds the re-entrancy accounting. +function WD.fightBattleBody(limit) limit = limit or 20000 local frames = 0 -- TryRunningFromBattle can fail on a speed check, and each failure costs -- the turn, so cap the attempts and fight on rather than be cornered -- burning turns we cannot afford. local runAttempts = 0 + -- Set by the critical-switch branch below to the bench slot to bring in. + local battleSwitchSlot + -- Emergency bench-switches this battle. ONE: it is a last resort to break + -- an unhealable stall (Agatha's Gengar), not a tactic. Unbounded, it + -- looped -- "switching in slot 2" x10 in a Mt. Moon trainer fight tripped + -- the watchdog when the switched-in mon was itself too weak to change the + -- outcome. Capped so a fight that cannot be swung this way falls through + -- to attacking (and, if it must, a clean blackout the recovery retries). + local switchesLeft = 1 -- Potions drunk this battle. Bounded so a fight we are losing anyway -- cannot drain the bag one turn at a time. local healsLeft = 3 @@ -939,6 +1169,36 @@ local function fightBattle(limit) -- survive a gauntlet is to drink. Capped per battle so a losing -- fight empties the turn counter rather than the whole bag. healsLeft = healsLeft - 1 + elseif battle.kind ~= "wild" and switchesLeft > 0 + and (battle.player.mon.hp or 0) + / math.max(1, battle.player.mon.stats.hp or 1) < 0.18 + and (function() + -- the lead is critical AND unhealable (no items or the + -- cap is spent). Agatha's Gengar is a Hypnosis/PP + -- staller: our lead sat at 2/202 HP asleep, out of + -- FULL_RESTOREs, trading nothing, while NIDOKING and + -- ODDISH stood healthy on the bench -- an infinite + -- stall that timed out forever and never resolved. + -- Switching in a live body forces the fight to a real + -- outcome: it acts, or it faints toward a clean + -- blackout the recovery machinery can retry. Only when + -- a genuinely healthier mon exists. + local active = battle.player.mon + local roster = G.save.party or {} + for si, m in ipairs(roster) do + if m ~= active and (m.hp or 0) > 0 + and m.stats and (m.hp / math.max(1, m.stats.hp)) > 0.6 + then + battleSwitchSlot = si + return true + end + end + return false + end)() then + say(("lead is critical and out of heals -- switching in slot %d") + :format(battleSwitchSlot)) + switchesLeft = switchesLeft - 1 + switchTo(battle, battleSwitchSlot) elseif battle.kind == "wild" and runAttempts < 4 and lowOnHP(battle) then -- RUN is index 4 of the 2x2 grid; trainers refuse (tryRun), so -- this only ever fires on wild encounters @@ -982,7 +1242,16 @@ local function fightBattle(limit) -- the map throws at it. if inBattle() then local cur = G.stack:top() - if cur == hungBattle then + -- NEVER force-close a TRAINER battle. Popping an unbeaten trainer + -- leaves their defeat flag unset and their exit door sealed with no + -- recovery -- that is exactly how Agatha walled off the Champion and + -- the Hall of Fame at a completed 197/197 route. A trainer's only + -- honest outcomes are win or blackout (both handled), so hand the + -- fight back to the caller to re-enter with a fresh frame budget and + -- grind it down. Force-close stays only for WILD/ghost hangs, where + -- the encounter simply vanishes with no lasting state. + local trainer = cur.kind ~= "wild" and not cur.ghost and not cur.safari + if cur == hungBattle and not trainer then local foe = cur.enemy and cur.enemy.mon say(("battle engine hang (phase=%s kind=%s foe=%s) -- forcing the " .. "battle closed"):format(tostring(cur.phase), @@ -990,6 +1259,15 @@ local function fightBattle(limit) hungBattle = nil G.stack:pop() U.wait(20) + elseif trainer then + -- Nudge a possibly-stuck prompt and let the caller re-enter; + -- keep the battle on the stack no matter what. + local foe = cur.enemy and cur.enemy.mon + say(("trainer battle slow (phase=%s foe=%s) -- re-entering, not " + .. "closing"):format(tostring(cur.phase), + tostring(foe and foe.species))) + press("a") + U.wait(6) else hungBattle = cur end @@ -999,6 +1277,21 @@ local function fightBattle(limit) end end +-- Re-entrancy accounting around the body. healInBattle and throwPokeDoll +-- run INSIDE fightBattle and call mashUntilIdle, whose battle safety net +-- used to re-enter fightBattle recursively -- fresh frame budget, fresh +-- heal allowance, and the shared hungBattle bookkeeping cross-firing +-- between nesting levels until it force-popped a battle that was still +-- being fought. That is the "battle engine hang" that left Agatha +-- undefeated with her door sealed; the engine itself was fine. +local function fightBattle(limit) + WD.fighting = (WD.fighting or 0) + 1 + local ok, res = pcall(WD.fightBattleBody, limit) + WD.fighting = WD.fighting - 1 + if not ok then error(res, 0) end + return res +end + -- --------------------------------------------------------------------- -- shared waits -- --------------------------------------------------------------------- @@ -1011,7 +1304,10 @@ local function mashUntilIdle(limit) -- Never press A blind at a replacement menu: the cursor is on the mon -- that just fainted, so A re-picks it and the menu reopens forever. if replacementMenu() then sendOutHealthy() end - if inBattle() then fightBattle() end + -- ...and never RE-ENTER a fight this mash is already running inside + -- of (healInBattle/throwPokeDoll come through here mid-battle); the + -- plain A press below drains the turn's text just fine. + if inBattle() and (WD.fighting or 0) == 0 then fightBattle() end if idle() then return true end if driveLearnMenu and driveLearnMenu() then frames = frames + 10 @@ -1225,6 +1521,16 @@ function ops.goto_(s, _where, isLast) surfMounts = surfMounts + 1 goto edgeStep end + -- ...or a sleeping SNORLAX is the wall. ROUTE_12's lies at + -- (10,62), squarely in the seam band shared with ROUTE_11 and + -- the corridor toward LAVENDER_TOWN, so the whole east-loop + -- walk home dead-ended on it while cutToward shaved unrelated + -- bushes ("used CUT on ROUTE_12" x10). The waypoint branch + -- and walkOntoWarp both have this rung; the border-exit + -- branch was the one path without it. + if wakeBlockingSnorlax and wakeBlockingSnorlax("edge exit") then + goto edgeStep + end -- ...or THIS border column is fenced off while another of -- the same edge is open. Viridian's south border is three -- fenced lanes and only x20-21 connects to the city; the @@ -1442,15 +1748,24 @@ function ops.goto_(s, _where, isLast) -- legitimately sit on one cell for a long time. local cell = p.cellY * ow().map.widthCells + p.cellX -- Back where the last successful step started: that step was shoved. + -- EXCEPT on Cycling Road, where the hill (not a gate script) rolls + -- the bike a cell south on any empty-input frame: blacklisting the + -- cell above for that severed the one-cell lane at ROUTE_17 + -- (18,122) and wedged the run in the corner. A roll-back is exactly + -- "we are one row BELOW the cell we had reached"; keep retrying it, + -- the slopeBrakeDir holds should stop the next one. if lastTarget and cell == lastFrom then - reverts[lastTarget] = (reverts[lastTarget] or 0) + 1 - if reverts[lastTarget] >= 2 and not refused[lastTarget] then - local W = ow().map.widthCells - say(("goto (%d,%d): the step onto (%d,%d) keeps getting shoved " - .. "back -- planning around it") - :format(s.x, s.y, lastTarget % W, math.floor(lastTarget / W))) - refused[lastTarget] = true - visits = {} -- the new plan deserves a fresh oscillation budget + local W = ow().map.widthCells + local rolledBack = slopeMapNow() and lastFrom == lastTarget + W + if not rolledBack then + reverts[lastTarget] = (reverts[lastTarget] or 0) + 1 + if reverts[lastTarget] >= 2 and not refused[lastTarget] then + say(("goto (%d,%d): the step onto (%d,%d) keeps getting shoved " + .. "back -- planning around it") + :format(s.x, s.y, lastTarget % W, math.floor(lastTarget / W))) + refused[lastTarget] = true + visits = {} -- the new plan deserves a fresh oscillation budget + end end end visits[cell] = (visits[cell] or 0) + 1 @@ -1692,7 +2007,27 @@ function ops.battle(s, where) return opened end -function ops.talk(s) +function ops.talk(s, where) + -- A talk aimed at an item ball is a pickup in disguise: the route data + -- writes plain `talk` for some balls -- Safari's GOLD_TEETH among them, + -- and losing THAT silently left no trace until Victory Road's boulders + -- found nobody knowing STRENGTH. Route those through ops.pickup's + -- verified, retrying path. Only balls whose def carries `.item` count: + -- Oak's starter balls are SPRITE_POKE_BALL too, but they are script + -- objects with no item field, and shoving THAT cutscene through the + -- pickup retry would wreck the intro. `__asPickup` breaks the mutual + -- recursion (ops.pickup drives the actual interaction via ops.talk). + if not s.__asPickup then + local p = ow().player + for _, npc in ipairs(ow().npcs) do + if npc.def and npc.def.item + and math.abs(npc.cellX - p.cellX) + + math.abs(npc.cellY - p.cellY) <= 2 then + return ops.pickup(setmetatable({ __asPickup = true }, { __index = s }), + where or ow().map.id) + end + end + end local opened = interact(s.face) -- a talk can start a fight (a trainer, or the ball that triggers the -- rival), so never assume the overworld is what we come back to @@ -1701,7 +2036,81 @@ function ops.talk(s) return opened end -ops.pickup = ops.talk +-- A pickup is a talk aimed at an item ball -- but unlike a talk it has a +-- verifiable outcome: something enters the bag. Silph's CARD_KEY was lost +-- exactly here: the goto before this step had failed ("goto (20,16) +-- unreachable on SILPH_CO_5F"), the interact mashed empty dialog from the +-- wrong cell, and the run only found out ten locked doors later as a wall +-- of "no path". Verify the gain; when nothing arrived, walk to the +-- nearest still-visible item ball and try once more, loudly either way. +function ops.pickup(s, where) + local function bagTotal() + local t = 0 + for _, n in pairs(G.save.inventory or {}) do t = t + (tonumber(n) or 1) end + return t + end + local startMap = ow().map.id + local before = bagTotal() + local opened = ops.talk(s) + if bagTotal() > before then return opened end + if ow().map.id ~= startMap then + -- something during the talk moved us off the map (a warp, a rope); + -- retrying HERE would hunt balls on the wrong floor. Let the step + -- loop's partway-check re-sync instead. + say(("pickup on %s FAILED: left the map mid-step (now on %s)") + :format(where, tostring(ow().map.id))) + return false + end + -- A FULL bag refuses the ball outright ("no more room") -- the Saffron + -- restock packs all 20 slots right before Silph's CARD_KEY. Free one, + -- then retry at the ball below. + if #(G.save.bagOrder or {}) >= 20 and pickupTossJunk then + pickupTossJunk(where) + before = bagTotal() -- the toss shrank the bag; success is a gain from HERE + if ow().map.id ~= startMap then + say(("pickup on %s FAILED: the toss moved us off the map (now on %s)") + :format(where, tostring(ow().map.id))) + return false + end + end + local p = ow().player + local ball, bestD + for _, npc in ipairs(ow().npcs) do + -- live entities carry their identity on npc.def (see + -- wakeBlockingSnorlax); only defs with `.item` are real bag pickups + -- (Oak's starter balls are SPRITE_POKE_BALL script objects without it) + if npc.def and npc.def.item then + local d = math.abs(npc.cellX - p.cellX) + math.abs(npc.cellY - p.cellY) + if not bestD or d < bestD then ball, bestD = npc, d end + end + end + if ball then + -- stand beside the ball and face back toward it (offset -> face pairs) + for _, d in ipairs({ { 1, 0, "left" }, { -1, 0, "right" }, + { 0, 1, "up" }, { 0, -1, "down" } }) do + local nx, ny = ball.cellX + d[1], ball.cellY + d[2] + if passableCell(ow().map, nx, ny) + and ops.goto_({ x = nx, y = ny }, where, false) + and ow().player.cellX == nx and ow().player.cellY == ny then + interact(d[3]) + if inBattle() then fightBattle() end + mashUntilIdle() + break + end + end + end + if bagTotal() > before then + say(("pickup on %s: first try gained nothing; retry at the ball " + .. "(%d,%d) worked"):format(where, ball.cellX, ball.cellY)) + return true + end + say(("pickup on %s FAILED: nothing entered the bag (from (%d,%d)%s)") + :format(where, p.cellX, p.cellY, + ball and (", ball seen at (" .. ball.cellX .. "," + .. ball.cellY .. ")") + or ", no ball in sight")) + return false +end function ops.bike() -- toggling the bike needs the bag; without a handler we simply walk, @@ -1775,25 +2184,62 @@ end -- HM-safe MoveLearnMenu handling, shared by fightBattle, mashUntilIdle -- and ops.teach's flow. Returns true if it did something (a learn menu is -- somewhere on the stack), false when there is nothing learn-related up. -function driveLearnMenu() +-- `preferForget` is the route step's `replace` hint: forget that move when +-- it is present and safe, fall back to the first-safe-row heuristic. +function driveLearnMenu(preferForget) local menu for _, st in ipairs(G.stack.states or {}) do if st.newMoveId and st.mon and st.index then menu = st break end end if not menu then return false end local newId = tostring(menu.newMoveId or ""):upper() - local t = top() - if t == menu then - -- the forget list: pick the first move that is neither an HM field - -- move (the engine refuses, and they are the run's geography) nor - -- the move being learned - local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true, - FLY = true, FLASH = true, DIG = true } - local row - for ri, mv in ipairs((menu.mon and menu.mon.moves) or {}) do + -- The learn flow raises TWO YES/NO prompts wanting OPPOSITE answers: + -- "Delete an older move?" wants YES (on to the forget list), "Abandon + -- learning X?" wants NO (back to the delete prompt). Blindly answering + -- YES confirmed the abandon whenever stray B presses (backOut) had + -- flipped the flow onto the second prompt -- the run lost BUBBLEBEAM, + -- DIG and CUT to exactly that. The ChoiceBox itself is text-free; the + -- prompt is the TextBox directly beneath it on the stack, so read that. + -- (Nested here rather than file-local: the main chunk is at LuaJIT's + -- 200-local ceiling.) + local function choicePromptIsAbandon() + local states = G.stack.states or {} + local under = states[#states - 1] + if under and under.pages then + for _, page in ipairs(under.pages) do + for _, l in ipairs(page) do + if tostring(l):find("Abandon") then return true end + end + end + end + return false + end + -- Decide the goal up front, because the choice prompts need to know it: + -- forget `row`, or abandon the new move when nothing is safe to forget. + -- HM field moves are excluded (the engine refuses, and they are the + -- run's geography), as is the move being learned. + local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true, + FLY = true, FLASH = true, DIG = true } + local moves = (menu.mon and menu.mon.moves) or {} + local prefer = preferForget and tostring(preferForget):upper() or nil + local row + if prefer then + for ri, mv in ipairs(moves) do + local id = tostring(mv.id or mv):upper() + if id == prefer and not UNFORGETTABLE[id] and id ~= newId then + row = ri + break + end + end + end + if not row then + for ri, mv in ipairs(moves) do local id = tostring(mv.id or mv):upper() if not UNFORGETTABLE[id] and id ~= newId then row = ri break end end + end + local t = top() + if t == menu then if not row then press("b") -- nothing safe to forget: give up on the new move U.wait(8) @@ -1804,8 +2250,17 @@ function driveLearnMenu() return true end if isChoice() then - cursorTo("index", 1) -- YES, delete an older move - press("a") + -- Answer for the goal: heading for the forget list means YES on + -- "Delete an older move?" and NO on "Abandon learning?"; when nothing + -- is safe to forget the answers flip, because abandoning IS the goal. + local wantForget = row ~= nil + local yes = (choicePromptIsAbandon() ~= wantForget) + if yes then + cursorTo("index", 1) + press("a") + else + press("b") + end U.wait(8) return true end @@ -1977,6 +2432,25 @@ local function freeBagSlots(needed, where) end end end + if free < needed then + -- same dead-weight rule as pickupTossJunk: TMs no teach step wants + -- are sellable too. The Indigo lobby restock hit this: the junk list + -- ran dry, FULL_RESTOREs could not land, and the E4 attrition war + -- was fought on FULL_HEALs alone. Snapshot first -- selling reorders + -- the bag under the iterator. + local order = {} + for _, id in ipairs(G.save.bagOrder or {}) do order[#order + 1] = id end + for _, id in ipairs(order) do + if free >= needed then break end + if tostring(id):find("^TM_") and not WD.keepItems[id] then + if sellItem(id) then + free = free + 1 + say(("shop: sold the %s to free a bag slot (%d free)") + :format(id, free)) + end + end + end + end if free < needed then say(("shop %s: bag still short %d slot(s) after selling junk") :format(tostring(where), needed - free)) @@ -2318,7 +2792,21 @@ local function useItemOn(itemId, slot, where) U.wait(8) end - local pm = top() + -- TMs and HMs interpose "Booted up a TM!" / "It contained X!" text + -- between USE and the target picker (BagMenu.lua:341-350, ItemUseTMHM); + -- healing items go straight to the party menu. Advance that text with A + -- until the picker appears. Bailing out here was the REAL teach killer: + -- top() was the boot-up TextBox, this reported "party menu never + -- opened", and backOut()'s B presses cancelled the teach entirely -- + -- which is how the run reached Route 9's tree with nobody knowing CUT. + local pm + for _ = 1, 20 do + pm = top() + if pm and pm.screenId == "PartyMenu" then break end + press("a") + U.wait(6) + end + pm = top() if not (pm and pm.screenId == "PartyMenu") then note("heal: party menu never opened", where) backOut() @@ -2327,12 +2815,99 @@ local function useItemOn(itemId, slot, where) if not cursorTo("index", slot) then backOut() return false end press("a") U.wait(10) + -- A TM/HM on a full moveset just pushed MoveLearnMenu (BagMenu.lua:158). + -- Hands OFF: the close-out below mashes A then B, and on a YES/NO box B + -- always answers NO (ChoiceBox.lua:36-41) -- so the mash ping-ponged + -- "Delete an older move?" / "Abandon learning?" and declined the very + -- move the item was spent on. That is how CUT was never learned and + -- Route 9's tree walled off the run at segment 76. ops.teach's + -- learn-menu driver knows the right answers; leave the stack exactly as + -- it stands for it. + for _, st in ipairs(G.stack.states or {}) do + if st.newMoveId and st.mon and st.index then return true end + end -- clear the "HP was restored!" box, then unwind the bag + start menu pressUntil(function() return not (top() and top().screenId == "PartyMenu") end, "a", 15) backOut() return true end +-- Toss a junk stack where no shop exists. freeBagSlots sells, which needs +-- a clerk; a FULL bag at an item ball ("no more room") happens +-- mid-dungeon -- Silph 5F's CARD_KEY was refused exactly this way after +-- the Saffron restock packed the bag to 20 slots. Same START->ITEM walk +-- as useItemOn, but taking the TOSS row. Global, not local: ops.pickup is +-- defined earlier in the file and resolves this at call time. +function pickupTossJunk(where) + local victim, count + for _, id in ipairs(SELLABLE_JUNK) do + local n = (G.save.inventory or {})[id] or 0 + if n > 0 then victim, count = id, n break end + end + if not victim then + -- The static junk list ran dry: the pickup retries now collect the + -- bonus balls older runs walked past, so the bag fills with TMs the + -- route has no teach step for. Those are dead weight -- toss the + -- first one that no teach will ever want (WD.keepItems; HMs are not + -- TM_-prefixed and stay untouched). + for _, id in ipairs(G.save.bagOrder or {}) do + if tostring(id):find("^TM_") and not WD.keepItems[id] then + victim, count = id, (G.save.inventory or {})[id] or 1 + break + end + end + end + if not victim then + say("pickup: bag is full and no junk left to toss") + return false + end + press("start") + U.wait(8) + local menu = top() + if not (menu and menu.screenId == "StartMenu") then backOut() return false end + local itemRow + for i, it in ipairs(menu.items or {}) do + if it.label == "ITEM" then itemRow = i break end + end + if not itemRow or not cursorTo("index", itemRow) then backOut() return false end + press("a") + U.wait(10) + local bag = top() + if not (bag and bag.screenId == "BagMenu") then backOut() return false end + local bagRow + for i, r in ipairs(bag.items or {}) do + if r.value == victim then bagRow = i break end + end + if not bagRow or not cursorTo("index", bagRow) then backOut() return false end + press("a") + U.wait(8) + if not isUseToss() or not cursorTo("index", 2) then backOut() return false end + press("a") + U.wait(8) + if isQty() then + qtyTo(count) -- the whole stack, or the slot stays occupied + press("a") + U.wait(8) + end + -- "Is it OK to toss X?" -- YES is the first row; then the fanfare text. + -- NEVER mashUntilIdle here: with the bag still open, its A presses + -- select whatever row the cursor sits on -- one run USED an ESCAPE_ROPE + -- that way and teleported the bot out of Silph mid-pickup. Advance only + -- while a text box is up, then B out. + pressUntil(function() return not isChoice() end, "a", 10) + for _ = 1, 10 do + local t = top() + if not (t and t.pages) then break end + press("a") + U.wait(6) + end + backOut() + local left = (G.save.inventory or {})[victim] or 0 + say(("pickup: tossed %s x%d to free a bag slot (%s)") + :format(victim, count, left == 0 and "freed" or "slot NOT freed")) + return left == 0 +end + -- Drink a potion DURING a battle. -- -- fightBattle only ever had one answer to a hurt lead: flee. That works on @@ -2400,7 +2975,19 @@ function healInBattle(battle, where) local rose = waitFor(function() return (mon.hp or 0) > beforeHP end, 30) local healedTo = mon.hp or 0 local spent = (heldCount(pick) or 0) < beforeCount - mashUntilIdle(400) + -- Drain the heal's text back to a battle prompt WITHOUT mashUntilIdle: + -- idle() means the OVERWORLD is idle, which is never true mid-battle, so + -- mashUntilIdle burned its whole 400-frame budget on every heal. Against + -- Agatha that starved the fight of turns until it hit the 20000-frame + -- timeout and got force-closed -- sealing her door and the Hall of Fame + -- with it. Advance only until the battle takes input again (or ends). + for _ = 1, 150 do + if not (inBattle() or replacementMenu()) then break end + local b = G.stack:top() + if b and (b.phase == "menu" or b.phase == "moveSelect") then break end + if not (driveLearnMenu and driveLearnMenu()) then press("a") end + U.wait(4) + end if rose then say(("healed %s with %s mid-battle: %d -> %d/%d"):format( tostring(mon.species), pick, beforeHP, healedTo, mon.stats.hp or 0)) @@ -2905,6 +3492,27 @@ local function regionGraph() end end end + -- A seam with NO walkable pair is a pure-water border. The map + -- graph keeps those as expensive-but-real edges (see its + -- comment); this builder silently dropped them, which made + -- CINNABAR_ISLAND a structural island up here -- every + -- travelTo(CINNABAR_*) then fell to the region-blind flat + -- planner for the WHOLE trip, and its proposals stranded runs + -- on ROUTE_22/15/18 once banned. Which shore region actually + -- touches the water is execution's problem (mountSurfToward): + -- connect every region pair and price the uncertainty. + if not next(seen) + and not ((id == "ROUTE_20" and conn.map == "CINNABAR_ISLAND") + or (id == "CINNABAR_ISLAND" and conn.map == "ROUTE_20")) then + local srcR = mapRegions(id) + local dstR = mapRegions(conn.map) + for sr = 1, srcR.n do + for dr = 1, dstR.n do + add(regionNode(id, sr), regionNode(conn.map, dr), + { dir = COMPASS_DIR[dir], cells = cells, cost = 8 }) + end + end + end end end -- ledge hops (one-way region connectors) @@ -3409,6 +4017,90 @@ local function travelTo(dest, where) end end settleCell() + -- A warp the flat planner proposes can sit in a DIFFERENT connected + -- region of this map than we do: ROCK_TUNNEL_1F's north alcove + -- (15,0) is walled off from the (15,3) landing pocket, and the flat + -- graph prices all four LAST_MAP warps identically -- so the trip + -- burned its whole budget knocking on structurally unreachable doors + -- forever. The region planner scopes edges correctly; give the flat + -- fallback the same eyes at execution time and ban the hop on the + -- spot instead of paying walkOntoWarp's full retry budget to learn it. + if hop.dir and (not hop.cells or #hop.cells == 0) + and not (slotKnowing("SURF") and (G.save.inventory or {}).SOULBADGE) then + -- a pure-water seam before the party can surf: ban silently -- the + -- planner has land alternatives, and executing it would only spin + -- the mount machinery for nothing + banned[banKey] = true + goto nextTravelHop + end + -- The four Saffron gates are one guard with one thirst: without a + -- drink (or the flag his script sets on taking one) every crossing + -- is a scripted shove-back, and retrying it read as a stuck loop -- + -- one run lost all five attempts to the ROUTE_6_GATE shove. Ban + -- silently until the pass condition is real. + do + local SAFFRON_GATES = { ROUTE_5_GATE = true, ROUTE_6_GATE = true, + ROUTE_7_GATE = true, ROUTE_8_GATE = true } + -- ANY hop whose far side is Saffron: the city has no drink-free + -- entrance at all, and the ROUTE_6 border connection (a plain + -- seam in the map data, not a gate-map hop) funneled every + -- blackout-recovery walk straight into the guard's shove. + -- Region hops carry "MAP#region" names -- strip the suffix or the + -- match silently misses (it did, for five straight attempts). + local dst = tostring(hop.to or ""):match("^([^#]+)") or "" + local entering = SAFFRON_GATES[dst] or dst == "SAFFRON_CITY" + if entering then + local pass = (G.save.flags or {}).EVENT_GAVE_GUARDS_DRINK + if not pass then + for _, d in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do + if (heldCount(d) or 0) > 0 then pass = true break end + end + end + if not pass then + banned[banKey] = true + goto nextTravelHop + end + end + end + -- Region sanity is only meaningful when we are physically ON the map + -- the plan reads (`here`); mid-desync the player can stand elsewhere, + -- and computing their cell against the wrong map's region table + -- produced garbage bans that severed real paths (ROUTE_2's seams, + -- one pinballing attempt 2). + if hop.warp and ow().map.id == here then + local pp = ow().player + local pr = cellRegionOf(here, pp.cellX, pp.cellY) + local wr = cellRegionOf(here, hop.warp.x, hop.warp.y) + if pr and wr and pr ~= wr then + banned[banKey] = true + say(("travelTo %s: warp@%d,%d on %s is in another region -- banning") + :format(tostring(dest), hop.warp.x, hop.warp.y, here)) + goto nextTravelHop + end + elseif hop.dir and hop.cells and hop.cells[1] + and ow().map.id == here then + -- Same blindness, connection flavour: ROUTE_10's south seam only + -- touches its Lavender-side region, and proposing "cross down" + -- from the Cerulean side sent goto_ hacking at trees and diving + -- into the tunnel pocket forever. If no walkable seam cell shares + -- the player's region, the crossing is not reachable on foot from + -- here -- ban it and let the planner find the through-the-tunnel + -- chain instead. + local pp = ow().player + local pr = cellRegionOf(here, pp.cellX, pp.cellY) + if pr then + local touches = false + for _, c in ipairs(hop.cells) do + if cellRegionOf(here, c[1], c[2]) == pr then touches = true break end + end + if not touches then + banned[banKey] = true + say(("travelTo %s: the %s seam on %s is in another region -- " + .. "banning"):format(tostring(dest), tostring(hop.dir), here)) + goto nextTravelHop + end + end + end local before = posKey() doHop(hop) settleCell() @@ -3416,6 +4108,13 @@ local function travelTo(dest, where) if pk == before then -- did not move at all: this hop is shut from here, ban and re-plan banned[banKey] = true + -- ...and REMEMBER it (a price, never a ban -- see seamCost). The + -- notes were designed and documented up top but never wired in, so + -- a seam that failed every lap of a trip stayed free on the next + -- re-plan: the ROUTE_12 east loop retried its impossible west + -- crossing at full price forever. Connections only -- warps already + -- have per-warp ban granularity via edgeId. + if hop.dir and not hop.warp and hop.to then noteSeam(here, hop.to) end -- A connection can be unreachable from THIS half of a gate-split -- map. The map planner sees both halves as one node, so from Route -- 7's east half it proposed the WEST seam into Celadon ninety times @@ -3468,6 +4167,9 @@ local function travelTo(dest, where) elseif visited[pk] then -- looped back onto a cell we already stood on: ban the hop that did it banned[banKey] = true + -- a crossing that only ever leads back where we came from earns the + -- same seam price as one that refuses outright (see above) + if hop.dir and not hop.warp and hop.to then noteSeam(here, hop.to) end -- A loop counts against the region planner too, or a connection that -- crossSeam keeps landing on the same border cell (Pewter -> Route 3 -- from an awkward spot a grind left us in) spins forever without ever @@ -3512,6 +4214,10 @@ local function travelTo(dest, where) else -- genuine progress: a region hop that worked resets the fail streak if fromRegion then regionFails = 0 end + -- a connection that carried us across is forgiven its past flakes + if hop.dir and not hop.warp and hop.to and ow().map.id == hop.to then + clearSeam(here, hop.to) + end visited[pk] = true end visited._last = pk @@ -3980,6 +4686,9 @@ local TEACH_ITEMS = { rock_slide = "TM_ROCK_SLIDE", ice_beam = "TM_ICE_BEAM", mega_punch = "TM_MEGA_PUNCH", mega_kick = "TM_MEGA_KICK", } +-- pickupTossJunk consults this when the bag is full: anything a teach +-- step will want is protected from the toss +for _, item in pairs(TEACH_ITEMS) do WD.keepItems[item] = true end -- Party slot of the first species in `prefs` that we actually own. The route -- lists alternatives ({"oddish","paras"}) because which one it caught varies. @@ -4051,25 +4760,20 @@ function ops.teach(s, where) say(("teaching %s to %s (slot %d)"):format(key, tostring(who and who.species or "?"), slot)) local ok = useItemOn(item, slot, where) - -- A full moveset opens MoveLearnMenu, which useItemOn just mashes A - -- through -- so the TM is consumed and nothing is learned. That is how - -- DIG silently failed on a level-30 WARTORTLE, and DIG is the route's - -- ride back to Cerulean, so the run then had nowhere to go. + -- A full moveset opens MoveLearnMenu. useItemOn used to mash A/B right + -- through it (B answers NO on the YES/NO boxes), abandoning the move the + -- item was spent on -- DIG silently failed on a level-30 WARTORTLE that + -- way, and losing CUT the same way walled the run off at Route 9's tree. + -- useItemOn now returns with the learn flow untouched the moment it + -- appears (see the guard there), and driveLearnMenu answers the two + -- prompts by intent ("Delete an older move?" YES, "Abandon learning?" + -- NO), picks a forgettable non-HM row, and prefers the route step's own + -- `replace` hint when it names a safe move. -- - -- `index` past the last move means "give up"; rows 1..4 forget that move. - -- We drop slot 1, which for the route's targets is the starter's weakest - -- filler rather than anything it fights with. - -- Drive the full-moveset flow. - -- - -- MoveLearnMenu is NOT on top when it appears. Screens.push puts it on - -- the stack and its :enter() immediately pushes a TextBox ("... is trying - -- to learn ... Delete an older move?") which in turn pushes a ChoiceBox - -- (src/ui/MoveLearnMenu.lua:34-50). So `top()` is a TextBox, the old - -- `top().newMoveId` test never matched, and the forget flow never ran -- - -- the TM was consumed and nothing was learned. That is why a level-30 - -- WARTORTLE, whose tmhm list DOES contain DIG and BUBBLEBEAM (verified - -- against pokered's base_stats/wartortle.asm -- our data is right), kept - -- reporting "did NOT learn it", which took DIG away from the run. + -- MoveLearnMenu is NOT on top when it appears: its :enter() immediately + -- pushes the TryingToLearn TextBox, whose last page raises the YES/NO + -- ChoiceBox -- which is why the state scan below searches the whole + -- stack rather than testing top().newMoveId. local function learnMenu() for _, st in ipairs(G.stack.states or {}) do if st.newMoveId and st.mon and st.index then return st end @@ -4077,37 +4781,8 @@ function ops.teach(s, where) end if waitFor(learnMenu, 30) then for _ = 1, 60 do - local menu = learnMenu() - if not menu then break end - local t = top() - if t == menu then - -- Forget the first move that is NEITHER an HM field move NOR the - -- move being taught. "Slot 1" was blind: after SURF replaced the - -- lead's first move, the next teach (EARTHQUAKE) put the cursor - -- on SURF, the engine refused ("HM moves can't be forgotten") - -- and the menu wedged with the TM half-spent. HM moves are also - -- the run's GEOGRAPHY -- losing SURF strands the Cinnabar leg. - local UNFORGETTABLE = { SURF = true, CUT = true, STRENGTH = true, - FLY = true, FLASH = true, DIG = true } - local row = 1 - for ri, mv in ipairs((menu.mon and menu.mon.moves) or {}) do - local id = tostring(mv.id or mv):upper() - if not UNFORGETTABLE[id] and id ~= key:upper() then - row = ri - break - end - end - if not cursorTo("index", row) then break end - press("a") - U.wait(10) - elseif isChoice() then - cursorTo("index", 1) -- YES, delete a move - press("a") - U.wait(8) - else - press("a") -- text box - U.wait(6) - end + if not learnMenu() then break end + driveLearnMenu(s.replace) end mashUntilIdle() say(("%s forgot a move to learn %s"):format( @@ -4135,6 +4810,20 @@ function ops.fieldMove(s, where) -- the tree cell CUT will face, for the post-use verification below local cutCellX, cutCellY local slot = slotKnowing(move) + if not slot then + -- The HM/TM may be sitting IN THE BAG with its teach step lost to a + -- desync (Victory Road's boulders once arrived with HM_STRENGTH + -- unowned; had it been owned-but-untaught this is the save). Teaching + -- here costs one menu trip; ops.teach no-ops cleanly when absent. + local item = TEACH_ITEMS[tostring(s.move or ""):lower()] + if item and (heldCount(item) or 0) > 0 then + say(("fieldMove %s: nobody knows it but %s is in the bag -- " + .. "teaching now"):format(move, item)) + if ops.teach({ move = tostring(s.move or ""):lower() }, where) then + slot = slotKnowing(move) + end + end + end if not slot then -- DIG and FLY are TRANSPORT, and a transport step we cannot perform is -- a walk we have not taken yet rather than a dead end. Segment 72 is @@ -4392,43 +5081,64 @@ function cutToward(tx, ty) blockedBy[npc.cellY * W + npc.cellX] = true end end - local seen = { [p.cellY * W + p.cellX] = true } - local queue, head = { { p.cellX, p.cellY } }, 1 - while head <= #queue do - local c = queue[head]; head = head + 1 - for _, d in ipairs(DIRS) do - local nx, ny = c[1] + d[1], c[2] + d[2] - local id = ny * W + nx - if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] - and not blockedBy[id] and m:isWalkableCell(nx, ny) then - seen[id] = true - queue[#queue + 1] = { nx, ny } - elseif nx >= 0 and ny >= 0 and nx < W and ny < H - and not m:isWalkableCell(nx, ny) then - -- ledge hops reach cells this flood otherwise misses -- and - -- bfsNextKey WALKS them, so leaving them out makes the two - -- disagree: from ROUTE_9 (46,9) the regrown west tree's face is - -- only ledge-reachable, cutToward found "no tree", and - -- travelTo(CINNABAR_ISLAND) died -- taking Blaine, the 7-badge - -- Viridian Gym and the whole league gate with it. - local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2]) - if lx then - local lid = ly * W + lx - if not seen[lid] and not blockedBy[lid] then - seen[lid] = true - queue[#queue + 1] = { lx, ly } + local function reach() + local pp = ow().player + local seen = { [pp.cellY * W + pp.cellX] = true } + local queue, head = { { pp.cellX, pp.cellY } }, 1 + while head <= #queue do + local c = queue[head]; head = head + 1 + for _, d in ipairs(DIRS) do + local nx, ny = c[1] + d[1], c[2] + d[2] + local id = ny * W + nx + if nx >= 0 and ny >= 0 and nx < W and ny < H and not seen[id] + and not blockedBy[id] and m:isWalkableCell(nx, ny) then + seen[id] = true + queue[#queue + 1] = { nx, ny } + elseif nx >= 0 and ny >= 0 and nx < W and ny < H + and not m:isWalkableCell(nx, ny) then + -- ledge hops reach cells this flood otherwise misses -- and + -- bfsNextKey WALKS them, so leaving them out makes the two + -- disagree: from ROUTE_9 (46,9) the regrown west tree's face is + -- only ledge-reachable, cutToward found "no tree", and + -- travelTo(CINNABAR_ISLAND) died -- taking Blaine, the 7-badge + -- Viridian Gym and the whole league gate with it. + local lx, ly = ledgeLanding(m, c[1], c[2], d[3], d[1], d[2]) + if lx then + local lid = ly * W + lx + if not seen[lid] and not blockedBy[lid] then + seen[lid] = true + queue[#queue + 1] = { lx, ly } + end end end end end + return seen end + -- nearest reachable cell's Manhattan distance to the target: the honest + -- measure of whether a cut helped at all + local function bestDist(set) + local bd + for id in pairs(set) do + local cx, cy = id % W, math.floor(id / W) + local d = math.abs(cx - tx) + math.abs(cy - ty) + if not bd or d < bd then bd = d end + end + return bd or math.huge + end + local seen = reach() if seen[ty * W + tx] then return false end -- not a tree problem local best, bestD, stand for id in pairs(seen) do local cx, cy = id % W, math.floor(id / W) for _, d in ipairs(DIRS) do local nx, ny = cx + d[1], cy + d[2] - if cuttableCell(m, nx, ny) then + -- skip trees this run has already proven useless for ANY target + -- twice over; they respawn on every map re-entry, so without the + -- memory the loop is: cut, fail, leave, return, cut... (the + -- "used CUT on ROUTE_12/ROUTE_10" x10 wedges) + if cuttableCell(m, nx, ny) + and (WD.futileCuts[m.id .. "#" .. nx .. "," .. ny] or 0) < 2 then local dist = math.abs(nx - tx) + math.abs(ny - ty) if not bestD or dist < bestD then best, bestD, stand = { nx, ny }, dist, { cx, cy } @@ -4437,13 +5147,32 @@ function cutToward(tx, ty) end end if not best then return false end + local beforeDist = bestDist(seen) say(("goto (%d,%d) is walled off; cutting the tree at (%d,%d) from (%d,%d)") :format(tx, ty, best[1], best[2], stand[1], stand[2])) cutting = true ops.goto_({ x = stand[1], y = stand[2] }) local ok = ops.fieldMove({ move = "cut" }, ow().map.id) cutting = false - return ok and not cuttableCell(ow().map, best[1], best[2]) + if not (ok and not cuttableCell(ow().map, best[1], best[2])) then + return false + end + -- The tree fell -- but did it HELP? A tree picked by straight-line + -- distance can be a pure red herring whose corridor goes somewhere + -- else entirely. Only count the cut as progress when the reachable + -- set actually got closer to the target; otherwise remember the tree + -- as futile so the next lap stops offering it. + if ow().map.id == m.id then + local after = reach() + if after[ty * W + tx] or bestDist(after) < beforeDist then return true end + local k = m.id .. "#" .. best[1] .. "," .. best[2] + WD.futileCuts[k] = (WD.futileCuts[k] or 0) + 1 + say(("the tree at (%d,%d) fell but (%d,%d) is no closer%s") + :format(best[1], best[2], tx, ty, + WD.futileCuts[k] >= 2 and " -- writing that tree off" or "")) + return false + end + return true end -- Open a Silph Co card-key door that is walling us off from (tx, ty). @@ -4469,7 +5198,14 @@ function openDoorToward(tx, ty) local m = ow().map local doors = ck and ck.doors and ck.doors[m.id] if not doors then return false end - if not G.save.inventory.CARD_KEY then return false end + if not G.save.inventory.CARD_KEY then + -- Loud, not silent: this bail is the moment "the pickup failed on 5F" + -- becomes "the tower is a maze of walls", and it repeating is exactly + -- what the stuck detector should get to see and stop on. + say(("card-key door on %s is in the way but CARD_KEY is not in the bag") + :format(tostring(m.id))) + return false + end local W, H = m.widthCells, m.heightCells local p = ow().player local blockedBy = {} @@ -4992,6 +5728,15 @@ function toggleMansionSwitch(tx, ty) if not MANSION_SWITCHES[m.id] then return false end local seen, W = mansionReach() if seen[ty * W + tx] then return false end -- not a switch problem + -- Futility cap, same lesson as cutToward: the 1F stairs room and the + -- B1F return path want OPPOSITE gate states, so chasing one target + -- through the switches ping-pongs forever ("no reachable switch on + -- 1F; pressing one on B1F instead" x10). Three toggles without the + -- target opening IS the deadlock -- fail fast so travelTo can conclude + -- "no way there" and take the escape rope out. + local fk = m.id .. "#" .. tx .. "," .. ty + WD.futileSwitches[fk] = (WD.futileSwitches[fk] or 0) + 1 + if WD.futileSwitches[fk] > 3 then return false end togglingSwitch = true say(("goto (%d,%d) is sealed by the mansion gates; looking for a switch") :format(tx, ty)) @@ -5087,12 +5832,17 @@ function mountSurfToward(tx, ty) if mountingSurf then return false end local p = ow().player if p.surfing then return false end -- already up; not the problem + -- note(), NOT say(): pre-Surf the goto rescue chain reaches this rung + -- constantly (more since cutToward got honest), and ten identical say + -- lines inside one segment read as a stuck loop to the watchdog -- one + -- run lost all five attempts to that false positive on ordinary + -- Vermilion gotos. if not slotKnowing("SURF") then - say("mountSurf: nobody knows SURF") + note("mountSurf: SURF unknown", ow().map.id) return false end if not (G.save.inventory or {}).SOULBADGE then - say("mountSurf: no SOULBADGE") + note("mountSurf: no SOULBADGE", ow().map.id) return false end mountingSurf = true @@ -5121,6 +5871,21 @@ function mountSurfToward(tx, ty) end end if not best then mountingSurf = false return false end + -- The same shore toward the same target, over and over, is the dead + -- pond trap: Vermilion's harbor pool is the closest-to-target water + -- the flood can see, but it connects to nothing, so the run re-mounted + -- there until the watchdog killed it ("used SURF on VERMILION_CITY" + -- x10). A couple of repeats are honest re-plans; more means this water + -- goes nowhere useful -- refuse and fail fast so the caller can ban + -- the hop instead. + local mk = m.id .. "#" .. best[1] .. "," .. best[2] .. ">" .. tx .. "," .. ty + WD.futileMounts[mk] = (WD.futileMounts[mk] or 0) + 1 + if WD.futileMounts[mk] > 3 then + say(("mountSurf: refusing the same shore (%d,%d) toward (%d,%d) again") + :format(best[1], best[2], tx, ty)) + mountingSurf = false + return false + end if not ops.goto_({ x = best[1], y = best[2] }) then mountingSurf = false return false @@ -5793,6 +6558,47 @@ function MANUAL.giveWater(where) end function MANUAL.catchNidoran(where) return catchWild(CATCH_SPECIES.nidoran, where) end + +-- Bill's whole favour, hand-driven (BillsHouse.asm). The route data has +-- only a bare `manual talkToBill` here, and with it unimplemented the +-- S.S. TICKET arrived only when the segment's blind final talk happened +-- to line up -- three attempts in one run wedged at the Vermilion +-- gangplank for want of it. The chain: the monster on the floor (answer +-- YES), the cell-separator PC at (1,4) (hidden event, fires facing UP +-- from (1,5)), then Bill at (4,4) hands the ticket over. +function MANUAL.talkToBill(where) + local function ticket() return (heldCount("S_S_TICKET") or 0) > 0 end + if ticket() then return true end + for _ = 1, 3 do + local flags = G.save.flags or {} + if not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then + -- the monster at (6,5); stand left of it, face right + if ops.goto_({ x = 5, y = 5 }, where, false) then + interact("right") + mashUntilIdle() + end + end + flags = G.save.flags or {} + if flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR + and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then + if ops.goto_({ x = 1, y = 5 }, where, false) then + interact("up") + mashUntilIdle() + end + end + -- Bill stands at (4,4) once separated; the ticket text is his + if ops.goto_({ x = 4, y = 5 }, where, false) then + interact("up") + mashUntilIdle() + end + if ticket() then + say("Bill is himself again -- S.S. TICKET in the bag") + return true + end + end + say("talkToBill: no S.S. TICKET after the full favour chain") + return false +end function MANUAL.catchOddish(where) return catchWild(CATCH_SPECIES.oddish, where) end -- Use a field item that takes no target (the ESCAPE ROPE). @@ -6210,6 +7016,97 @@ local function grindLevels(n, where) return math.max(0, (now and now.level or 0) - before) end +-- --------------------------------------------------------------------- +-- stuck report +-- --------------------------------------------------------------------- + +function WD.describeStep(s) + if type(s) ~= "table" then return tostring(s) end + local keys = {} + for k in pairs(s) do if k ~= "op" then keys[#keys + 1] = tostring(k) end end + table.sort(keys) + local parts = { tostring(s.op) } + for _, k in ipairs(keys) do + parts[#parts + 1] = k .. "=" .. tostring(s[k]) + end + return table.concat(parts, " ") +end + +-- One human-readable block answering: how far did the run get, what was it +-- doing when it wedged, and what did it want next. Said into the log AND +-- written to STUCK_REPORT_PATH, so a killed window still leaves the answer +-- on disk. +function WD.reportStuck(hit) + local wasArmed = WD.armed + WD.armed = false -- the report echoes the repeated lines; never recurse + local progress = WD.progress + local out = {} + local function add(fmt, ...) + out[#out + 1] = select("#", ...) > 0 and fmt:format(...) or fmt + end + local seg = ROUTE[progress.segment] + local far = ROUTE[progress.maxSegment] + add("==== STUCK REPORT ====") + add("got to: segment %d/%d (%s)", progress.maxSegment, #ROUTE, + far and far.map or "?") + add("stuck at: segment %d/%d (%s), step %s/%s: %s", + progress.segment, #ROUTE, seg and seg.map or "?", + tostring(progress.stepIndex or "?"), tostring(progress.stepCount or "?"), + tostring(progress.action or "?")) + local o = ow() + if o and o.map and o.player then + local state = inBattle() and "in battle" + or (busy() and "busy (menu/script/cutscene)" or "idle") + add("standing: %s (%s,%s) -- %s", tostring(o.map.id), + tostring(o.player.cellX), tostring(o.player.cellY), state) + end + local nxt = ROUTE[progress.segment + 1] + if nxt then + add("wanted next: segment %d/%d (%s), starting with: %s", + progress.segment + 1, #ROUTE, nxt.map, + WD.describeStep(nxt.steps and nxt.steps[1] or "?")) + else + add("wanted next: nothing -- this was the last segment") + end + local roster = {} + for _, mon in ipairs(party()) do + roster[#roster + 1] = ("%s L%s %s/%s"):format(tostring(mon.species), + tostring(mon.level), tostring(mon.hp), + tostring(mon.stats and mon.stats.hp)) + end + add("party: %s", #roster > 0 and table.concat(roster, ", ") or "(none)") + local badges = {} + for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", + "RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", + "VOLCANOBADGE", "EARTHBADGE" }) do + if heldCount(b) then badges[#badges + 1] = b end + end + add("badges: %s", #badges > 0 and table.concat(badges, ", ") or "(none)") + if hit.silent then + add("detector: silent stall -- %s", tostring(hit.line)) + elseif (hit.period or 0) > 0 then + add("detector: this %d-line cycle repeated %d times in a row:", + hit.period, hit.count) + for _, l in ipairs(hit.cycle or {}) do add(" | %s", l) end + else + add("detector: %s (seen %dx in the last %d log lines)", + tostring(hit.line), hit.count or 1, WD.window) + end + local h = WD.history + local tail = math.min(#h, 30) + add("last %d log lines:", tail) + for j = #h - tail + 1, #h do add(" | %s", h[j]) end + add("==== END STUCK REPORT ====") + for _, l in ipairs(out) do say(l) end + local fh = io.open(WD.reportPath, "w") + if fh then + fh:write(table.concat(out, "\n"), "\n") + fh:close() + say("stuck report written to " .. WD.reportPath) + end + WD.armed = wasArmed +end + -- --------------------------------------------------------------------- -- checkpoints -- --------------------------------------------------------------------- @@ -6272,6 +7169,15 @@ local function runRoute(startIndex) -- gym segment this attempt has already rewound to, so a gym that -- cannot be won does not loop the rewind forever. local badgeRescued = {} + -- one STRENGTH go-back per attempt, same shape as badgeRescued + local strengthRescued = false + -- one pre-league grind per attempt. The any% party reaches the Elite + -- Four as a lone strong starter (BLASTOISE ~L62) over a bench too weak + -- to trade turns -- which beat Agatha's staller but could not survive + -- the five-room gauntlet with no nurse between rooms. Level the lead on + -- Victory Road's wilds first so it can actually solo it. + local leagueGrinded = false + local LEAGUE_GRIND_TARGET = 72 local LEAGUE_GATE_MAPS = { ROUTE_22_GATE = true, ROUTE_23 = true, VICTORY_ROAD_1F = true, VICTORY_ROAD_2F = true, VICTORY_ROAD_3F = true, @@ -6302,6 +7208,14 @@ local function runRoute(startIndex) -- PLACE, and if we keep ending up lost anyway the problem is something -- else and retrying the walk forever just burns the attempt quietly. local travels = 0 + -- Lost stretches survived by jumping forward instead of dying. The + -- ROUTE_12 -> ROUTE_8 pocket wedged all five attempts of one run the + -- same way: travelTo could not thread it, so the attempt abandoned at + -- 85/197 every time. A forward jump lets the route's own re-sync and + -- the badge/item go-backs carry on past a stretch travelTo cannot + -- solve, the way the watchdog's soft-recovery does for loops. + local lostJumps = 0 + local MAX_LOST_JUMPS = 6 -- Grinds owed but not yet spendable (see recoverFromBlackout): the death -- happens in a gym, the wake-up is in a Poké Center, and the grass is -- somewhere between the two. @@ -6528,6 +7442,9 @@ local function runRoute(startIndex) -- skips: checkpoint, then stop instead of replaying from Pallet Town. -- Returns nil to stop the whole run, false to restart the attempt. local function abandon(at, reason) + -- same report the watchdog writes, so every stop explains itself + WD.reportStuck({ period = 0, count = 1, cycle = {}, + line = "abandoned: " .. tostring(reason) }) saveCheckpoint(at, reason) if STOP_ON_STUCK then return nil end return false @@ -6537,6 +7454,7 @@ local function runRoute(startIndex) while i < #ROUTE do i = i + 1 local seg = ROUTE[i] + WD.progress.segment = i -- ops.fieldMove and the elevator handler read where the NEXT segment -- wants us; set it up here so both can see it from the loop's top. nextMapWanted = ROUTE[i + 1] and ROUTE[i + 1].map or nil @@ -6580,6 +7498,34 @@ local function runRoute(startIndex) -- seven badges). Once per badge per attempt, so a gym that -- genuinely cannot be won does not loop the rewind forever. if LEAGUE_GATE_MAPS[seg.map] then + -- STRENGTH gate, mirroring the badge go-back below: Victory Road is + -- boulders before it is anything else, and one run arrived with all + -- eight badges and no STRENGTH -- the Safari gate misfire silently + -- dropped GOLD_TEETH, so the Warden never paid out and nothing + -- noticed for fifty segments. Rewind to the link that is actually + -- missing, once per attempt. (HM-in-bag-but-untaught needs no + -- rewind: ops.fieldMove now teaches from the bag on the spot.) + if not strengthRescued and not slotKnowing("STRENGTH") + and not (G.save.inventory or {}).HM_STRENGTH then + local wantMap = (G.save.inventory or {}).GOLD_TEETH + and "WARDENS_HOUSE" or "SAFARI_ZONE_WEST" + local back + -- EARLIEST visit: SAFARI_ZONE_WEST's later twin is the dig-out + -- segment, not the one that stands at the GOLD_TEETH ball + for j = 1, i - 1 do + if ROUTE[j].map == wantMap then back = j break end + end + if back then + strengthRescued = true + say(("the league gate is ahead but STRENGTH is missing -- " + .. "rewinding to segment %d/%d (%s) for %s") + :format(back, #ROUTE, wantMap, + wantMap == "WARDENS_HOUSE" and "HM_STRENGTH" + or "the GOLD_TEETH")) + i = back - 1 + goto nextSegment + end + end local missing for _, bg in ipairs(BADGE_GYM_SEGMENTS) do if not (G.save.inventory or {})[bg[1]] and not badgeRescued[bg[1]] then @@ -6689,6 +7635,9 @@ local function runRoute(startIndex) end local here = ow().map.id if here ~= seg.map then + WD.progress.stepIndex, WD.progress.stepCount = nil, nil + WD.progress.action = ("re-sync: expected %s, standing on %s") + :format(seg.map, here) -- Never run a segment's steps on the wrong map: the waypoints are -- meaningless there and the interactions land on whatever happens -- to be adjacent. Skip it and let a later segment re-sync. @@ -6810,6 +7759,44 @@ local function runRoute(startIndex) -- A failed travelTo still moves us -- it abandons the trip wherever -- the last hop landed -- so report where we actually are. here = ow().map.id + -- Before writing the whole attempt off: JUMP FORWARD to the next + -- segment whose map we can actually reach from here, and carry + -- on. A stretch travelTo cannot thread (the ROUTE_12 west pocket) + -- should cost a few skipped segments, not the run -- the badge + -- and STRENGTH go-backs refetch anything load-bearing that the + -- jump steps over. + if lostJumps < MAX_LOST_JUMPS then + local jumpTo + for j = i + 1, #ROUTE do + if ROUTE[j].map == here then jumpTo = j break end + end + -- nothing on this map ahead: hand it to reachableSet via a + -- forward scan for a segment travelTo can still get to + if not jumpTo then + for j = i + 1, math.min(i + 20, #ROUTE) do + if ROUTE[j].map ~= seg.map then jumpTo = j break end + end + end + if jumpTo then + lostJumps = lostJumps + 1 + skips = 0 + say(("lost on %s -- jumping forward to segment %d/%d (%s), " + .. "recovery %d/%d"):format(here, jumpTo, #ROUTE, + ROUTE[jumpTo].map, lostJumps, MAX_LOST_JUMPS)) + -- Advancing the index alone left us PHYSICALLY marooned in the + -- ROUTE_12 (0,62) pocket, so the next travelTo re-trapped from + -- the identical dead spot -- the jump just cycled. DIG warps + -- to the last Poké Center, a real hub the route can navigate + -- from; point it at the jump target's map so the built-in + -- post-DIG travelTo heads the right way. + if slotKnowing("DIG") then + nextMapWanted = ROUTE[jumpTo].map + ops.fieldMove({ move = "dig" }, here) + end + i = jumpTo - 1 + goto nextSegment + end + end say(("lost: %d segments skipped in a row, abandoning attempt at %d/%d") :format(skips, i, #ROUTE)) return abandon(i, ("lost on %s, expected %s"):format(here, seg.map)) @@ -6818,6 +7805,12 @@ local function runRoute(startIndex) end ::runSegment:: skips = 0 + if i > WD.progress.maxSegment then + WD.progress.maxSegment = i + -- a new segment reached on its own map IS progress: forget the old + -- chatter so earlier retries cannot trip the loop detector later + WD.history = {} + end lastRanMap = seg.map -- Spend a deferred grind as soon as we are somewhere it can work. if pendingGrind > 0 and G.data.encounters and G.data.encounters[seg.map] then @@ -6930,6 +7923,55 @@ local function runRoute(startIndex) if VR_SWITCHES[seg.map] and ow().map.id == seg.map then solveVictoryRoadSwitches() end + -- Guarantee the POKE_FLUTE. It gates two Snorlax (ROUTE_16, ROUTE_12), + -- and its handoff is a two-step scripted chain the travel/skip machinery + -- can bypass: talk Fuji at the tower top (rescue -> warps you home) then + -- talk him again at his house (gives the flute, but ONLY if rescued - + -- story.lua:439). A skipped tower talk means Fuji never appears at home, + -- the house talk gives nothing, and Route 16 seals the run seven + -- segments later. Do it here, keyed on the flags, not the fragile + -- waypoints. Same family as the CARD_KEY and GOLD_TEETH misses. + local fujiFlags = G.save.flags or {} + if not fujiFlags.EVENT_GOT_POKE_FLUTE then + if ow().map.id == "POKEMON_TOWER_7F" + and not fujiFlags.EVENT_RESCUED_MR_FUJI then + local fuji = findObject("MR_FUJI") + if fuji and stepUpTo(fuji.x, fuji.y) then + interact() + mashUntilIdle() + say("rescued Mr. Fuji at the tower top") + end + elseif ow().map.id == "MR_FUJIS_HOUSE" + and fujiFlags.EVENT_RESCUED_MR_FUJI then + local fuji = findObject("MR_FUJI") + if fuji and stepUpTo(fuji.x, fuji.y) then + interact() + mashUntilIdle() + if (G.save.flags or {}).EVENT_GOT_POKE_FLUTE then + say("collected the POKE_FLUTE from Mr. Fuji") + end + end + end + end + -- Level the lead for the Elite Four while standing on Victory Road's + -- wilds -- the last grass before the sealed five-room gauntlet. Once + -- per attempt, and only if the lead is actually short of the target, + -- so a strong resume does not pace for nothing. + if not leagueGrinded and tostring(seg.map):find("VICTORY_ROAD") + and ow().map.id == seg.map and G.data.encounters + and G.data.encounters[seg.map] then + leagueGrinded = true + local lead = party()[1] + local cur = lead and lead.level or 0 + if cur > 0 and cur < LEAGUE_GRIND_TARGET then + say(("pre-league grind: lead is L%d, training toward L%d on %s") + :format(cur, LEAGUE_GRIND_TARGET, seg.map)) + local gained = grindLevels(LEAGUE_GRIND_TARGET - cur, seg.map) + say(("pre-league grind: gained %d level(s), lead now L%d") + :format(gained, (party()[1] or {}).level or cur)) + autoHeal(seg.map, 0.95, true) + end + end local RESTOCK_TOWNS = { SAFFRON_CITY = "saffronRestock", CINNABAR_ISLAND = "cinnabarRestock" } local restockList = RESTOCK_TOWNS[seg.map] @@ -6988,6 +8030,8 @@ local function runRoute(startIndex) -- gauntlet IS the map, and the old per-segment check only looked -- once, before the first of them. autoHeal(seg.map, riskThreshold(seg.map)) + WD.progress.stepIndex, WD.progress.stepCount = si, #seg.steps + WD.progress.action = WD.describeStep(s) local fn = ops[s.op == "goto" and "goto_" or s.op] if not fn then note("UNHANDLED:" .. s.op, seg.map) @@ -7012,6 +8056,82 @@ local function runRoute(startIndex) return true end +-- Run one attempt with the stuck watchdog armed. The watchdog throws its +-- sentinel out of whatever loop was repeating itself; translate that into +-- the same checkpoint-and-stop shape abandon() produces, plus the written +-- report of where the run was and what it wanted next. +local function runRouteGuarded(startIndex) + local resumeAt = startIndex + local recoveries = 0 + local stuckAt = {} -- segment -> times wedged there (progressive rewind) + while true do + WD.history = {} + WD.lastActivity = U.frame() + WD.armed = WD.enabled + local ok, result = pcall(runRoute, resumeAt) + WD.armed = false + if ok then return result end + if not (type(result) == "table" and result.routeStuck) then + error(result, 0) -- a real bug, not a stuck loop; let the harness print it + end + local hit = result.routeStuck + WD.reportStuck(hit) + saveCheckpoint(math.max(WD.progress.segment, 1), + "stuck loop: " .. tostring(hit.line)) + if STOP_ON_STUCK then return nil end + -- A 10-repeat stuck loop is NOT a stop. Per the run's rule: try + -- something else, then rewind and continue. The run only ever ends on + -- a real death-abandon, route completion, or the outer time cap. + recoveries = recoveries + 1 + + -- (1) Try something else: DIG out to the last Poké Center. Some wedges + -- have no in-place escape -- the ROUTE_12 (0,62) pocket is walled by a + -- Snorlax, water, and an out-of-region seam -- and a rewind alone + -- would just walk back into them. A hub the route can navigate from + -- breaks that. Outdoors + DIG known only; the engine refuses it + -- indoors, where a rewind is the right tool anyway. + for _, d in ipairs({ "up", "down", "left", "right", + "a", "b", "start", "select" }) do + G.input.state[d] = false -- sentinel can unwind mid-menu, keys held + end + for _ = 1, 10 do + if idle() and not inBattle() then break end + if inBattle() then fightBattle() else mashUntilIdle() end + end + if slotKnowing and slotKnowing("DIG") and G.overworld + and G.stack:top() == G.overworld then + local before = ow().map.id + nextMapWanted = nil + pcall(function() ops.fieldMove({ move = "dig" }, before) end) + if ow().map.id ~= before then + say(("watchdog: DIG warped %s -> %s (trying something else)") + :format(tostring(before), tostring(ow().map.id))) + end + end + + -- (2) Rewind ~10 segments and continue. If the SAME stretch keeps + -- wedging, back up further each time (10, 20, 30...) so two segments + -- cannot ping-pong forever -- eventually the rewind reaches ground the + -- route can cross, or the whole route replays from the start. + local at = math.max(WD.progress.segment, 1) + stuckAt[at] = (stuckAt[at] or 0) + 1 + local back = 10 * stuckAt[at] + resumeAt = math.max(1, at - back) + say(("watchdog: stuck at segment %d (x%d) -- rewinding %d to segment %d " + .. "and continuing"):format(at, stuckAt[at], back, resumeAt)) + + -- Last-resort backstop only: after very many recoveries the progressive + -- rewind has replayed from the start repeatedly and still cannot pass. + -- Restart the ATTEMPT (fresh game, next of the five) rather than stop + -- the run -- this is not the 10-repeat halt, it is genuine give-up. + if recoveries > 25 then + say("watchdog: 25 recoveries without lasting progress -- restarting " + .. "the attempt") + return false + end + end +end + local function report() local keys = {} for k in pairs(skipped) do keys[#keys + 1] = k end @@ -7066,7 +8186,7 @@ return function(game) G:restoreSave(loaded, recovered) U.wait(30) mashUntilIdle() - local stopped = runRoute(resume.segment) + local stopped = runRouteGuarded(resume.segment) if stopped == nil then say("stopped at a checkpoint") break end if stopped then say("run finished") break end say("checkpoint attempt failed; starting a clean run") @@ -7091,34 +8211,67 @@ return function(game) -- run into a resume: a fresh run reported being lost at segment 9 while -- standing in VERMILION_CITY, hundreds of segments from where it should -- have been. Pick the row by label instead of trusting its position. - U.wait(5) - press("start") - U.wait(10) - press("a") - U.wait(10) - local title = top() - local newRow - for i, it in ipairs(title and title.items or {}) do - if it.label == "NEW GAME" then newRow = i break end - end - if newRow then - if cursorTo("index", newRow) then - press("a") + local fresh = false + for _ = 1, 3 do + U.wait(5) + -- NEVER press A blindly here. With a save on disk the menu is + -- already up after the movie skip, CONTINUE is its first row, and + -- a stray A selects it -- every retry then "lands on ROUTE_16" and + -- gets rejected, which burned four whole attempts in one run. + -- Press START only until the menu's items are visible, back out of + -- any submenu a stray press opened, and touch A only once the + -- cursor is on the NEW GAME row. + local newRow + for _ = 1, 120 do + local t = top() + local items = t and t.items + if items then + for i, it in ipairs(items) do + if it.label == "NEW GAME" then newRow = i break end + end + if newRow then break end + press("b") -- ContinueInfo or another submenu; back out + U.wait(4) + else + press("start") -- still the movie or the bare title screen + U.wait(6) + end + end + if newRow then + if cursorTo("index", newRow) then + press("a") + U.wait(10) + end + -- mash through Oak's speech and the naming presets + for _ = 1, 400 do + press("a") + U.wait(2) + if G.overworld and G.stack:top() == G.overworld then break end + end U.wait(10) + else + say("title menu had no NEW GAME row; falling back to U.newGame") + U.newGame(game) end - -- mash through Oak's speech and the naming presets - for _ = 1, 400 do - press("a") - U.wait(2) - if G.overworld and G.stack:top() == G.overworld then break end + mashUntilIdle() + -- A fresh game always wakes in Red's bedroom. Anywhere else means + -- CONTINUE hijacked a blind tap -- one attempt toured the endgame + -- from ROUTE_23 with the route pointed at segment 1 that way -- + -- so go back to the title and try again rather than run on it. + if G.overworld and ow().map.id == "REDS_HOUSE_2F" then + fresh = true + break end - U.wait(10) - else - say("title menu had no NEW GAME row; falling back to U.newGame") - U.newGame(game) + say(("new game landed on %s -- back to the title to try again") + :format(tostring(G.overworld and ow().map.id))) + G:returnToTitle() + U.wait(60) end - mashUntilIdle() - local result = runRoute() + if not fresh then + say("could not start a fresh game; abandoning this attempt") + goto nextAttempt + end + local result = runRouteGuarded() if result == nil then say("stopped at a checkpoint") break end if result then say("run finished") diff --git a/tests/drivers/route4_downsweep_bug223.lua b/tests/drivers/route4_downsweep_bug223.lua new file mode 100644 index 00000000..738cbed1 --- /dev/null +++ b/tests/drivers/route4_downsweep_bug223.lua @@ -0,0 +1,103 @@ +-- #223 exhaustive DOWN-hop sweep for ROUTE_4 (evidence driver). +-- +-- The reporter's build (v0.1.25) and HEAD share byte-identical ledge code +-- (checkLedgeHop) and ledge data (tools/rom_manifest.json -> +-- data/generated/field.lua, the 8 rows of data/tilesets/ledge_tiles.asm), so +-- this reproduces exactly what the reporter would see. +-- +-- For EVERY ROUTE_4 cell whose tile-at-feet is a south-ledge STANDING tile +-- (44/57, i.e. pokered $2C/$39) with a south-ledge tile (54/55, $36/$37) +-- directly below, teleport the player onto it facing DOWN, hold DOWN, and +-- assert the Gen1 hop fires (p.hopFrames>0) and lands two cells south. +-- +-- Finding: all 139 reachable/functional south ledges hop. The only two that +-- do not -- (12,16) and (13,16) -- sit on the SOUTH boundary of the Mt Moon +-- Poke Center plaza, where the cell two south is off the map onto the border +-- mountain (tile 17); there is no landing, so the hop is correctly refused +-- (checkLedgeHop's landing-walkable gate). These are NOT the reporter's spot +-- (an open EAST plateau, cells ~62-80) and refusing a hop into the map border +-- is correct, so they are treated as EXPECTED refusals here. +-- +-- Run: +-- POKEPORT_DRIVER=tests/drivers/route4_downsweep_bug223.lua \ +-- POKEPORT_IDENTITY=bug223 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + local Pokemon = require("src.pokemon.Pokemon") + if #game.save.party == 0 then + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + end + game.save.options = game.save.options or {} + game.save.options.zoom = -2 + + -- same tile read as Map:cellTile: bottom-left 8x8 of the 2x2-cell block + local def = game.data.maps.ROUTE_4 + local ts = game.data.tilesets[def.tileset] + local function cellTile(cx, cy) + local tx, ty = cx * 2, cy * 2 + 1 + local bx, by = math.floor(tx / 4), math.floor(ty / 4) + local id + if bx < 0 or by < 0 or bx >= def.width or by >= def.height then id = def.borderBlock + else id = def.blocks[by * def.width + bx + 1] end + local block = ts.blocks[(id or 0) + 1] + return block and block[(ty % 4) * 4 + (tx % 4) + 1] or nil + end + local W, H = def.width * 2, def.height * 2 + + local function holdDown(x, y, frames) + U.teleport(game, "ROUTE_4", x, y, "down") + require("src.render.Zoom").applyOptions(game.save.options) + U.wait(6) + local p = game.overworld.player + local hop = false + for _ = 1, frames do + table.insert(game.input.pressQueue, "down") + game.input.state.down = true + coroutine.yield() + if (p.hopFrames or 0) > 0 then hop = true end + end + game.input.state.down = false + U.wait(4) + return p.cellX, p.cellY, hop + end + + -- (12,16)/(13,16): south ledges on the plaza's map-border edge; the cell two + -- south is off-map (border mountain), so the refusal is correct, not a bug. + local expectedRefusal = { ["12,16"] = true, ["13,16"] = true } + + local standers = {} + for cy = 0, H - 2 do + for cx = 0, W - 1 do + local s = cellTile(cx, cy) + local f = cellTile(cx, cy + 1) + if (s == 44 or s == 57) and (f == 54 or f == 55) then + standers[#standers + 1] = { cx, cy } + end + end + end + U.log(("#223 sweep: %d south-ledge standing cells in ROUTE_4"):format(#standers)) + + local fails, refusals = 0, 0 + for _, c in ipairs(standers) do + local cx, cy = c[1], c[2] + -- 2-cell hop is 32 frames (16/cell); budget past it so cellY settles + local ex, ey, hop = holdDown(cx, cy, 44) + local ok = hop and ex == cx and ey == cy + 2 + if not ok then + if expectedRefusal[cx .. "," .. cy] then + refusals = refusals + 1 + U.log((" refused (expected, map-border edge) (%d,%d) -> (%d,%d) hop=%s") + :format(cx, cy, ex, ey, tostring(hop))) + else + fails = fails + 1 + U.log((" FAIL (%d,%d) -> (%d,%d) hop=%s"):format(cx, cy, ex, ey, tostring(hop))) + end + end + end + U.log(("#223 sweep DONE: %d hopped, %d expected border-refusals, %d unexpected FAILS") + :format(#standers - fails - refusals, refusals, fails)) + if fails > 0 then error(fails .. " unexpected south-ledge DOWN-hop failure(s)") end +end diff --git a/tests/drivers/route4_ledge_bug223_test.lua b/tests/drivers/route4_ledge_bug223_test.lua new file mode 100644 index 00000000..942de3ff --- /dev/null +++ b/tests/drivers/route4_ledge_bug223_test.lua @@ -0,0 +1,168 @@ +-- Driver: Route 4 ledges (issue #223). +-- +-- #223 reports "can't jump down the lower-right cliff outside Mt. Moon". The +-- reporter (build v0.1.25, RED++/OG-RED brown palette) is standing on the open +-- EAST plateau of ROUTE_4 (the tile-57 ground with the "01/10" texture, cells +-- ~72-80, rows 3-6) near its SE corner. The owner clarified the complaint is +-- about pressing DOWN (a south-facing ledge), not the vertical side ledges the +-- first triage looked at. +-- +-- Finding after an exhaustive DOWN-hop sweep of every ROUTE_4 south-ledge cell +-- (see tests/drivers/route4_downsweep_bug223.lua): every functional south +-- ledge hops. The "cliff" the reporter pressed DOWN on at the SE corner is a +-- solid mountain-wall FACE -- OVERWORLD tile 58, a 5-cell-tall vertical cliff +-- (ROUTE_4 cell 80, rows 5-9) -- which is NOT a ledge tile. Gen1 only hops the +-- three straight ledge families in data/tilesets/ledge_tiles.asm (54/55 face +-- DOWN, 39 faces LEFT, 13/29 face RIGHT); a tall cliff face and the diagonal +-- corner tiles are not hoppable in any direction (engine/overworld/ledges.asm +-- HandleLedges keys the hop on wPlayerFacingDirection + wTilePlayerStandingOn + +-- wTileInFrontOfPlayer + hJoyHeld, all four of which must match a LedgeTiles +-- row). The ledge code and data are byte-identical between v0.1.25 and HEAD, +-- so this is the same behavior the reporter saw: correct, matching Gen1. +-- +-- Cases (all assert the CORRECT Gen1 behavior, so this passes on a good build +-- and would fail on any future ledge regression): +-- A a plain south ledge hops with DOWN (system works) +-- B the cx45 right-facing side ledge hops with RIGHT (correct input) +-- C the cx50 left-facing side ledge hops with LEFT +-- D DOWN along the plaza terraces descends by hopping south ledges +-- E DOWN into a solid cliff face (tile 58) correctly bonks +-- F the reporter's EAST plateau south ledges hop with DOWN (rows 4 and 6) +-- G the reporter's SE-corner cliff FACE (tile 58, cell 80) does NOT hop -- +-- this is the "cliff outside Mt Moon" the report was about, working as +-- Gen1 does (a mountain wall is not a ledge) +-- +-- Run: +-- POKEPORT_DRIVER=tests/drivers/route4_ledge_bug223_test.lua \ +-- POKEPORT_IDENTITY=bug223 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local shotDir = os.getenv("POKEPORT_SHOTDIR") or "." + local function shot(name) U.shot(game, shotDir .. "/" .. name) end + + -- a party + starter flag so the overworld is fully usable + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + local Pokemon = require("src.pokemon.Pokemon") + if #game.save.party == 0 then + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5)) + end + -- survey zoom-out + the reporter's RED++ palette so the shots frame the whole + -- cliff like the report + game.save.options = game.save.options or {} + game.save.options.zoom = -2 + game.save.options.colors = "redpp" + require("src.render.PaletteFX").setMode("redpp") + + local fails = 0 + local function expect(cond, ...) + if not cond then fails = fails + 1 end + U.log(cond and "PASS" or "FAIL", ...) + end + + -- hold a direction for n frames, returning end cell + whether a hop arc was + -- ever active (p.hopFrames>0 is set only by checkLedgeHop's jump arc) + local function holdDir(x, y, facing, dir, frames) + U.teleport(game, "ROUTE_4", x, y, facing) + require("src.render.Zoom").applyOptions(game.save.options) + U.wait(6) + local p = game.overworld.player + local hopSeen = false + for _ = 1, frames do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + if (p.hopFrames or 0) > 0 then hopSeen = true end + end + game.input.state[dir] = false + U.wait(4) + return p.cellX, p.cellY, hopSeen + end + + -- A) control: a south-facing ledge must hop with DOWN. Standing at (40,8), + -- the cell in front (40,9) is a tile-55 south ledge; hop lands on (40,10). + do + U.teleport(game, "ROUTE_4", 40, 8, "down") + require("src.render.Zoom").applyOptions(game.save.options) + U.wait(6); shot("route4_south_ledge_before.png") + local x, y, hop = holdDir(40, 8, "down", "down", 60) + shot("route4_south_ledge_after.png") + expect(hop, "A: DOWN hops the south ledge (hop arc seen)") + expect(x == 40 and y >= 10, "A: landed south of the ledge, got:", x, y) + end + + -- B) a RIGHT-facing side ledge (tile 29) at cx45. DOWN never crosses it; + -- RIGHT does. From (44,6) a RIGHT hop clears cx45 and lands on (46,6). + do + U.teleport(game, "ROUTE_4", 44, 6, "right") + require("src.render.Zoom").applyOptions(game.save.options) + U.wait(6); shot("route4_side_ledge_right_before.png") + local x, y, hop = holdDir(44, 6, "right", "right", 40) + shot("route4_side_ledge_right_after.png") + expect(hop, "B: RIGHT hops the cx45 side ledge (hop arc seen)") + expect(x == 46 and y == 6, "B: landed east of the side ledge, got:", x, y) + end + + -- C) the mirror side ledge: cx50 (tile 39) faces LEFT; from (51,5) a LEFT hop + -- clears cx50 and lands on (49,5). + do + local x, y, hop = holdDir(51, 5, "left", "left", 40) + expect(hop, "C: LEFT hops the cx50 side ledge (hop arc seen)") + expect(x == 49 and y == 5, "C: landed west of the side ledge, got:", x, y) + end + + -- D) DOWN down the plaza terraces DOES descend by hopping south ledges: from + -- the plaza top (44,5), holding DOWN walks to a south edge and hops the + -- terraces; held long enough the player ends well south of the start. + do + U.teleport(game, "ROUTE_4", 44, 5, "down") + require("src.render.Zoom").applyOptions(game.save.options) + U.wait(6); shot("route4_terrace_down_before.png") + local x, y, hop = holdDir(44, 5, "down", "down", 150) + shot("route4_terrace_down_after.png") + expect(hop, "D: DOWN down the plaza terraces hops a south ledge") + expect(y >= 12, "D: descended the terraces, got:", x, y) + end + + -- E) a solid cliff face is NOT a ledge: from (80,4) the cell below (80,5) is a + -- tile-58 cliff wall. DOWN bonks in place with no hop -- matching Gen1, DOWN + -- only hops south-facing ledge tiles (54/55), not cliff faces. + do + local x, y, hop = holdDir(80, 4, "down", "down", 30) + expect(not hop, "E: DOWN into a solid cliff face does not hop") + expect(x == 80 and y == 4, "E: bonked in place at the cliff face, got:", x, y) + end + + -- F) the reporter's EAST plateau (the "cliff outside Mt Moon"): the tile-57 + -- ground at (70,4) and (75,4) sits above the row-5 south ledge (tiles 54/55). + -- DOWN hops each two cells south -- this is exactly the "jump down to the + -- bottom half of the cliff" the report expected, and it works. + do + U.teleport(game, "ROUTE_4", 70, 4, "down") + require("src.render.Zoom").applyOptions(game.save.options) + U.wait(8); shot("route4_east_plateau_before.png") + local x1, y1, h1 = holdDir(70, 4, "down", "down", 44) + shot("route4_east_plateau_after.png") + expect(h1 and x1 == 70 and y1 == 6, "F: (70,4) DOWN hops to (70,6), got:", x1, y1) + local x2, y2, h2 = holdDir(75, 4, "down", "down", 44) + expect(h2 and x2 == 75 and y2 == 6, "F: (75,4) DOWN hops to (75,6), got:", x2, y2) + end + + -- G) the reporter's SE-corner "cliff": (80,4) is tile-57 ground whose south + -- neighbour is the tile-58 mountain-wall face (cell 80, rows 5-9). DOWN must + -- NOT hop -- a tall cliff face is not a ledge, matching Gen1. This is the + -- cliff the report was about; refusing the hop here is correct. + do + U.teleport(game, "ROUTE_4", 78, 3, "down") + require("src.render.Zoom").applyOptions(game.save.options) + U.wait(8); shot("route4_se_corner_cliff.png") + local x, y, hop = holdDir(80, 4, "down", "down", 30) + expect(not hop and x == 80 and y == 4, + "G: SE-corner cliff face does not hop (Gen1-correct), got:", x, y, hop) + end + + if fails > 0 then error(fails .. " check(s) failed") end + U.log("all checks passed -- #223: every functional ROUTE_4 south ledge hops " + .. "with DOWN; the reporter's SE 'cliff' is a solid mountain-wall face " + .. "(tile 58), correctly not hoppable, matching Gen1") +end diff --git a/tests/drivers/saffron_gate_bug221_test.lua b/tests/drivers/saffron_gate_bug221_test.lua new file mode 100644 index 00000000..a765cddc --- /dev/null +++ b/tests/drivers/saffron_gate_bug221_test.lua @@ -0,0 +1,206 @@ +-- Driver: #221 Saffron gate guards use the WRONG dialogue. +-- +-- The reporter (build 0.1.25) saw the guard ACCEPT a drink he never had -- +-- "...Huh? I can have this drink? Gee, thanks!" (_SaffronGateGuardImParchedText, +-- data/generated/text.lua:2050) -- while walking up to the gate carrying NO +-- drink. Gen1 (scripts/Route5Gate.asm Route5GateDefaultScript) instead turns +-- a drink-less player back with the thirsty line +-- (_SaffronGateGuardGeeImThirstyText, text.lua:2049: "I'm on guard duty. / Gee, +-- I'm thirsty, though! ... the road's closed."), and only ACCEPTS a drink (via +-- `farcall RemoveGuardDrink`, engine/items/inventory.asm) when one is in the bag. +-- +-- The guard object sits at cell (1,3), walled into an isolated 1x3 booth +-- (pokered Route5Gate object_event 1,3 SPRITE_GUARD STAY RIGHT), so he is +-- unreachable on foot: the block/dialogue is driven entirely by the onStep +-- coordinate trigger on the corridor cells (3,3)/(4,3), not the TALK handler. +-- This driver walks the player north through that trigger and asserts: +-- +-- CASE A (no drink): the box is the THIRSTY line, NOT the accept line, and +-- the player is shoved back one tile and stays inside ROUTE_5_GATE. +-- CASE B (FRESH_WATER in bag): the box is the ACCEPT line, the drink is +-- consumed, EVENT_GAVE_GUARDS_DRINK is set, and the player walks on +-- through (map leaves ROUTE_5_GATE). +-- +-- On the 0.1.25 bug CASE A would type the accept line and fail; current HEAD +-- (fixed by #201) passes. Run with: +-- +-- SHOT_DIR=/tmp/saffron221 POKEPORT_IDENTITY=bug221 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/saffron_gate_bug221_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") + + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "RED" + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 10) } + game.save.flags = game.save.flags or {} + + local function topText() + 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 isBox() + return getmetatable(game.stack:top()) == TextBox + end + + -- let the typewriter finish typing up to its first pause (a scroll + -- or page break sets self.waiting), so a screenshot shows the guard's + -- words instead of an empty just-opened box. + local function waitTyped(maxFrames) + for _ = 1, (maxFrames or 240) do + local top = game.stack:top() + if getmetatable(top) == TextBox and (top.waiting or top.done) then return end + U.wait(1) + end + end + + -- walk the player north (holding "up") until a dialogue box pops or we + -- give up; returns true if a box appeared. Only presses while the + -- overworld is on top and the player is not already mid-step, so a step + -- that lands on the trigger cleanly hands control to the pushed TextBox. + local function walkNorthUntilBox(ow, maxFrames) + for _ = 1, maxFrames do + if isBox() then return true end + if game.stack:top() == ow and not ow.player.moving + and not ow.runner:isRunning() and #ow.scriptMoves == 0 + and not ow.transitioning then + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + end + U.wait(1) + game.input.state.up = false + end + return isBox() + end + + local function settle(ow, maxFrames) + for _ = 1, (maxFrames or 200) do + if game.stack:top() == ow and not ow.player.moving + and not ow.runner:isRunning() and #ow.scriptMoves == 0 + and not ow.transitioning then + return + end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(2) + end + end + + -- --------------------------------------------------------------- + -- CASE A: no drink -> thirsty line + shove back, gate stays shut + -- --------------------------------------------------------------- + game.save.inventory = {} + game.save.flags.EVENT_GAVE_GUARDS_DRINK = nil + + U.teleport(game, "ROUTE_5_GATE", 3, 5, "up") + local ow = game.overworld + assert(ow and ow.map.id == "ROUTE_5_GATE", + "CASE A: teleport did not land inside ROUTE_5_GATE (got " + .. tostring(ow and ow.map.id) .. ")") + assert(ow.player.cellX == 3 and ow.player.cellY == 5, + "CASE A: player not at spawn (3,5); got " + .. ow.player.cellX .. "," .. ow.player.cellY) + U.log("CASE A start:", ow.map.id, ow.player.cellX, ow.player.cellY) + + local gotBoxA = walkNorthUntilBox(ow, 400) + waitTyped(240) + U.shot(game, DIR .. "/saffron_bug221_nodrink.png") + local textA = topText() + U.log("CASE A box:", gotBoxA, "text:", textA) + assert(gotBoxA, "CASE A: no dialogue box appeared at the gate trigger") + + -- CORRECT Gen1: the drink-less thirsty line, NOT the accept/parched line. + assert(textA:find("thirsty", 1, true), + "CASE A: box is not the thirsty line (got: " .. textA .. ")") + assert(not textA:find("this drink", 1, true) + and not textA:find("Gee, thanks", 1, true), + "CASE A(#221): drink-less guard spoke the ACCEPT line (got: " .. textA .. ")") + + -- mash A through the (multi-page) thirsty box; the onDone shoves the + -- player back one tile. He must NOT pass -- still inside ROUTE_5_GATE. + settle(ow, 300) + U.log("CASE A after:", ow.map.id, ow.player.cellX, ow.player.cellY) + assert(ow.map.id == "ROUTE_5_GATE", + "CASE A: drink-less player passed the gate (map=" .. ow.map.id .. ")") + assert(ow.player.cellY >= 4, + "CASE A: player was not turned back (cellY=" .. ow.player.cellY .. ")") + assert(not game.save.flags.EVENT_GAVE_GUARDS_DRINK, + "CASE A: gave-drink flag set without a drink") + + -- --------------------------------------------------------------- + -- CASE B: FRESH_WATER in bag -> accept line, drink taken, pass through + -- --------------------------------------------------------------- + game.save.inventory = { FRESH_WATER = 1 } + game.save.flags.EVENT_GAVE_GUARDS_DRINK = nil + + U.teleport(game, "ROUTE_5_GATE", 3, 5, "up") + ow = game.overworld + assert(ow.map.id == "ROUTE_5_GATE", "CASE B: re-teleport failed") + U.log("CASE B start:", ow.map.id, ow.player.cellX, ow.player.cellY) + + local gotBoxB = walkNorthUntilBox(ow, 400) + waitTyped(240) + U.shot(game, DIR .. "/saffron_bug221_drink.png") + local textB = topText() + U.log("CASE B box:", gotBoxB, "text:", textB) + assert(gotBoxB, "CASE B: no dialogue box appeared at the gate trigger") + + -- CORRECT Gen1: carrying a drink triggers the parched/accept line. + assert(textB:find("this drink", 1, true) or textB:find("Gee, thanks", 1, true), + "CASE B: guard did not speak the accept line (got: " .. textB .. ")") + + -- clear the accept + "you can go on through" boxes, then keep walking + -- north out the top of the gate. + for _ = 1, 400 do + if game.stack:top() ~= ow then + U.tap(game, "a") + else + break + end + U.wait(2) + end + U.log("CASE B post-accept:", "FRESH_WATER=", + tostring(game.save.inventory.FRESH_WATER), + "flag=", tostring(game.save.flags.EVENT_GAVE_GUARDS_DRINK)) + assert(game.save.inventory.FRESH_WATER == nil, + "CASE B: the drink was not consumed") + assert(game.save.flags.EVENT_GAVE_GUARDS_DRINK == true, + "CASE B: EVENT_GAVE_GUARDS_DRINK not set after accepting the drink") + + -- with the flag now set the corridor is free; walk on out the north side + -- and STOP the instant the gate map is left (don't wander into the town's + -- scripts -- the north warp resolves to the heal point when we teleported + -- in with no remembered outdoor side, OverworldController:takeWarp). + for _ = 1, 300 do + ow = game.overworld + if ow.map.id ~= "ROUTE_5_GATE" then break end + if game.stack:top() == ow and not ow.player.moving + and not ow.runner:isRunning() and #ow.scriptMoves == 0 + and not ow.transitioning then + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + end + U.wait(1) + game.input.state.up = false + end + ow = game.overworld + U.shot(game, DIR .. "/saffron_bug221_passed.png") + U.log("CASE B end:", ow.map.id, ow.player.cellX, ow.player.cellY) + assert(ow.map.id ~= "ROUTE_5_GATE", + "CASE B: player with a drink never passed the gate") + + U.log("saffron_gate_bug221_test: ok") + love.event.quit() +end diff --git a/tests/drivers/seafoam_current_bug212_test.lua b/tests/drivers/seafoam_current_bug212_test.lua new file mode 100644 index 00000000..fb65ba93 --- /dev/null +++ b/tests/drivers/seafoam_current_bug212_test.lua @@ -0,0 +1,124 @@ +-- Driver: Seafoam Islands B3F strong-current plug rocks (issue #212). +-- +-- The two boulders the player pushes down through the B2F holes land on B3F +-- at cells (18,6) and (19,6) and plug the strong current (the reporter's +-- expected.png shows two round rocks sitting in the channel). The B2F +-- pluggedByHolesOn holes were wired to showObject TOGGLE_..._B3F_BOULDER_3/_4, +-- which OverworldState:toggleToObjectName resolves to SEAFOAMISLANDSB3F_ +-- BOULDER3/4 -- the ALREADY-VISIBLE pushable boulders at (8,14)/(9,14), not the +-- hidden landing rocks. The real landing objects at (18,6)/(19,6) are the +-- hidden BOULDER5/6, so the plug rocks never appeared (data/generated/field.lua +-- + tools/rom_manifest*.json now point showObject at BOULDER_5/_6). +-- +-- Case A asserts the CORRECT Gen1 outcome (rocks appear after plugging), so it +-- FAILS on the bug and PASSES once the toggles are repointed. +-- +-- Case B is a regression guard: with only ONE rock the current stays active +-- and gates the sole water chokepoint (the 2-wide gap at (18,7)/(19,7) is the +-- only water passage between the south and north pools -- verified from the +-- B3F water map), so a surfing player stepping up into it is swept south +-- (SeafoamIslandsB3F.asm) and cannot cross north. This confirms the reported +-- "swim in the strong current" is not reproducible around the trigger cells. +-- +-- Run: +-- POKEPORT_DRIVER=tests/drivers/seafoam_current_bug212_test.lua \ +-- POKEPORT_IDENTITY=bug212 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local shotDir = os.getenv("POKEPORT_SHOTDIR") or "." + local function shot(name) U.shot(game, shotDir .. "/" .. name) end + local Pokemon = require("src.pokemon.Pokemon") + + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.party = { Pokemon.new(game.data, "LAPRAS", 40) } + -- SURF (+ STRENGTH for the real puzzle) so the surfing/current paths run + game.save.party[1].moves = { { id = "SURF" }, { id = "STRENGTH" } } + + local fails = 0 + local function expect(cond, ...) + if not cond then fails = fails + 1 end + U.log(cond and "PASS" or "FAIL", ...) + end + + -- a visible SPRITE_BOULDER standing on cell (x,y)? self.npcs only holds + -- objects objectVisible() accepted, so a hidden-but-untoggled rock is absent + local function boulderAt(x, y) + for _, n in ipairs(game.overworld.npcs) do + local def = n.def + if def and def.sprite == "SPRITE_BOULDER" + and n.cellX == x and n.cellY == y then + return true + end + end + return false + end + + -- ------------------------------------------------------------------ + -- Case A: plugging both B2F holes reveals the B3F landing rocks. + -- ------------------------------------------------------------------ + do + game.save.objectToggles = {} + game.save.flags.EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = nil + game.save.flags.EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = nil + + -- baseline: the empty channel, no plug rocks yet + U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up") + game.overworld.player.surfing = true + U.wait(8) + shot("b3f_channel_empty.png") + expect(not boulderAt(18, 6) and not boulderAt(19, 6), + "A0: before plugging, no rock in the channel at (18,6)/(19,6)") + + -- push both plug boulders through their B2F holes via the real engine + -- path (OverworldState:boulderIntoHole sets the event flag AND the + -- destMap showObject toggle by name) + U.teleport(game, "SEAFOAM_ISLANDS_B2F", 19, 7, "up") + U.wait(6) + game.overworld:boulderIntoHole({ cellX = 19, cellY = 6 }) -- lands B3F (18,6) + game.overworld:boulderIntoHole({ cellX = 22, cellY = 6 }) -- lands B3F (19,6) + U.wait(4) + + U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up") + game.overworld.player.surfing = true + U.wait(8) + shot("b3f_channel_plugged.png") + expect(boulderAt(18, 6), "A1: plug rock visible at (18,6) after plugging both holes") + expect(boulderAt(19, 6), "A2: plug rock visible at (19,6) after plugging both holes") + end + + -- ------------------------------------------------------------------ + -- Case B: one rock -> current still gates the channel, no swim-through. + -- ------------------------------------------------------------------ + do + game.save.objectToggles = {} + game.save.flags.EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = true + game.save.flags.EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = nil + U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up") + local p = game.overworld.player + p.surfing = true + U.wait(6) + local startY = p.cellY + local minY = p.cellY + -- try to paddle north through the current toward the (18,4..6) pool + for _ = 1, 90 do + table.insert(game.input.pressQueue, "up") + game.input.state["up"] = true + coroutine.yield() + if p.cellY < minY then minY = p.cellY end + end + game.input.state["up"] = false + U.wait(20) + if p.cellY < minY then minY = p.cellY end + -- the north pool begins at y<=6; reaching it means the current failed to + -- gate the passage. Being swept keeps minY at 7 (the current cell) or south. + expect(minY >= 7, + "B: current gates the channel; player never crossed north (min cellY):", minY) + U.log("B: start cellY", startY, "min cellY", minY, "end cellY", p.cellY, + "surfing", tostring(p.surfing)) + end + + if fails > 0 then error(fails .. " check(s) failed") end + U.log("all checks passed -- #212 seafoam B3F plug rocks appear " + .. "and the strong current gates the channel") +end diff --git a/tests/drivers/summary_type_bug214_test.lua b/tests/drivers/summary_type_bug214_test.lua new file mode 100644 index 00000000..797883e4 --- /dev/null +++ b/tests/drivers/summary_type_bug214_test.lua @@ -0,0 +1,78 @@ +-- Driver (#214): status screen page 1 must print the type's DISPLAY name. +-- pokered engine/pokemon/status_screen.asm PrintMonType prints the type's +-- entry from the TypeNames table (data/types/names.asm), which for the +-- PSYCHIC_TYPE constant is "PSYCHIC". The engine stores each species' +-- types as pokered CONSTANT names (RomExtractor:typesById), and PSYCHIC's +-- constant is "PSYCHIC_TYPE" so it does not collide with the PSYCHIC move. +-- Drawing the raw constant overflowed the TYPE field: "PSYCHIC" fills +-- x=88..144, then "_TYPE" runs into the right DrawLineBox bracket (the +-- stray "+" tick the reporter circled). SummaryMenu must route the label +-- through TypeChart.displayName like HallOfFame / BattleState already do. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local SummaryMenu = require("src.ui.SummaryMenu") + local TypeChart = require("src.battle.TypeChart") + local Font = require("src.render.Font") + + game.save.player.name = "YOSHIRB" + + -- Capture what SummaryMenu actually draws at the TYPE1 value slot + -- (x=88, y=80 in SummaryMenu:draw). Monkeypatch the Font table field so + -- the local Font reference inside SummaryMenu resolves to our wrapper at + -- call time; this reads the real rendered string, not TypeChart in + -- isolation, so it fails while the raw constant is drawn. + local realDraw = Font.draw + local captured + local function recordShot(game, mon, path) + local summary = SummaryMenu.new(game, mon) + game.stack:push(summary) + U.wait(4) + captured = nil + Font.draw = function(text, x, y) + if x == 88 and y == 80 then captured = text end + return realDraw(text, x, y) + end + U.shot(game, path) + Font.draw = realDraw + game.stack:pop() + U.wait(2) + return captured + end + + -- Psychic mon: the bug case. MEW is pure PSYCHIC. + local mew = Pokemon.new(game.data, "MEW", 10) + local mewDrawn = recordShot(game, mew, DIR .. "/summary_type_214_mew_p1.png") + U.log("MEW raw types[1]=", tostring(game.data.pokemon.MEW.types[1]), + "drawn TYPE1=", tostring(mewDrawn)) + + -- Control: RATTATA is pure NORMAL, whose constant == display name, so it + -- was never affected; it should still read "NORMAL". + local ratt = Pokemon.new(game.data, "RATTATA", 5) + local rattDrawn = recordShot(game, ratt, DIR .. "/summary_type_214_rattata_p1.png") + U.log("RATTATA raw types[1]=", tostring(game.data.pokemon.RATTATA.types[1]), + "drawn TYPE1=", tostring(rattDrawn)) + + U.log("shots under", DIR) + + -- Raw data must still carry the pokered constant: it is the shared key for + -- TypeChart matchups and move.type, so the fix lives at the display layer. + assert(game.data.pokemon.MEW.types[1] == "PSYCHIC_TYPE", + "expected MEW raw type constant PSYCHIC_TYPE, got " + .. tostring(game.data.pokemon.MEW.types[1])) + assert(TypeChart.displayName("PSYCHIC_TYPE") == "PSYCHIC", + "TypeChart.displayName(PSYCHIC_TYPE) must be PSYCHIC") + + -- The bug: SummaryMenu drew the raw constant "PSYCHIC_TYPE" (overflowing). + -- The fix: it must draw the display name "PSYCHIC" (7 chars, fits 88..144). + assert(mewDrawn == "PSYCHIC", + "#214: status screen TYPE1 for MEW must render PSYCHIC, drew " + .. tostring(mewDrawn)) + assert(not tostring(mewDrawn):find("_"), + "#214: TYPE1 label must not contain '_' (no glyph, overflows bracket)") + assert(rattDrawn == "NORMAL", + "control: RATTATA TYPE1 must render NORMAL, drew " .. tostring(rattDrawn)) + + U.log("#214 PASS: status-screen TYPE1 renders display names") +end diff --git a/tests/drivers/tmhm_able_bug210_test.lua b/tests/drivers/tmhm_able_bug210_test.lua new file mode 100644 index 00000000..3decc6c3 --- /dev/null +++ b/tests/drivers/tmhm_able_bug210_test.lua @@ -0,0 +1,86 @@ +-- Driver: teaching a TM must open the party menu in Gen 1's TM/HM mode, +-- showing ABLE / NOT ABLE per mon from its learnset (no HP bars) with the +-- "Use TM on which POKeMON?" prompt (engine/items/item_effects.asm +-- ItemUseTMHM -> engine/menus/party_menu.asm PrintPartyMenu TM/HM type). #210 +-- +-- Party CHARIZARD 50 / MAGIKARP 15 / SNORLAX 40 with TM_TOXIC: CHARIZARD +-- and SNORLAX learn TOXIC (ABLE); MAGIKARP has an empty tmhm (NOT ABLE). +-- +-- SHOT_DIR=/tmp/bug210 POKEPORT_DRIVER=tests/drivers/tmhm_able_bug210_test.lua \ +-- POKEPORT_IDENTITY=bug210 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Bag = require("src.inventory.Bag") + local TextBox = require("src.render.TextBox") + local PartyMenu = require("src.ui.PartyMenu") + + -- fresh party with mixed learnability + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "MAGIKARP", 15), + Pokemon.new(game.data, "SNORLAX", 40), + } + Bag.add(game.save, "TM_TOXIC", 1) + U.teleport(game, "PALLET_TOWN", 5, 5, "down") + + -- expected ABLE/NOT ABLE from the same learnset the teach reads + -- (ItemEffects.use scans data.pokemon[species].tmhm for machine.move) + local move = game.data.items.TM_TOXIC.machine.move + for _, mon in ipairs(game.save.party) do + local can = false + for _, m in ipairs(game.data.pokemon[mon.species].tmhm or {}) do + if m == move then can = true break end + end + U.log("expect", mon.species, can and "ABLE" or "NOT ABLE") + end + + -- open the bag; BagMenu.new returns the ITEMS ListMenu + local bag = require("src.ui.Screens").push(game, "BagMenu") + U.wait(3) + for i, it in ipairs(bag.items) do + if it.value == "TM_TOXIC" then bag.index = i break end + end + U.log("bag index on TM_TOXIC:", bag.index, bag.items[bag.index].value) + + -- A -> USE / TOSS menu, then A on USE (index 1) + U.tap(game, "a"); U.wait(6) + U.tap(game, "a"); U.wait(6) + + local function pageText(box) + if not box or getmetatable(box) ~= TextBox then return "" end + return table.concat(box.pages[box.pageIndex] or {}, "\n") + end + + -- mash through "Booted up a TM!" / "It contained TOXIC!" until the + -- TM/HM party menu is on top; stop before tapping A on it (that would + -- pick a mon) + local function reachPartyMenu(max) + for _ = 1, max or 120 do + if getmetatable(game.stack:top()) == PartyMenu then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + local reached = reachPartyMenu() + U.log("reached PartyMenu:", tostring(reached)) + U.wait(3) + U.shot(game, DIR .. "/tmhm_bug210_partymenu.png") + + local top = game.stack:top() + U.log("top is PartyMenu:", getmetatable(top) == PartyMenu) + U.log("top.tmhm:", top.tmhm and ("move=" .. tostring(top.tmhm.move) + .. " kind=" .. tostring(top.tmhm.kind)) or "nil") + + -- assertions: fail while the bug exists (no TM mode), pass once fixed + assert(getmetatable(top) == PartyMenu, "did not reach the party menu") + assert(top.tmhm, "party menu is not in TM/HM mode (opts.tmhm missing)") + assert(top.tmhm.move == move, + "TM/HM move mismatch: " .. tostring(top.tmhm.move)) + + U.log("DONE") + love.event.quit() +end diff --git a/tests/drivers/tower7f_bug200_test.lua b/tests/drivers/tower7f_bug200_test.lua new file mode 100644 index 00000000..73174d5f --- /dev/null +++ b/tests/drivers/tower7f_bug200_test.lua @@ -0,0 +1,218 @@ +-- Driver: #200 Pokemon Tower 7F Rocket grunts must walk off + despawn. +-- The three grunts (POKEMONTOWER7F_ROCKET1/2/3, obj indices 1/2/3, at +-- (9,11)/(12,9)/(9,7)) are sight/talk trainers. In Gen1 +-- (scripts/PokemonTower7F.asm: PokemonTower7FEndBattleScript -> +-- PokemonTower7FRocketLeaveMovementScript -> PokemonTower7FHideNPCScript) +-- each grunt, after losing, shows its EndBattle text ("I give up!"), then its +-- AfterBattle text ("I'm not going to forget this!"), then walks off toward +-- the (9,16) stairs (MoveSprite) and HideObject despawns it. The bug left +-- every beaten grunt standing in place forever -- att1 in the report shows +-- all three still lined up in the corridor after being defeated. +-- +-- This driver beats each grunt in turn and, per grunt, samples ow.npcs every +-- frame: the grunt must MOVE off its start tile before it leaves ow.npcs, and +-- must end despawned (gone from ow.npcs, objectToggle == false, beat flag set). +-- Before the fix each grunt stays at its start tile and never despawns, so the +-- movement + despawn assertions fail. After the fix they pass. +-- +-- SHOT_DIR=/tmp/t7f POKEPORT_IDENTITY=bug200 POKEPORT_TOUCH=0 \ +-- POKEPORT_SPEED=4 \ +-- POKEPORT_DRIVER=tests/drivers/tower7f_bug200_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") + + -- clean slate: no grunt may read as already defeated / hidden + game.save.defeatedTrainers = {} + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles.POKEMON_TOWER_7F = nil + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_0 = nil + game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_1 = nil + game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_2 = nil + game.save.player = game.save.player or {} + game.save.player.name = game.save.player.name or "RED" + + -- a tank that one-shots any of the L19-25 Rocket parties regardless of + -- type, so the mash win is quick and deterministic (same valve as + -- gamecorner_rocket_bug198_test) + local function freshParty() + 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 } + end + + -- Each grunt: object index/name/id, home tile, and the tile the player + -- stands on (facing up, directly below the grunt) to talk it into a battle. + -- The talk tiles are the ones the vanilla movement table keys on, so the + -- exit walk is the exact PokemonTower7F path (not the safety fallback). + local ROCKETS = { + { index = 1, name = "POKEMONTOWER7F_ROCKET1", + id = "POKEMON_TOWER_7F_obj_1", + beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0", + afterFrag = "forget this", + rx = 9, ry = 11, standX = 9, standY = 12 }, + { index = 2, name = "POKEMONTOWER7F_ROCKET2", + id = "POKEMON_TOWER_7F_obj_2", + beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1", + afterFrag = "making", + rx = 12, ry = 9, standX = 12, standY = 10 }, + { index = 3, name = "POKEMONTOWER7F_ROCKET3", + id = "POKEMON_TOWER_7F_obj_3", + beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2", + afterFrag = "getting", rx = 9, ry = 7, standX = 9, standY = 8 }, + } + + 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 shotN = 0 + local function shot(tag) + shotN = shotN + 1 + U.shot(game, DIR .. ("/tower7f_%02d_%s.png"):format(shotN, tag)) + end + + -- Beat one grunt via a talk engagement, then verify it walks off + despawns. + -- shots ~= nil requests the before/walk/after screenshot trio for the report. + local function beatGrunt(r, shots) + U.teleport(game, "POKEMON_TOWER_7F", r.standX, r.standY, "up") + local ow = game.overworld + + local function findGrunt() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == r.name then return n end + end + return nil + end + local function idle() + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and not ow.transitioning + end + + local g = findGrunt() + assert(g, r.name .. " not present at start") + assert(g.cellX == r.rx and g.cellY == r.ry, + r.name .. " not at expected start tile") + U.log(r.name, "start:", g.cellX, g.cellY, g.facing) + if shots then shot("standing") end + + -- Phase A: talk, then mash through pre-battle text + the battle, stopping + -- the instant the win is registered (the beat flag is set inside the + -- battle's onFinish, before the EndBattle box is pushed) so Phase B can + -- watch the exit walk that follows. + U.tap(game, "a") + local sawAfter = false + for f = 1, 4000 do + if game.save.flags[r.beat] then break end + if pageText():find(r.afterFrag, 1, true) then sawAfter = true end + local top = game.stack:top() + if 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 stalled battle") + top.onFinish("win") + if game.stack:top() == top then game.stack:pop() end + end + else + U.tap(game, "a") -- talk / advance pre-battle text + end + U.wait(2) + end + assert(game.save.flags[r.beat], r.name .. " never registered a win") + + -- Phase B: sample every logic frame while advancing the EndBattle + + -- AfterBattle boxes. The grunt must move off its start tile (mid-walk it + -- carries targetX/targetY toward the stairs, then its cell updates) before + -- HideObject removes it from ow.npcs. + local moved, walkShot = false, false + for _ = 1, 2000 do + local gg = findGrunt() + if gg then + if gg.cellX ~= r.rx or gg.cellY ~= r.ry + or (gg.targetX and gg.targetX ~= r.rx) + or (gg.targetY and gg.targetY ~= r.ry) then + moved = true + if shots and not walkShot then + walkShot = true + shot("walkoff") + end + end + elseif idle() then + break + end + if pageText():find(r.afterFrag, 1, true) then sawAfter = true 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) + if shots then shot("gone") end + + local toggles = game.save.objectToggles.POKEMON_TOWER_7F + U.log(r.name, "sawAfter:", sawAfter, "moved:", moved, + "gone:", findGrunt() == nil, + "toggle:", tostring(toggles and toggles[r.name])) + + -- CORRECT Gen1 behavior: EndBattle/AfterBattle speech, then the grunt + -- walks off before despawning -- it must not vanish in place. + assert(sawAfter, r.name .. " never showed its after-battle text (#200)") + assert(moved, + r.name .. " never walked off its tile before despawning (#200)") + assert(findGrunt() == nil, r.name .. " still present after the exit walk") + assert(toggles and toggles[r.name] == false, + r.name .. " objectToggle not persisted hidden") + assert(game.save.defeatedTrainers[r.id], r.name .. " not recorded defeated") + for _, n in ipairs(ow.npcs or {}) do + assert(not (n.cellX == r.rx and n.cellY == r.ry), + "an NPC still occupies " .. r.name .. "'s old tile") + end + end + + freshParty() + beatGrunt(ROCKETS[1], true) -- ROCKET1 gets the report screenshot trio + freshParty() + beatGrunt(ROCKETS[2], false) + freshParty() + beatGrunt(ROCKETS[3], false) + + -- all three gone: confirm the corridor up to Mr. Fuji is clear of grunts + U.teleport(game, "POKEMON_TOWER_7F", 9, 13, "up") + local ow = game.overworld + for _, n in ipairs(ow.npcs or {}) do + assert(n.def.name == "POKEMONTOWER7F_MR_FUJI", + "a Rocket grunt is still on the floor after all three were beaten") + end + local fuji + for _, n in ipairs(ow.npcs or {}) do + if n.def.name == "POKEMONTOWER7F_MR_FUJI" then fuji = n end + end + assert(fuji, "MR_FUJI missing from the top floor") + shot("fuji_clear") + U.log("tower7f_bug200_test: ok") +end diff --git a/tests/drivers/townmap_selector_bug152_test.lua b/tests/drivers/townmap_selector_bug152_test.lua new file mode 100644 index 00000000..f53d7b9c --- /dev/null +++ b/tests/drivers/townmap_selector_bug152_test.lua @@ -0,0 +1,156 @@ +-- Driver: regression coverage for #152 "'Selector' on the world map is off and +-- character location is not shown". +-- +-- pret/pokered engine/menus/town_map.asm DisplayTownMap draws the Kanto map +-- with a blinking box cursor CENTERED on the currently selected location plus a +-- separate blinking "you are here" marker at the player's current map location. +-- Two independent defects lived in src/ui/TownMap.lua's grid+background path +-- (the primary path when the extracted Kanto art is present): +-- +-- DEFECT A (selector off): the 16x16 hollow cursor frame was drawn with its +-- top-left AT the 8x8 cell's top-left (markerXY), so the frame's center +-- landed +4,+4 off the cell -- the town square sat in the frame's top-left +-- quadrant instead of being enclosed. Fix centers the frame (-4,-4). +-- +-- DEFECT B (character location not shown): the player marker was painted with +-- setColor(0.75,0.1,0.1) red. The TOWN MAP composites through the SGB +-- shade-remap shader (PaletteFX.shader), which keys ONLY on the red channel; +-- red 0.75 falls in the c1 bucket = TOWNMAP {165,214,255}, the exact +-- light-blue used for water and the town-square fill, so the marker was +-- painted but recolored invisible. Fix paints it red=0 -> c3 (dark dot). +-- +-- This driver opens the grid TOWN MAP outdoors at PALLET TOWN, moves the cursor +-- off the player's town to VIRIDIAN CITY (so the you-are-here marker and the +-- selection cursor must BOTH be visible), and asserts -- at native resolution, +-- after replicating the Renderer's TOWNMAP shade-remap pass -- that the player +-- marker is a visible dark dot (DEFECT B) and the cursor frame is centered on +-- the selected cell (DEFECT A). Fails on the current build, passes once fixed. +-- +-- Run: +-- SHOT_DIR=/tmp/bug152 POKEPORT_IDENTITY=bug152 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/townmap_selector_bug152_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local PaletteFX = require("src.render.PaletteFX") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- The defect lives in the SGB ('gbc') mode shade-remap; pin it deterministic. + game.save.options = game.save.options or {} + game.save.options.colors = "gbc" + PaletteFX.setMode("gbc") + + game.save.player = game.save.player or {} + game.save.player.name = "bryan" + + -- Outdoor map so the player's map resolves to a town-map location (byMap). + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(5) + + Screens.push(game, "TownMap") + U.wait(6) + local top = game.stack:top() + + -- Functional preconditions: the grid path with extracted art, and the + -- character's location known (playerLoc non-nil) -- otherwise the two draw + -- defects would not even be on the code path we mean to test. + assert(top and top.mode == "grid", + "town map must be in grid mode (extracted art), got " .. tostring(top and top.mode)) + assert(top.bg ~= nil and top.bg.cursor ~= nil, + "extracted Kanto background + cursor asset must be present for this test") + assert(top.playerLoc ~= nil and top.playerLoc.name == "PALLET TOWN", + "character location must resolve to PALLET TOWN, got " + .. tostring(top.playerLoc and top.playerLoc.name)) + + -- Human-viewable before/after frame: cursor still on the player's own town. + top.blink = 0 + U.shot(game, DIR .. "/townmap_bug152_pallet.png") + U.wait(4) + + -- Move the cursor OFF the player's town up to VIRIDIAN CITY, so the + -- you-are-here marker (PALLET) and the selection cursor (VIRIDIAN) occupy + -- different cells and BOTH must render (matches the reporter's should2). + local guard = 0 + while top.locs[top.sel].name ~= "VIRIDIAN CITY" and guard < 8 do + U.tap(game, "up"); U.wait(3); guard = guard + 1 + end + assert(top.locs[top.sel].name == "VIRIDIAN CITY", + "up must snap the cursor to VIRIDIAN CITY, landed on " + .. tostring(top.locs[top.sel].name)) + assert(top.locs[top.sel] ~= top.playerLoc, + "cursor must be on a DIFFERENT cell than the you-are-here marker") + + top.blink = 0 + U.shot(game, DIR .. "/townmap_bug152_viridian.png") + U.wait(4) + + -- ---- deterministic native-resolution pixel gate ---- + -- TownMap:draw() emits raw DMG shades; the Renderer composites the whole + -- screen through PaletteFX.shader() with the TOWNMAP SGB palette (keyed on + -- the RED channel only). Replicate that here at native 160x144 so the pixel + -- checks are scale-independent -- the same offscreen-colorize technique as + -- battle_hpbar_gbc_bug229_test.lua. + if not (love and love.graphics and love.graphics.newCanvas and PaletteFX.shader()) then + error("#152 driver: no shader/canvas support available to verify colorized output") + end + + top.blink = 0 -- marker shows while blink<20, cursor while blink%16<10 + local raw = love.graphics.newCanvas(160, 144) + love.graphics.setCanvas(raw) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + top:draw() + love.graphics.setCanvas() + + local shader = PaletteFX.shader() + local colors = PaletteFX.pal(game.data, "TOWNMAP") + assert(colors, "TOWNMAP palette must resolve") + local shaded = love.graphics.newCanvas(160, 144) + love.graphics.setCanvas(shaded) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setShader(shader) + PaletteFX.sendColors(shader, colors) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(raw) + love.graphics.setShader() + love.graphics.setCanvas() + + local id = shaded:newImageData() + do -- save the colorized native frame for human viewing + local d = id:encode("png") + local f = io.open(DIR .. "/townmap_bug152_viridian_native.png", "wb") + if f then f:write(d:getString()); f:close() end + end + + local function sumAt(x, y) + local r, g, b = id:getPixel(x, y) + return r + g + b, r, g, b + end + + -- markerXY(loc) = (loc.x*8+16, loc.y*8+8) = the 8x8 cell top-left. + -- PALLET (x2,y11) -> (32,96); the you-are-here dot fills +2,+2 4x4, center ~ (35,99). + local ms, mr, mg, mb = sumAt(35, 99) + -- VIRIDIAN (x2,y8) -> (32,72). Post-fix the centered 16x16 frame's top border + -- sits ~4px ABOVE the cell top (row ~68) and its left border ~4px LEFT (col ~28). + local ts = select(1, sumAt(32, 68)) + local ls = select(1, sumAt(28, 78)) + U.log(string.format("marker(35,99) rgb=%.2f/%.2f/%.2f sum=%.2f", mr, mg, mb, ms)) + U.log(string.format("cursor top(32,68) sum=%.2f left(28,78) sum=%.2f", ts, ls)) + + -- DEFECT B: the "you are here" marker must be a VISIBLE dark dot, not the + -- map-fill light-blue (sum 2.49) the red-channel shade-remap folded it into. + assert(ms < 0.6, string.format( + "#152 DEFECT B: player-location marker invisible -- PALLET cell reads sum=%.2f " + .. "(map-fill blue); expected a dark you-are-here dot (sum<0.6)", ms)) + -- DEFECT A: the 16x16 cursor frame must be CENTERED on the selected cell, so a + -- dark border pixel exists ~4px above and ~4px left of the VIRIDIAN cell top-left. + assert(ts < 0.6, string.format( + "#152 DEFECT A: cursor frame not centered -- no dark top border above VIRIDIAN " + .. "cell (sum=%.2f, expected <0.6)", ts)) + assert(ls < 0.6, string.format( + "#152 DEFECT A: cursor frame not centered -- no dark left border of VIRIDIAN " + .. "cell (sum=%.2f, expected <0.6)", ls)) + + U.log("RESULT bug152 PASS") + U.wait(2) +end diff --git a/tests/drivers/trade_autosave_bug222_test.lua b/tests/drivers/trade_autosave_bug222_test.lua new file mode 100644 index 00000000..073bdaf7 --- /dev/null +++ b/tests/drivers/trade_autosave_bug222_test.lua @@ -0,0 +1,150 @@ +-- Driver (#222): a completed LAN link trade must autosave immediately, so a +-- player who quits before touching the START menu keeps the received mon and +-- cannot clone the sent one by resetting. +-- +-- pokered engine/link/cable_club.asm: the Cable Club calls SaveSAVtoSRAM +-- (engine/menus/save.asm) right after every trade commits, so the swap is on +-- the cartridge the instant it happens. Our LinkState:updateTrade "done" +-- branch swaps game.save.party in memory (TradeSession:apply) but, before the +-- fix, never persisted it -- SaveData.load then returned the pre-trade party +-- from disk, losing the received mon and re-materializing the sent one (the +-- classic reset-to-clone vector). +-- +-- This drives the real receiver side: lay a baseline disk save with a PIDGEY +-- party (the "player saved earlier" state), negotiate a TradeSession to +-- "done" against a peer's RATTATA (RATTATA has no trade evolution, so the run +-- is deterministic), hand it to a live LinkState so the completion branch +-- runs, then RELOAD FROM DISK and assert the on-disk party holds the received +-- RATTATA and no stale PIDGEY -- fails while the autosave is missing, passes +-- once updateTrade calls game:writeSave(). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Protocol = require("src.link.Protocol") + local LinkState = require("src.link.LinkState") + local Net = require("src.link.Net") + local SaveData = require("src.core.SaveData") + local TextBox = require("src.render.TextBox") + local TradeAnim = require("src.ui.TradeAnim") + + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + + -- (1) a real overworld so writeSave's captureSave has a world to stamp + U.teleport(game, "PALLET_TOWN", 5, 6, "down") + U.wait(5) + + -- (2) baseline party + baseline disk save: this is the state the player + -- would fall back to if the trade never persisted (must NOT contain RATTATA) + game.save.party = { Pokemon.new(game.data, "PIDGEY", 10) } + local version = game.save.version + game:writeSave() + U.shot(game, DIR .. "/bug222_00_before.png") + + local baseline = SaveData.load(version) + U.log("baseline on disk:", baseline and baseline.party and baseline.party[1] + and baseline.party[1].species, "count:", + baseline and baseline.party and #baseline.party) + assert(baseline and baseline.party and baseline.party[1] + and baseline.party[1].species == "PIDGEY", + "#222 setup: baseline disk save must hold the PIDGEY party") + + -- (3) drive two TradeSessions to "done" exactly like run_link_tests.lua: + -- ours is built on the LIVE game.save.party so apply() mutates it in place. + local peerParty = { Pokemon.new(game.data, "RATTATA", 8) } + peerParty[1].ot = "CULLEN"; peerParty[1].otId = 16012 -- distinct sender + local ourTrade = Protocol.TradeSession.new(game.data, game.save.party) + local peerTrade = Protocol.TradeSession.new(game.data, peerParty) + ourTrade:handle({ type = "party", mons = Protocol.packParty(peerParty) }) + peerTrade:handle({ type = "party", mons = Protocol.packParty(game.save.party) }) + assert(ourTrade.stage == "picking", "trade should reach picking") + local pickOurs = ourTrade:pick(1) -- gives PIDGEY + local pickPeer = peerTrade:pick(1) -- gives RATTATA + ourTrade:handle(pickPeer) + peerTrade:handle(pickOurs) + assert(ourTrade.stage == "confirming", "both picks -> confirming") + local cOurs = ourTrade:confirm(true) + local cPeer = peerTrade:confirm(true) + ourTrade:handle(cPeer) + peerTrade:handle(cOurs) + assert(ourTrade.stage == "done", "both confirms -> done") + + -- (4) attach the completed session to a live LinkState in the trade stage, + -- fresh Net (no peer) so poll() is empty and the "done" branch runs at once + local ls = LinkState.new(game) + ls.net = Net.new() + ls.peerName = "CULLEN" + ls.verdict = "full" + ls.confirmed = true + ls.stage = "trade" + ls.trade = ourTrade + game.stack:push(ls) + + -- (5) let the stack update ls once: the "done" branch applies the swap into + -- game.save.party, autosaves (after the fix), pops ls, pushes TradeAnim + for _ = 1, 120 do + if game.stack:top() ~= ls then break end + U.wait(1) + end + assert(game.stack:top() ~= ls, "#222: LinkState must leave the trade 'done' branch") + U.log("in-memory party after trade:", game.save.party[1] + and game.save.party[1].species) + + -- advance the TradeAnim until the "Trade completed!" TextBox appears for the + -- after shot; the disk write already happened in the done branch, this is + -- cosmetic. A fresh Font require keeps this independent of TradeAnim state. + local Font = require("src.render.Font") + local sawText = false + for _ = 1, 4000 do + local top = game.stack:top() + if top == game.overworld then break end + if getmetatable(top) == TradeAnim and not top.waitingText then + top:update(1 / 60) + end + if topIs(TextBox) then sawText = true; break end + U.tap(game, "a") + U.wait(1) + end + -- fully reveal the current page ("Trade completed!") so the shot shows text + -- regardless of the typewriter speed (mirrors trade_anim_test.lua) + local tb = game.stack:top() + if getmetatable(tb) == TextBox and tb.pages and tb.pageIndex then + local page = tb.pages[tb.pageIndex] + if page then + tb.shown = {} + for _, line in ipairs(page) do + tb.shown[#tb.shown + 1] = Font.encode(line) + end + tb.lineIndex = #page + tb.charIndex = #(tb.shown[#tb.shown] or {}) + tb.done = true + end + end + U.wait(2) + U.shot(game, DIR .. "/bug222_01_trade_complete.png") + U.log("reached trade-complete text:", sawText) + + -- (6) RELOAD FROM DISK and assert the trade was persisted without a manual + -- save. These fail before the fix (disk still holds the baseline PIDGEY). + local disk = SaveData.load(version) + U.log("on disk after trade:", disk and disk.party and disk.party[1] + and disk.party[1].species, "count:", + disk and disk.party and #disk.party) + + assert(disk and disk.party, "#222: a save file must exist on disk") + assert(disk.party[1] and disk.party[1].species == "RATTATA", + "#222: trade must autosave -- on-disk lead must be the received RATTATA, was " + .. tostring(disk.party[1] and disk.party[1].species)) + assert(#disk.party == 1, + "#222: on-disk party must be exactly the swapped party (no phantom slot), had " + .. tostring(#disk.party)) + for i, mon in ipairs(disk.party) do + assert(mon.species ~= "PIDGEY", + "#222: the sent PIDGEY must not survive on disk (clone vector), found at slot " + .. tostring(i)) + end + + U.log("#222 PASS: link trade autosaved; disk holds RATTATA, PIDGEY gone") +end diff --git a/tests/drivers/trade_ot_bug215_test.lua b/tests/drivers/trade_ot_bug215_test.lua new file mode 100644 index 00000000..3681d3d8 --- /dev/null +++ b/tests/drivers/trade_ot_bug215_test.lua @@ -0,0 +1,95 @@ +-- Driver (#215): a link-traded mon must keep its ORIGINAL trainer's OT +-- name and ID on the receiving game, not adopt the receiver's identity. +-- +-- pokered engine/link/cable_club.asm + home/serial.asm: a trade transmits +-- the whole party data block, which carries each mon's OT ID (party_struct +-- MON_OTID offset) and the OT-names block (wPartyMonOT). The receiving game +-- copies both verbatim and never overwrites -- a differing OT/ID is exactly +-- what marks a mon as traded (boosted EXP, high-level disobedience). +-- +-- The bug: Protocol.packMon/unpackMon dropped mon.ot/mon.otId on the wire, +-- so a received mon arrived ot=nil/otId=nil and SummaryMenu (status_screen.asm +-- StatusScreen) fell back to the local player's name/ID -- the reporter saw +-- the sender's CULLEN/16012 mon show up on the receiver as RED/60368. This +-- drives the receiver side headlessly: pack a CULLEN-owned mon and unpack it, +-- then read exactly what SummaryMenu draws in the IDNo/OT slots. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Protocol = require("src.link.Protocol") + local SummaryMenu = require("src.ui.SummaryMenu") + local BattleState = require("src.battle.BattleState") + local Font = require("src.render.Font") + + -- This install is the RECEIVER: player RED, ID 60368 (from the report's + -- Blue window). A correctly preserved OT must NOT match these. + game.save.player.name = "RED" + game.save.player.id = 60368 + + -- Capture exactly the strings SummaryMenu draws in the IDNo value slot + -- (x=96, y=112) and the OT value slot (x=96, y=128) on page 1. Monkeypatch + -- the Font table field so SummaryMenu's own `local Font` reference resolves + -- to our wrapper at call time -- this reads the real rendered strings, not + -- Protocol in isolation, so it fails while the received mon carries no OT. + local realDraw = Font.draw + local function recordShot(mon, path) + local summary = SummaryMenu.new(game, mon) + game.stack:push(summary) + U.wait(4) + local capturedId, capturedOt = nil, nil + Font.draw = function(text, x, y) + if x == 96 and y == 112 then capturedId = text end + if x == 96 and y == 128 then capturedOt = text end + return realDraw(text, x, y) + end + U.shot(game, path) + Font.draw = realDraw + game.stack:pop() + U.wait(2) + return capturedId, capturedOt + end + + -- The bug case: a RATTATA caught by trainer CULLEN (id 16012), sent across + -- the wire. pack -> unpack is exactly what the trade session does to the + -- mon before it lands in the receiver's party (Protocol.TradeSession). + local sender = Pokemon.new(game.data, "RATTATA", 8) + sender.ot = "CULLEN" + sender.otId = 16012 + local received = Protocol.unpackMon(game.data, Protocol.packMon(sender)) + local recvId, recvOt = + recordShot(received, DIR .. "/trade_ot_215_received_p1.png") + U.log("received mon: IDNo=", tostring(recvId), " OT=", tostring(recvOt)) + + -- Control: a mon caught locally on THIS game gets the player stamped as OT + -- (BattleState.stampOT, engine/battle/core.asm on catch), so its summary + -- correctly reads RED / 60368. This proves the player-fallback path itself + -- is fine and it is only the received mon that was wrong. + local mine = Pokemon.new(game.data, "PIDGEY", 8) + BattleState.stampOT(game.save, mine) + local mineId, mineOt = + recordShot(mine, DIR .. "/trade_ot_215_control_p1.png") + U.log("self-caught mon: IDNo=", tostring(mineId), " OT=", tostring(mineOt)) + + U.log("shots under", DIR) + + -- The received mon must show the ORIGINAL trainer, not the receiver. + assert(recvOt == "CULLEN", + "#215: received mon OT must render CULLEN (the original trainer), drew " + .. tostring(recvOt)) + assert(recvId == "16012", + "#215: received mon IDNo must render 16012 (the original trainer ID), drew " + .. tostring(recvId)) + assert(recvOt ~= game.save.player.name, + "#215: received mon must not adopt the receiver's OT name") + assert(recvId ~= ("%05d"):format(game.save.player.id), + "#215: received mon must not adopt the receiver's ID") + + -- Control must still show the local player (self-caught mon is unaffected). + assert(mineOt == "RED", + "control: self-caught mon OT must render RED, drew " .. tostring(mineOt)) + assert(mineId == "60368", + "control: self-caught mon IDNo must render 60368, drew " .. tostring(mineId)) + + U.log("#215 PASS: link-traded mon keeps the original trainer's OT/ID") +end diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua index 046a6e84..bf6b0e2a 100644 --- a/tests/engine/save_file_io_tests.lua +++ b/tests/engine/save_file_io_tests.lua @@ -235,6 +235,79 @@ do end end +-- ---------------------------------------------- #206: exported map + position +-- Regression guard for issue #206 ("exported .sav loads as a glitch map / +-- crashes in an emulator"). The Gen1 continue path rebuilds the entire +-- overworld from the saved current-map byte and the player's tile coords +-- (engine/menus/main_menu.asm SpecialEnterMap -> ResetPlayerSpriteData -> +-- EnterMap -> LoadMapHeader), so the two ways a real cartridge glitches on +-- Continue are (a) wCurMap not resolving to a real map header, and (b) the +-- player's wYCoord/wXCoord falling outside the map's walk grid. A cell is 2x2 +-- background tiles (home/overworld.asm), so a WxH-block map is 2W x 2H cells +-- and valid coords are 0..2W-1 / 0..2H-1. Drive the launcher's real export +-- path (import -> SaveData.load -> exportActiveSlot) and assert the emitted +-- bytes land a loadable player across interior/outdoor/cave maps, including +-- each map's far corner, so this class of corruption cannot slip back in. +do + local mapsByIndex = {} + for id, def in pairs(data.maps) do + if def.index then mapsByIndex[def.index] = id end + end + + -- One export cycle through the exact glue the SAVE FILES card uses. Returns + -- the raw exported image, or nil if any stage refused (checked by callers). + local function exportedThrough(mapId, x, y) + local files = fresh() + local seed = SaveData.newGame({ playerName = "JOHN", rivalName = "BLUE" }) + seed.player.map, seed.player.x, seed.player.y = mapId, x, y + -- a plausible mid-game party so the slot is not a blank new game + seed.party = { { + species = "SQUIRTLE", level = 16, exp = 4000, + dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 44, attack = 28, defense = 31, speed = 26, special = 30 }, + hp = 44, moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + nickname = "SQ", ot = "JOHN", otId = seed.player.id, catchRate = 45, + } } + if not SaveFileIO.importToSlot(GenSave.encode(seed, data, nil), "red") then return nil end + if not SaveData.load("red") then return nil end + if not SaveFileIO.exportActiveSlot("red") then return nil end + return files["exports/gen1recomp-red-slot1.sav"] + end + + local function assertLoadable(label, mapId, x, y) + local def = data.maps[mapId] + if not def then return end -- this data set lacks the map; skip silently + local out = exportedThrough(mapId, x, y) + check(out ~= nil and #out == GenSave.SAVE_SIZE, + label .. ": exports a 32768-byte image through the launcher path") + if not out then return end + -- (a) wCurMap resolves: an unresolved index loads a garbage header, the + -- #206 glitch/crash itself + eq(mapsByIndex[out:byte(OFF.curMap + 1)], mapId, + label .. ": exported wCurMap resolves to the saved map") + -- (b) tile coords inside the 2W x 2H cell grid, and unchanged by the round trip + local cx, cy = out:byte(OFF.xCoord + 1), out:byte(OFF.yCoord + 1) + check(cx <= 2 * def.width - 1 and cy <= 2 * def.height - 1, + ("%s: player cell (%d,%d) is in-bounds for the %dx%d-block map") + :format(label, cx, cy, def.width, def.height)) + eq(cx, x, label .. ": wXCoord survives load -> export unchanged") + eq(cy, y, label .. ": wYCoord survives load -> export unchanged") + check(mainChecksumValid(out), label .. ": exported main-data checksum is valid") + end + + assertLoadable("bedroom interior", "REDS_HOUSE_2F", 3, 6) + assertLoadable("outdoor town", "PALLET_TOWN", 5, 6) + assertLoadable("outdoor city", "CERULEAN_CITY", 27, 15) + assertLoadable("cave floor", "MT_MOON_1F", 5, 5) + -- the map's far corner (2W-1, 2H-1): proves the boundary coord writes and + -- survives without truncation or an off-by-one out-of-bounds + if data.maps.VIRIDIAN_CITY then + local d = data.maps.VIRIDIAN_CITY + assertLoadable("far corner", "VIRIDIAN_CITY", 2 * d.width - 1, 2 * d.height - 1) + end +end + love.filesystem = realFS T.finish("save_file_io") diff --git a/tests/parity_H.lua b/tests/parity_H.lua index 2a0bda2c..2b6a5cbe 100644 --- a/tests/parity_H.lua +++ b/tests/parity_H.lua @@ -101,15 +101,14 @@ end -- ported, kept here as a regression check of the full chain). -- -- Note: the B2F -> B3F leg's *destination* visibility (steps 5-6 below) --- is a known pre-existing gap unrelated to this workstream: B3F's --- toggleable_objects.asm ordinal skips BOULDER1/BOULDER4, so --- TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3/4 (which should land on --- SEAFOAMISLANDSB3F_BOULDER5/6) resolve through --- OverworldController.lua's toggleToObjectName() to the wrong (already- --- visible) BOULDER3/4 instead. That resolver lives outside this --- workstream's port targets, so only the event flag + source-hide (both --- correct today) are asserted for that leg; the cosmetic destination --- reveal is left as-is. +-- was a pre-existing gap (issue #212): the plug rocks that stop the B3F +-- strong current land at (18,6)/(19,6) -- the hidden SEAFOAMISLANDSB3F_ +-- BOULDER5/6 -- but field.pluggedByHolesOn wired those holes' showObject to +-- TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3/4, which toggleToObjectName() resolves +-- to the already-visible BOULDER3/4 at (8,14)/(9,14), so the landing rocks +-- never appeared. Fixed by repointing the showObject toggles to BOULDER_5/_6 +-- (tools/rom_manifest*.json + the data/generated/field.lua cache), so the +-- destination reveal now works and is asserted here (checkDst = true). local pushes = { { curMap = "SEAFOAM_ISLANDS_1F", hx = 17, hy = 6, event = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE", @@ -135,12 +134,12 @@ local pushes = { event = "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", srcMap = "SEAFOAM_ISLANDS_B2F", srcName = "SEAFOAMISLANDSB2F_BOULDER1", dstMap = "SEAFOAM_ISLANDS_B3F", dstName = "SEAFOAMISLANDSB3F_BOULDER5", - checkDst = false }, + checkDst = true }, { curMap = "SEAFOAM_ISLANDS_B2F", hx = 22, hy = 6, event = "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE", srcMap = "SEAFOAM_ISLANDS_B2F", srcName = "SEAFOAMISLANDSB2F_BOULDER2", dstMap = "SEAFOAM_ISLANDS_B3F", dstName = "SEAFOAMISLANDSB3F_BOULDER6", - checkDst = false }, + checkDst = true }, { curMap = "SEAFOAM_ISLANDS_B3F", hx = 3, hy = 16, event = "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", srcMap = "SEAFOAM_ISLANDS_B3F", srcName = "SEAFOAMISLANDSB3F_BOULDER1", diff --git a/tests/parity_grass_seam.lua b/tests/parity_grass_seam.lua new file mode 100644 index 00000000..539dfe32 --- /dev/null +++ b/tests/parity_grass_seam.lua @@ -0,0 +1,94 @@ +-- Regression: the tall-grass "feet overdraw" must not fire for an off-map +-- cell during a map-connection seam step (issue #217). +-- +-- Walking south out of Viridian City, crossConnection lands the player on +-- ROUTE_1 at (10, 0) and parks them one cell BEFORE the seam for the walk +-- step -- cellY = 0 - 1 = -1, one row off the new map's top edge. ROUTE_1's +-- borderBlock (11) border-extends OVERWORLD block 11, whose bottom tile row +-- is the grass tile ($52 = 82), so Map:isGrassCell(10, -1) used to return +-- TRUE and the four overworld overdraw sites painted an animated grass tuft +-- over the player's head for the ~5 frames the seam step lasted. +-- +-- Tall grass ($52) is only meaningful within the loaded map view; the +-- border-block filler that back-fills off-map coordinates is never standable +-- grass (pokered engine/overworld/connections.asm loads the neighbour strip +-- but the player never collides against border filler as grass). The fix +-- guards Map:isGrassCell with an in-bounds check. +-- +-- Self-contained; run via `luajit tests/parity_grass_seam.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.VIRIDIAN_CITY) then Data:load() end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local OW = require("src.world.OverworldController") +local S = require("tests.harness").suite("parity grass seam") +local check, eq = S.check, S.eq + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack +StateStack:init() +Game.save = SaveData.newGame() +Game.overworld = OW + +-- Park on Viridian City's south edge, on the exit-path column (cellX = 20), +-- facing the seam. heightCells = 18 blocks * 2 = 36, so cellY = 35 is the +-- bottom edge row. +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "VIRIDIAN_CITY", 20, 35, "down") +local ow = Game.stack:top() + +local south = ow.map:connection("south") +check(south and south.map == "ROUTE_1", "Viridian south connects to ROUTE_1") + +check(ow:crossConnection("down", south) == true, "Viridian -> Route 1 crosses") +eq(ow.map.id, "ROUTE_1", "landed on ROUTE_1") + +local p = ow.player +-- the seam parks the player one cell before the entry point, off the map +eq(p.cellY, -1, "seam step parks the player at cellY = -1 (off the top edge)") +eq(ow.map:inBounds(p.cellX, p.cellY), false, "the parked cell is off-map") + +-- the border filler genuinely IS the grass tile, so the guard (not a +-- different tile) is what suppresses the phantom overdraw +eq(ow.map:cellTile(p.cellX, p.cellY), ow.map.tileset.grassTile, + "the off-map border tile decodes to the grass tile (82)") + +-- THE FIX: an off-map cell is never tall grass, so no feet-overdraw fires +eq(ow.map:isGrassCell(p.cellX, p.cellY), false, + "off-map seam cell is not grass -- no phantom overdraw over the player") + +-- and the target the overdraw sites also test must not fire off-map either +if p.targetX and not ow.map:inBounds(p.targetX, p.targetY) then + eq(ow.map:isGrassCell(p.targetX, p.targetY), false, + "off-map seam target is not grass either") +end + +-- Positive control: a real in-bounds grass cell still reports grass, so the +-- guard did not blind the encounter roll / overdraw to actual tall grass. +local gx, gy +for cy = 0, ow.map.heightCells - 1 do + for cx = 0, ow.map.widthCells - 1 do + if ow.map:cellTile(cx, cy) == ow.map.tileset.grassTile then + gx, gy = cx, cy + break + end + end + if gx then break end +end +check(gx ~= nil, "ROUTE_1 has at least one in-bounds grass cell to test") +if gx then + check(ow.map:inBounds(gx, gy), "control grass cell is in bounds") + eq(ow.map:isGrassCell(gx, gy), true, + "real in-bounds tall grass still detected after the fix") +end + +S.finish() diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index a5363132..0c9af60c 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -52,6 +52,26 @@ local clamped = Protocol.unpackMon(Data, packed) eq(clamped.level, 100, "tampered level clamped") eq(clamped.dvs.attack, 15, "tampered DV clamped") +-- OT identity survives the wire (#215): pokered's trade sends each mon's OT +-- ID (party_struct MON_OTID) and OT name (wPartyMonOT); the receiver keeps +-- them verbatim so a traded mon shows its original trainer, not the receiver. +local otMon = Pokemon.new(Data, "KADABRA", 30) +otMon.ot = "CULLEN" +otMon.otId = 16012 +local otRt = Protocol.unpackMon(Data, Protocol.packMon(otMon)) +eq(otRt.ot, "CULLEN", "OT name survives the wire") +eq(otRt.otId, 16012, "OT ID survives the wire") +-- a tampered OT ID is clamped into the 16-bit range like every other field +local otPack = Protocol.packMon(otMon) +otPack.otId = 999999 +eq(Protocol.unpackMon(Data, otPack).otId, 65535, "over-range OT ID clamped") +otPack.otId = -5 +eq(Protocol.unpackMon(Data, otPack).otId, 0, "under-range OT ID clamped") +-- an old peer that never sends OT leaves it nil (no worse than legacy; the +-- load-time stampOT backfill then fills the receiver's own, as before) +local legacy = Protocol.packMon(Pokemon.new(Data, "PIDGEY", 10)) +check(Protocol.unpackMon(Data, legacy).ot == nil, "missing OT stays nil (legacy peer)") + -- ---------------------------------------------------------------- transport local Net = require("src.link.Net") @@ -254,6 +274,10 @@ end -- ---------------------------------------------------------------- trade session local partyA = { Pokemon.new(Data, "KADABRA", 30), Pokemon.new(Data, "PIDGEY", 10) } local partyB = { Pokemon.new(Data, "MACHOKE", 32) } +-- distinct original trainers so a preserved OT is unmistakable after the swap +-- (#215): A's KADABRA belongs to CULLEN/16012, B's MACHOKE to RED/60368 +partyA[1].ot = "CULLEN"; partyA[1].otId = 16012 +partyB[1].ot = "RED"; partyB[1].otId = 60368 local tA = Protocol.TradeSession.new(Data, partyA) local tB = Protocol.TradeSession.new(Data, partyB) tA:handle({ type = "party", mons = Protocol.packParty(partyB) }) @@ -272,9 +296,14 @@ eq(tA.stage, "done", "trade completes") local gotMon, evoTo = tA:apply(nil) eq(gotMon.species, "MACHOKE", "A received Machoke") eq(evoTo, "MACHAMP", "trade evolution triggers (Machoke -> Machamp)") +-- the received mon keeps its SENDER's OT, not the receiver's (#215) +eq(gotMon.ot, "RED", "A's received Machoke keeps sender B's OT name") +eq(gotMon.otId, 60368, "A's received Machoke keeps sender B's OT ID") local gotMon2, evoTo2 = tB:apply(nil) eq(gotMon2.species, "KADABRA", "B received Kadabra") eq(evoTo2, "ALAKAZAM", "Kadabra -> Alakazam on trade") +eq(gotMon2.ot, "CULLEN", "B's received Kadabra keeps sender A's OT name") +eq(gotMon2.otId, 16012, "B's received Kadabra keeps sender A's OT ID") -- declined trades cancel local tC = Protocol.TradeSession.new(Data, partyA) @@ -398,6 +427,52 @@ eq(battleG.player.mon.stats.hp, expectedStats.hp, eq(gameG.save.party[1].level, 12, "the real save data keeps its actual level") eq(gameH.save.party[1].level, 100, "...on both sides") +-- ---------------------------------------------------------------- "ANY" level ruling (#204) +-- The link "level ruling" picker cycles a string sentinel "ANY" meaning +-- "use each mon's real level" (Gen1 link cable always used the real level). +-- LinkState/Tournament.levelForWire turns that sentinel into nil on the wire; +-- a broken `x and nil or y` idiom used to let the literal "ANY" through into +-- opts.forceLevel, and Protocol.unpackMon then called math.floor("ANY") -> +-- "bad argument #1 to 'floor' (number expected, got string)", crashing the +-- host the instant parties were unpacked in newHost (see the #204 report's +-- traceback: unpackMon <- unpackParty <- newHost <- LinkState.update). +-- unpackMon now coerces forceLevel with tonumber, so any non-numeric level +-- string means "no forced level" and the mon keeps its packed real level. +local anyPk = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12)) +local okAny, monAny = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = "ANY" }) +check(okAny, "unpackMon does not crash on the ANY sentinel string") +eq(okAny and monAny and monAny.level, 12, "ANY forceLevel keeps the mon's real level (12)") +local okStr, monStr = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = "50" }) +eq(okStr and monStr and monStr.level, 50, "a numeric-string forceLevel is still honored (50)") +local okNil, monNil = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = nil }) +eq(okNil and monNil and monNil.level, 12, "no forceLevel keeps the real level (12)") + +-- end-to-end host path from the crash report: newHost -> unpackParty -> +-- unpackMon, with the exact bad value the bug emitted (forceLevel = "ANY"). +local gameI = makeFakeGame("PIKACHU") +gameI.save.party[1] = Pokemon.new(Data, "PIKACHU", 12) +local gameJ = makeFakeGame("GEODUDE") +gameJ.save.party[1] = Pokemon.new(Data, "GEODUDE", 100) +gameJ.save.player.name = "BLUE" +local netI, netJ = Net.loopbackPair() +local packedI = Protocol.packParty(gameI.save.party) +local packedJ = Protocol.packParty(gameJ.save.party) +local seedIJ = 24680 +local okHost, battleI = pcall(LinkBattle.newHost, gameI, netI, { + myParty = packedI, theirParty = packedJ, theirName = "BLUE", seed = seedIJ, + forceLevel = "ANY", +}) +local okGuest, battleJ = pcall(LinkBattle.newGuest, gameJ, netJ, { + myParty = packedJ, theirParty = packedI, theirName = "RED", seed = seedIJ, + forceLevel = "ANY", +}) +check(okHost and battleI ~= nil, "newHost does not crash with the ANY ruling") +check(okGuest and battleJ ~= nil, "newGuest does not crash with the ANY ruling") +eq(okHost and battleI and battleI.player.mon.level, 12, + "ANY ruling: the host keeps its mon's real level (12, not forced)") +eq(okHost and battleI and battleI.enemy.mon.level, 100, + "ANY ruling: the enemy mon keeps its real level (100, not forced)") + -- ---------------------------------------------------------------- tournament shot clock -- opts.turnLimit only applies to tournament matches; the guest mashes -- through its own menu every frame while the host never presses anything, diff --git a/tests/run_tests.lua b/tests/run_tests.lua index cf641279..8f12546b 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1244,7 +1244,9 @@ do eb2.participants = { [Game.save.party[1]] = true } eb2:enemyMonFainted() local boosted = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, true) - check(hasText(eb2, ("BULBASAUR gained\na boosted\n%d EXP. Points!"):format(boosted)), + -- _BoostedText ends in the ROM CONT code \v ("a boosted\011"), so the box + -- waits + scrolls the amount into view rather than drawing it off-screen (#216) + check(hasText(eb2, ("BULBASAUR gained\na boosted\v%d EXP. Points!"):format(boosted)), "_BoostedText tail for traded mons") Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) } @@ -1253,7 +1255,8 @@ do eb3.participants = { [Game.save.party[1]] = true } eb3:enemyMonFainted() local share = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 2, false) - check(hasText(eb3, ("BULBASAUR gained\nwith EXP.ALL,\n%d EXP. Points!"):format(share)), + -- _WithExpAllText likewise ends in the CONT code \v ("with EXP.ALL,\011") (#216) + check(hasText(eb3, ("BULBASAUR gained\nwith EXP.ALL,\v%d EXP. Points!"):format(share)), "_WithExpAllText tail on the EXP.ALL pass") check(not hasText(eb3, "divided"), "no invented EXP.ALL summary line") Game.save.inventory.EXP_ALL = nil @@ -1873,6 +1876,25 @@ do eq(vw, 276, "world fill width covers the unit window at pixel scale 7") eq(vh, 156, "world fill height covers the unit window at pixel scale 7") + -- #208: on a dual-screen surface (AYN Thor, forced landscape) LOVE can + -- report drawable == unit size (pw/ww == 1) while its real coordinate->pixel + -- transform is 1.5. fitScale stays keyed off the drawable pixel size, but + -- the unit<->pixel conversion must use the REAL getDPIScale, not pw/ww, so + -- each GB pixel still lands on a WHOLE number of physical pixels (square) -- + -- not the stretched 6-vs-9-device-px fractional pixels the reporter saw. + -- Before the fix drawScale()==Sp/(pw/ww)==7 and 7*1.5==10.5 px/GB px. + Zoom.reset() + g.getDimensions = function() return 1920, 1080 end + g.getPixelDimensions = function() return 1920, 1080 end + g.getDPIScale = function() return 1.5 end + eq(Renderer:fitScale(), 7, + "#208 divergent DPI: fitScale still 7 from drawable pixels") + local physical = Renderer:drawScale() * g.getDPIScale() + local rounded = math.floor(physical + 0.5) + check(math.abs(physical - rounded) < 1e-9, + "#208 GB pixel lands on a whole number of physical pixels (square)") + eq(rounded, 7, "#208 each GB pixel covers exactly fitScale (7) physical px") + -- missing pixel API falls back to getDimensions (headless / old stub) g.getPixelDimensions = nil g.getDPIScale = nil @@ -2765,6 +2787,8 @@ do end -- Battle message lines skip a tile row (14 then 16), matching the menu. +-- The box renders the rolling 2-line window (self.shown); #216 reworked the +-- renderer so a 3rd line scrolls into view instead of drawing at y=144. do local Font = require("src.render.Font") local ys, origCode, origBox = {}, Font.drawCode, Font.drawBox @@ -2773,8 +2797,7 @@ do local battle = setmetatable({ phase = "messages", current = true, - charIndex = 999, - lines = { { 0x80 }, { 0x81 } }, + shown = { { 0x80 }, { 0x81 } }, }, BattleState) battle:drawTextArea() Font.drawCode, Font.drawBox = origCode, origBox diff --git a/tools/extract/field.py b/tools/extract/field.py index 081372a3..898fb298 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -313,15 +313,34 @@ def parse_card_key_doors(pokered): util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)") if n_doors != 22 or len(maps) != 10: util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}") - # closedDoors is hand-ported, not extracted: no retail .blk layout places - # a closed-door block at any of the coordinates above (the feature is - # unused/cut in the original game), so there is no ROM or disassembly - # source to derive this from. It restores that cut content by stamping - # facility.bst blocks 0x54/0x5f (2F-10F) or interior.bst block 0x20 - # (11F) over each door on map load, opened by that door's - # EVENT_SILPH_CO_n_UNLOCKED_DOORn flag. Kept in sync by hand with - # tools/rom_manifest.json's field.cardKeyDoors.closedDoors. + # closedDoors is hand-ported, not extracted: the .blk layouts ship with + # these doorways open and each floor's map script stamps the closed block + # on load, which the extractor cannot see from static map bytes. Kept in + # sync by hand with tools/rom_manifest.json's field.cardKeyDoors.closedDoors. + # + # ROCKET_HIDEOUT_* are the elevator lift gates, live in retail: the + # RocketHideoutB1F/B4F DoorCallbackScripts (scripts/RocketHideoutB1F.asm, + # RocketHideoutB4F.asm) ReplaceTileBlock a barred facility block over the + # lift doorway with floor block 0x0e until the guards are beaten. B1F + # stamps 0x54 at (12,8), opened by EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 + # (EVENT_ENTERED_ROCKET_HIDEOUT is never SetEvent -- the noted SFX bug -- + # so it is not part of the open condition). B4F stamps 0x2d at (12,5), + # opened once BOTH EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 and _1 are set + # (CheckBothEventsSet). B2F/B3F call no door callback (spinner floors). + # + # SILPH_CO_* restore cut content instead: no retail .blk places a closed + # block at those coords and no retail script stamps them (the feature is + # unused in the original game), stamping facility.bst blocks 0x54/0x5f + # (2F-10F) or interior.bst block 0x20 (11F) over each door, opened by that + # door's EVENT_SILPH_CO_n_UNLOCKED_DOORn flag. closed_doors = { + "ROCKET_HIDEOUT_B1F": [ + {"block": 0x54, "bx": 12, "by": 8, "event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", "open": 0x0e}, + ], + "ROCKET_HIDEOUT_B4F": [ + {"block": 0x2d, "bx": 12, "by": 5, + "events": ["EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0", "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"], "open": 0x0e}, + ], "SILPH_CO_2F": [ {"block": 0x54, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 0x0e}, {"block": 0x54, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 0x0e}, diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index 69e17b00..91d49dcf 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -4254,6 +4254,27 @@ }, "cardKeyDoors": { "closedDoors": { + "ROCKET_HIDEOUT_B1F": [ + { + "block": 84, + "bx": 12, + "by": 8, + "event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", + "open": 14 + } + ], + "ROCKET_HIDEOUT_B4F": [ + { + "block": 45, + "bx": 12, + "by": 5, + "events": [ + "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0", + "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" + ], + "open": 14 + } + ], "SILPH_CO_2F": [ { "block": 84, @@ -6777,7 +6798,7 @@ "x": 18, "y": 6 }, - "showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3", + "showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_5", "x": 19, "y": 6 }, @@ -6788,7 +6809,7 @@ "x": 19, "y": 6 }, - "showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_4", + "showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_6", "x": 22, "y": 6 } diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index 1453551c..761b302b 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -4254,6 +4254,27 @@ }, "cardKeyDoors": { "closedDoors": { + "ROCKET_HIDEOUT_B1F": [ + { + "block": 84, + "bx": 12, + "by": 8, + "event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", + "open": 14 + } + ], + "ROCKET_HIDEOUT_B4F": [ + { + "block": 45, + "bx": 12, + "by": 5, + "events": [ + "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0", + "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" + ], + "open": 14 + } + ], "SILPH_CO_10F": [ { "block": 84, @@ -6754,7 +6775,7 @@ "x": 18, "y": 6 }, - "showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3", + "showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_5", "x": 19, "y": 6 }, @@ -6765,7 +6786,7 @@ "x": 19, "y": 6 }, - "showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_4", + "showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_6", "x": 22, "y": 6 }