diff --git a/assets/logo/gen1recomp_cover.png b/assets/logo/gen1recomp_cover.png index daf0543c..4e077216 100644 Binary files a/assets/logo/gen1recomp_cover.png and b/assets/logo/gen1recomp_cover.png differ diff --git a/data/scripts/flavor/pewter_city.lua b/data/scripts/flavor/pewter_city.lua index e01f0e87..b5047092 100644 --- a/data/scripts/flavor/pewter_city.lua +++ b/data/scripts/flavor/pewter_city.lua @@ -1,15 +1,11 @@ -- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm). -- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum --- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER. +-- guide and SUPER_NERD2 garden nerd. -- --- The escort choreography (SUPER_NERD1 walking the player to the --- museum, YOUNGSTER walking the player to the gym) is scripted NPC --- movement + a wPewterCityCurScript state machine that steers the --- player off-map; the YOUNGSTER's escort to the gym is handled on this --- map by story5.lua's onStep gate at the east exit (before --- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported. --- Here we only port the real YES/NO-branched flavor text these NPCs --- speak when talked to. +-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in +-- story5.lua so the lockstep RLE walk is not overwritten by this +-- flavor merge. SUPER_NERD1's museum escort is not ported; only the +-- YES/NO-branched flavor text is here. local M = {} @@ -61,15 +57,6 @@ M.PEWTER_CITY = { end) end, - -- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow - -- me" line the youngster says when the player is stopped from - -- leaving Pewter east before beating Brock; the actual gate/step - -- block is handled by story5.lua's onStep for this map. - TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done) - local t = text(game) - push(game, t._PewterCityYoungsterYoureATrainerFollowMeText - or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done) - end, }, } diff --git a/data/scripts/flavor/silph_co_9f.lua b/data/scripts/flavor/silph_co_9f.lua index e04af559..b0606af7 100644 --- a/data/scripts/flavor/silph_co_9f.lua +++ b/data/scripts/flavor/silph_co_9f.lua @@ -5,19 +5,23 @@ return { SILPH_CO_9F = { talk = { -- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm): - -- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows - -- "You look tired..." then "Don't give up!"; after the event, just - -- says thanks. Nurse texts are not in data/generated/text.lua, so - -- the exact pokered/text/SilphCo9F.asm strings are used as literals. + -- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade + -- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give + -- up!"; after the event, just says thanks. Nurse texts are not in + -- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm + -- strings are used as literals. TEXT_SILPHCO9F_NURSE = { - { "face_player" }, - { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, - { "jump_if_true", 8 }, - { "show_text", "You look tired!\nYou should take a\nquick nap!" }, - { "heal_party" }, - { "show_text", "Don't give up!" }, - { "jump", 9 }, - { "show_text", "Thank you so\nmuch!" }, + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2 + { "jump_if_true", 11 }, -- 3 + { "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4 + { "heal_party" }, -- 5 + { "fade", "out", "white" }, -- 6 + { "wait", 3 }, -- 7 Delay3 + { "fade", "in", "white" }, -- 8 + { "show_text", "Don't give up!" }, -- 9 + { "jump", "end" }, -- 10 + { "show_text", "Thank you so\nmuch!" }, -- 11 }, }, }, diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index e7fc8dcb..20082832 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -24,8 +24,11 @@ local function starterBall(askText, species, choseFlag, ownBall, { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 { "jump_if_false", 20 }, -- 4 -- the Pokédex "new species" entry shows before the ask (predef - -- StarterDex ahead of OaksLabYouWant...Text) - { "push_screen", "DexEntryMenu", species }, -- 5 + -- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily + -- sets the owned bits so ShowPokedexData prints height/weight/text; + -- forceOwned is that bypass without mutating save.pokedex.owned. + { "push_screen", "DexEntryMenu", + { species = species, forceOwned = true } }, -- 5 { "ask", askText }, -- 6 { "jump_if_false", 21 }, -- 7 { "give_pokemon", species, 5 }, -- 8 @@ -61,40 +64,44 @@ return { TEXT_OAKSLAB_OAK1 = { { "face_player" }, -- 1 { "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2 - { "jump_if_false", 14 }, -- 3 + { "jump_if_false", 16 }, -- 3 { "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4 - { "jump_if_true", 14 }, -- 5 + { "jump_if_true", 16 }, -- 5 { "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6 { "take_item", "OAKS_PARCEL", 1 }, -- 7 { "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8 { "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9 { "set_flag", "EVENT_GOT_POKEDEX" }, -- 10 + -- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 + -- so the table sprites leave with the gift (#106). + { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11 + { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12 -- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606: -- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN). -- Until this ran, the walking man at (17,5) -- who owns the coffee -- ask and the catch tutorial -- stayed OFF for the whole game -- (toggleable_objects.asm seeds him OFF, the sleeper ON). - { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11 - { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12 - { "jump", 32 }, -- 13 - { "check_flag", "EVENT_GOT_STARTER" }, -- 14 - { "jump_if_false", 29 }, -- 15 - { "check_item", "POKE_BALL" }, -- 16 - { "jump_if_true", 27 }, -- 17 - { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18 - { "jump_if_false", 31 }, -- 19 - { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20 - { "jump_if_true", 27 }, -- 21 - { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22 - { "give_item", "POKE_BALL", 5, false }, -- 23 - { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24 - { "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25 - { "jump", 32 }, -- 26 - { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27 - { "jump", 32 }, -- 28 - { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29 - { "jump", 32 }, -- 30 - { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end) + { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13 + { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14 + { "jump", "end" }, -- 15 + { "check_flag", "EVENT_GOT_STARTER" }, -- 16 + { "jump_if_false", 31 }, -- 17 + { "check_item", "POKE_BALL" }, -- 18 + { "jump_if_true", 29 }, -- 19 + { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20 + { "jump_if_false", 33 }, -- 21 + { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22 + { "jump_if_true", 29 }, -- 23 + { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24 + { "give_item", "POKE_BALL", 5, false }, -- 25 + { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26 + { "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27 + { "jump", "end" }, -- 28 + { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29 + { "jump", "end" }, -- 30 + { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31 + { "jump", "end" }, -- 32 + { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33 }, TEXT_OAKSLAB_CHARMANDER_POKE_BALL = @@ -140,6 +147,19 @@ return { }, }, + -- Saves that got the Pokédex before #106 never wrote objectToggles for + -- the table sprites; re-entering the lab applies the same HideObject + -- the gift script now does (OaksLab.asm OakGivesPokedex). + onEnter = function(game, ow) + if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then + return + end + local Commands = require("src.script.Commands") + local ctx = { save = game.save, game = game, overworld = ow } + Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1") + Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2") + end, + -- Oak stops you leaving without a starter; the rival stops you on -- the way out for the first battle (scripts/OaksLab.asm -- OaksLabScript8 / OaksLabRivalChallenge) diff --git a/data/scripts/reds_house.lua b/data/scripts/reds_house.lua index f7b3dc04..cdd45994 100644 --- a/data/scripts/reds_house.lua +++ b/data/scripts/reds_house.lua @@ -1,15 +1,23 @@ -- Hand-ported from pret/pokered scripts/RedsHouse1F.asm. --- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the --- "you should rest" / "looking great" dialogue. The intro "wake up" --- branch is tied to the unported intro cutscene, so the heal path is used. +-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip; +-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white, +-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great". return { talk = { TEXT_REDSHOUSE1F_MOM = { - { "face_player" }, - { "show_text", "_RedsHouse1FMomYouShouldRestText" }, - { "heal_party" }, - { "show_text", "_RedsHouse1FMomLookingGreatText" }, + { "face_player" }, -- 1 + { "check_flag", "EVENT_GOT_STARTER" }, -- 2 + { "jump_if_true", 6 }, -- 3 + { "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4 + { "jump", "end" }, -- 5 + -- RedsHouse1FMomHealScript + { "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6 + { "fade", "out", "white" }, -- 7 GBFadeOutToWhite + { "heal_party" }, -- 8 + { "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map + { "fade", "in", "white" }, -- 10 GBFadeInFromWhite + { "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11 }, }, } diff --git a/data/scripts/story.lua b/data/scripts/story.lua index f0926930..2b859fde 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -809,10 +809,14 @@ M.HALL_OF_FAME = { -- that teleports back to the new-game bedroom spawn (special_warps.asm -- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated -- convenience -- there is no such prompt in the original ROM. + -- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not + -- Indigo Plateau (issue #103 escape path for already-stuck saves). TEXT_HALLOFFAME_PC = { { "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck { "jump_if_false", "end" }, -- 2 NO: back away - { "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room + { "heal_party" }, -- 3 + { "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door + { "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room }, }, } @@ -839,21 +843,51 @@ M.CERULEAN_CITY = { }, } +-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript +-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_ +-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player +-- at (15,5) is the ON_LEFT case. +local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN = + { "right", "down", "down", "right", "down", "down", "right", "right" } +local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT = + { "down", "down", "right", "right", "right", "right", "down", "down" } + +local function pokemonTower2FRivalScript(playerX) + local exitDirs = (playerX == 15) + and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT + or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN + return { + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2 + { "jump_if_true", 12 }, -- 3 + { "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4 + { "rival_battle", "OPP_RIVAL2", 4 }, -- 5 + { "jump_if_false", "end" }, -- 6 loss: stay + { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7 + { "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8 + { "walk_npc", 1, exitDirs }, -- 9 + { "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10 + { "jump", "end" }, -- 11 + { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12 + } +end + M.POKEMON_TOWER_2F = { + rivalScript = pokemonTower2FRivalScript, talk = { - TEXT_POKEMONTOWER2F_RIVAL = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2 - { "jump_if_true", 10 }, -- 3 - { "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4 - { "rival_battle", "OPP_RIVAL2", 4 }, -- 5 - { "jump_if_false", 11 }, -- 6 - { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7 - { "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8 - { "jump", 11 }, -- 9 - { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10 - }, + TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done) + ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX), + { npc = npc, onDone = done }) + end, }, + -- Saves that beat him before the exit walk was ported still have the + -- flag but a visible rival; hide on enter like BillsHouse repairs. + onEnter = function(game, ow) + if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end + local Commands = require("src.script.Commands") + Commands.hide_object({ game = game, save = game.save, overworld = ow }, + "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL") + end, -- PokemonTower2FDefaultScript: walking past the rival's tile forces -- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never -- waits to be talked to @@ -865,8 +899,7 @@ M.POKEMON_TOWER_2F = { if not rival then return false end ow.player.facing = (x == 15) and "left" or "up" require("src.core.Music").play(game.data, "Music_MeetRival") - ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL, - { npc = rival }) + ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival }) return true end, } diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 06e5f892..bc77679d 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -201,6 +201,49 @@ local function elevatorWalkOut(ow, floor) end) end +local function elevatorFloors(elevatorMapId, game) + local floors = {} + for mapId, def in pairs(game.data.maps) do + for i, w in ipairs(def.warps) do + if w.destMap == elevatorMapId then + -- short floor token pokered actually prints, e.g. + -- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F" + local token = mapId:match("_([^_]+)$") or mapId + -- warpIdx: this floor's warp back into the elevator IS the + -- warp the car's rewritten exit lands on (the reciprocal + -- pair), matching wElevatorWarpMaps' (warp id, map id) + table.insert(floors, + { map = mapId, x = w.x, y = w.y, token = token, warpIdx = i }) + break + end + end + end + -- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F), + -- not lexicographic -- otherwise 10F/11F sort before 2F..9F + table.sort(floors, function(a, b) + return (tonumber(a.token:match("%d+")) or 0) < + (tonumber(b.token:match("%d+")) or 0) + end) + return floors +end + +local function elevatorSeedExit(ow, floors, fromMapId) + -- Seed a walk-out destination before the menu (or key-gate text): + -- entry floor when known, else the first listed floor (1F). Choosing + -- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave + -- keeps this seed so walking out of the car cannot hit a missing ROM + -- placeholder (#123) or the car's static default floor (#90: Rocket + -- Hideout defaults to B1F even when entered from B2F/B4F). + local exitFloor = floors[1] + if fromMapId then + for _, f in ipairs(floors) do + if f.map == fromMapId then exitFloor = f break end + end + end + elevatorSetExit(ow, exitFloor) + return exitFloor +end + local function elevator(elevatorMapId, keyGate, preFrames) return { -- fromMapId: the floor the player just left (setMap passes it), so a @@ -208,45 +251,18 @@ local function elevator(elevatorMapId, keyGate, preFrames) -- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve -- asserted and hard-crashed (#123). onEnter = function(game, ow, fromMapId) + local floors = elevatorFloors(elevatorMapId, game) + elevatorSeedExit(ow, floors, fromMapId) + -- Rocket Hideout: without LIFT_KEY the panel only prints the need- + -- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are + -- still seeded above so walking out returns to the entry floor + -- instead of the car's ROM default (B1F) — #90 / #105. if keyGate and not game.save.inventory[keyGate.item] then local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, game.data.text[keyGate.text] or "It appears to\nneed a key.")) return end - local floors = {} - for mapId, def in pairs(game.data.maps) do - for i, w in ipairs(def.warps) do - if w.destMap == elevatorMapId then - -- short floor token pokered actually prints, e.g. - -- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F" - local token = mapId:match("_([^_]+)$") or mapId - -- warpIdx: this floor's warp back into the elevator IS the - -- warp the car's rewritten exit lands on (the reciprocal - -- pair), matching wElevatorWarpMaps' (warp id, map id) - table.insert(floors, - { map = mapId, x = w.x, y = w.y, token = token, warpIdx = i }) - break - end - end - end - -- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F), - -- not lexicographic -- otherwise 10F/11F sort before 2F..9F - table.sort(floors, function(a, b) - return (tonumber(a.token:match("%d+")) or 0) < - (tonumber(b.token:match("%d+")) or 0) - end) - -- Seed a walk-out destination before the menu: entry floor when - -- known, else the first listed floor (1F). Choosing a floor still - -- rewrites via elevatorWalkOut; B-cancel keeps this seed so leaving - -- the car cannot hit a missing ROM placeholder map. - local exitFloor = floors[1] - if fromMapId then - for _, f in ipairs(floors) do - if f.map == fromMapId then exitFloor = f break end - end - end - elevatorSetExit(ow, exitFloor) local items = {} for _, f in ipairs(floors) do table.insert(items, { label = f.token, value = f }) @@ -292,6 +308,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9) M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR", { item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" }) +-- ------------------------------------------------------------------- +-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm): +-- Rocket3's after-battle text_asm drops the LIFT KEY item ball +-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject +-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map +-- objects; without this talk side-effect the key never appears (#90, +-- #105). +-- Giovanni's post-battle script likewise ShowObject's the Silph Scope +-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4). +-- ------------------------------------------------------------------- + +M.ROCKET_HIDEOUT_B4F = { + talk = { + TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done) + if not ow:trainerDefeated(npc) then + ow:engageTrainer(npc, done) + return + end + local TextBox = require("src.render.TextBox") + local t = game.data.text + game.stack:push(TextBox.new(game, + t._RocketHideoutB4FRocket3AfterBattleText + or "Oh no! I dropped\nthe LIFT KEY!", + function() + -- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk + -- after the win reveals the ball; later talks only reprint. + if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then + game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true + local Commands = require("src.script.Commands") + Commands.show_object( + { game = game, save = game.save, overworld = ow }, + "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY") + end + done() + end)) + end, + + TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done) + -- Giovanni has no trainer-header row (def_trainers 2); his text_asm + -- owns both the engage and the BeatGiovanniScript aftermath. + if ow:trainerDefeated(npc) + or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText + or "I hope we meet\nagain...", done)) + return + end + local TextBox = require("src.render.TextBox") + local BattleState = require("src.battle.BattleState") + local t = game.data.text + local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText + or "So! I must say, I\nam impressed you\ngot here!" + local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText + or "WHAT!\nThis cannot be!" + local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText + or "I hope we meet\nagain..." + game.stack:push(TextBox.new(game, impressed, function() + local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1) + battle.onFinish = function(result) + if result ~= "win" then + ow:afterBattle(result, battle) + done() + return + end + game.save.defeatedTrainers[npc.id] = true + game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true + -- End-battle "WHAT!" then BeatGiovanniScript's hope text, + -- fade, HideObject Giovanni, ShowObject Silph Scope. + game.stack:push(TextBox.new(game, cannotBe, function() + game.stack:push(TextBox.new(game, hope, function() + local Transition = require("src.render.Transition") + game.stack:push(Transition.new(game, function() + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F", + "ROCKETHIDEOUTB4F_GIOVANNI") + Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F", + "ROCKETHIDEOUTB4F_SILPH_SCOPE") + end, function() + ow:afterBattle(result, battle) + done() + end)) + end)) + end)) + end + ow:pushBattle(battle) + end)) + end, + }, +} + -- ------------------------------------------------------------------- -- Game Corner coins, prizes, and the rocket-poster switch that reveals -- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm + diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index bd2c55f3..e460a953 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -262,38 +262,180 @@ M.CINNABAR_ISLAND = { -- Pewter's youngster stops you leaving east before Brock is beaten and -- escorts you to the gym (scripts/PewterCity.asm -- PewterCityCheckPlayerLeavingEastScript / --- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm --- PewterGymGuyCoords): the "follow me" lines, then the player is walked --- west along the road to the front of the PEWTER_GYM door at (16,17). +-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm +-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm +-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the +-- youngster walks RLEList_PewterGymGuy while the player plays the +-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning +-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through +-- the building. +local pewterEscort = {} + +-- RLEList_PewterGymGuy (NPC directions play forward) +pewterEscort.guySteps = { + "down", "down", + "left", "left", "left", "left", "left", "left", "left", "left", + "left", "left", "left", "left", "left", "left", "left", + "up", "up", "up", "up", "up", + "left", "left", "left", "left", "left", "left", "left", "left", + "left", "left", "left", + "down", "down", "down", "down", "down", + "right", "right", "right", +} + +-- Walk home: reverse of guySteps with opposite facings (gym → spawn). +do + local opp = { up = "down", down = "up", left = "right", right = "left" } + local ret = {} + for i = #pewterEscort.guySteps, 1, -1 do + ret[#ret + 1] = opp[pewterEscort.guySteps[i]] + end + pewterEscort.guyReturnSteps = ret +end + +-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2, +-- DOWN×5, LEFT×11, UP×5, LEFT×15) +pewterEscort.playerRle = { + "NO", + "right", "right", + "down", "down", "down", "down", "down", + "left", "left", "left", "left", "left", "left", "left", "left", + "left", "left", "left", + "up", "up", "up", "up", "up", + "left", "left", "left", "left", "left", "left", "left", "left", + "left", "left", "left", "left", "left", "left", "left", +} + +-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE +-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for +-- the NPC, so eight of them ≈ one guy head-start step) +pewterEscort.preambles = { + ["34,16"] = { "left", "down", "down", "right" }, + ["35,17"] = { "left", "down", "right", "left" }, + ["37,18"] = { "left", "left", "left", + "NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" }, + ["37,19"] = { "left", "left", "up", "left" }, + ["36,17"] = { "left", "down", "left", + "NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" }, +} + +-- Realized player path for a trigger tile: PewterGuys overwrites the +-- last RLE byte and appends the preamble, then simulated joypad plays +-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the +-- trailing end-of-list NO is dropped (one-frame pause). +function pewterEscort.playerPlan(x, y) + local pre = pewterEscort.preambles[x .. "," .. y] + if not pre then return nil end + local buf = {} + for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end + buf[#buf] = pre[1] + for i = 2, #pre do buf[#buf + 1] = pre[i] end + local path = {} + for i = #buf, 1, -1 do path[#path + 1] = buf[i] end + local head = 0 + while path[head + 1] == "NO" do head = head + 1 end + local tail = #path + while tail > head and path[tail] == "NO" do tail = tail - 1 end + local steps = {} + for i = head + 1, tail do steps[#steps + 1] = path[i] end + return { steps = steps, guyHeadStart = math.floor(head / 8) } +end + +local function pewterGymEscort(game, ow) + if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end + local x, y = ow.player.cellX, ow.player.cellY + local plan = pewterEscort.playerPlan(x, y) + local Music = require("src.core.Music") + local t = text(game) + local follow = t._PewterCityYoungsterYoureATrainerFollowMeText + or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!" + -- PewterGuys only has entries for five tiles; an unmatched talk tile + -- (e.g. (36,16) east of him) just gets the follow-me line, same as a + -- failed coords lookup would refuse to arm the walk. + if not plan then + push(game, follow) + return + end + local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER + local guySteps = pewterEscort.guySteps + local head = plan.guyHeadStart + + -- After the walk: face the player, restore map music, "Go take on + -- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16). + -- (pokered teleports him via MovementData_PewterGymGuyExit; we walk + -- the same route home instead. Brock victory still HideObject's him.) + local function walkHome() + if not guy then return end + local ret = pewterEscort.guyReturnSteps + local i = 0 + local function tick() + i = i + 1 + if not ret[i] then + guy.facing = "down" + return + end + ow:scriptMove(guy, ret[i], 1, tick) + end + tick() + end + + local function afterWalk() + if guy then guy.facing = "left" end + Music.playMap(game.data, "PEWTER_CITY") + push(game, t._PewterCityYoungsterGoTakeOnBrockText + or "Go take on BROCK\nat the GYM first!", walkHome) + end + + local function lockstep() + local i = 0 + local function tick() + i = i + 1 + local ps = plan.steps[i] + if not ps then + afterWalk() + return + end + local gs = guySteps[head + i] + if guy and gs then ow:scriptMove(guy, gs, 1) end + ow:scriptMove(ow.player, ps, 1, tick) + end + tick() + end + + local function beginWalk() + Music.play(game.data, "Music_MuseumGuy") + if guy and head > 0 then + local h = 0 + local function headTick() + h = h + 1 + if h > head then lockstep(); return end + ow:scriptMove(guy, guySteps[h], 1, headTick) + end + headTick() + else + lockstep() + end + end + + push(game, follow, beginWalk) +end + M.PEWTER_CITY = { + escort = pewterEscort, + -- PewterCityYoungsterText: talking also arms the gym escort script + talk = { + TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done) + pewterGymEscort(game, ow) + if done then done() end + end, + }, onStep = function(game, ow, x, y) if game.save.flags.EVENT_BEAT_BROCK then return false end if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then return false end - local t = text(game) - -- walk up onto the road row (y=17), west to one tile east of the gym - -- door, then drop below the door and turn to face it - local steps = {} - for _ = 1, y - 17 do steps[#steps + 1] = "up" end - for _ = 1, x - 17 do steps[#steps + 1] = "left" end - steps[#steps + 1] = "down" - steps[#steps + 1] = "left" - local function walk(i) - if not steps[i] then - ow.player.facing = "up" - return - end - ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end) - end - push(game, t._PewterCityYoungsterYoureATrainerFollowMeText - or "Hey! You're a\ntrainer, right?", function() - push(game, t._PewterCityYoungsterGoTakeOnBrockText - or "Go take on BROCK\nat the GYM first!", function() - walk(1) - end) - end) + pewterGymEscort(game, ow) return true end, } diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index 66dc1b39..45c620c5 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -2,8 +2,9 @@ -- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported -- from the leaders'/bosses' text_asm victory scripts: -- gym badges: scripts/PewterGym.asm ... ViridianGym.asm --- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him --- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed. +-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by +-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the +-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua). -- The TM each gym leader hands out afterwards is also ported. -- -- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script @@ -21,9 +22,16 @@ local function range(prefix, first, last) end return { + -- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY + -- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit + -- escort NPC and the first Route 22 rival stay gone after the badge. ["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK", item = "TM_BIDE", - deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } }, + deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, + hide = { + { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, + { "ROUTE_22", "ROUTE22_RIVAL1" }, + } }, ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", item = "TM_BUBBLEBEAM", deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) }, diff --git a/src/core/Game.lua b/src/core/Game.lua index 7c519e05..a7bbb14b 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -460,6 +460,13 @@ function Game:restoreSave(loaded, recovered) -- SaveData.load and skip on the format guard local activeMods = self.modStatus and self.modStatus.loaded SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods) + -- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with + -- lastOutdoor on Indigo. One-shot relocate + heal before validate. + if SaveData.needsPostGameRescue(loaded) then + SaveData.applyPostGameHome(loaded, self:bootConfig()) + local Pokemon = require("src.pokemon.Pokemon") + for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end + end local modsDiff = SaveData.modsDiff(loaded, activeMods) local report = SaveData.validate(loaded, self.data) report.recovered = recovered diff --git a/src/core/Music.lua b/src/core/Music.lua index be88fbd9..324f09fc 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -360,16 +360,13 @@ local function chipAwaitingFirstBuffer() and require("src.core.ChipAudio").awaitingFirstBuffer() end --- is a playOnce jingle still sounding? (AnimateHealingMachine's --- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends) +-- is a playOnce jingle still in flight? (AnimateHealingMachine's +-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.) +-- pendingRestore stays set from playOnce until Music.update restores the +-- map theme, covering the threaded chip "empty QueueableSource" window +-- where Source:isPlaying is briefly false before the first buffer lands. function Music.oneShotPlaying() - if not state.pendingRestore then return false end - -- threaded chip songs start silent for ~1 frame; that gap is not "over" - if chipAwaitingFirstBuffer() then return true end - local src = state.source - if not src then return false end - local ok, playing = pcall(src.isPlaying, src) - return ok and playing or false + return state.pendingRestore == true end function Music.restoreMap(data) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index b1ceb91a..47b0dd99 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -813,6 +813,36 @@ function SaveData.defaultHeal(boot) return { map = map, x = boot.startX or 3, y = boot.startY or 6 } end +-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME +-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and +-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so +-- leaving the house does not dump the player back at Indigo Plateau. +-- Marks postGameHomeOk so a later intentional HoF save is not relocated. +function SaveData.applyPostGameHome(save, boot) + boot = type(boot) == "table" and boot or {} + local heal = SaveData.defaultHeal(boot) + save.lastHeal = { map = heal.map, x = heal.x, y = heal.y } + save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y } + save.player = save.player or {} + save.player.map = boot.startMap or "REDS_HOUSE_2F" + save.player.x = boot.startX or 3 + save.player.y = boot.startY or 6 + save.player.facing = boot.startFacing or "down" + save.postGameHomeOk = true + return heal +end + +-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits, +-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE. +function SaveData.needsPostGameRescue(save) + if not (save and save.player and save.player.map == "HALL_OF_FAME") then + return false + end + if save.postGameHomeOk then return false end + local hof = save.hallOfFame + return type(hof) == "table" and #hof > 0 +end + function SaveData.newGame(boot) boot = type(boot) == "table" and boot or {} local map = boot.startMap or "REDS_HOUSE_2F" @@ -836,6 +866,10 @@ function SaveData.newGame(boot) }, flags = {}, inventory = {}, + -- Vanilla Gen1 seeds one Potion in the player's item PC + -- (wBoxItems / players_pc.asm); existing saves keep whatever they + -- already have — this only applies to New Game. + pcItems = { POTION = 1 }, party = {}, box = {}, money = boot.startMoney or 3000, diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 58403744..496f5429 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -351,6 +351,18 @@ function RomImporter:setError(message, version) self.romData = nil end +-- draw() may leave the system hand cursor set while hovering a Play / +-- Choose control. Once the importer is torn down that draw path stops +-- running, so restore the arrow before handing off to boot (issue #114). +local function resetPointerCursor(self) + if self.android then return end + if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then + return + end + self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow") + love.mouse.setCursor(self.arrowCursor) +end + -- Verify + extract a ROM. The version is decided by the ROM's own SHA-1, so -- dropping a Red or Blue cart into either column always lands in the right one. function RomImporter:startData(data, displayName) @@ -428,6 +440,7 @@ function RomImporter:startData(data, displayName) -- Stay on the launcher; the player presses Play to boot the new game. return end + resetPointerCursor(self) if self.onComplete then self.onComplete(version) end end) end @@ -507,6 +520,7 @@ end function RomImporter:play(version) if self.workState == "working" then return end if not self.ready[version] then return end + resetPointerCursor(self) if self.onComplete then self.onComplete(version) end end @@ -1045,8 +1059,7 @@ function RomImporter:draw() self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand") love.mouse.setCursor(self.handCursor) else - self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow") - love.mouse.setCursor(self.arrowCursor) + resetPointerCursor(self) end end end diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 5fcbef53..fb24ab50 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -872,11 +872,15 @@ R.palettes = { } -- keyed by species id, unlike the vanilla byDex array: a species past the --- end of the dex gets an icon without punching a hole in the list +-- end of the dex gets an icon without punching a hole in the list. The party +-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla +-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD, +-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a +-- { image = , frames? } table of your own art. R.icons = { semantics = "record", target = "icons.bySpecies", value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } }, - example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })', + example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }', } -- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index ea174bfc..f1100fde 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -242,6 +242,20 @@ function PaletteFX.pack(data) return data and data.palettes or nil end +-- SuperPalettes that differ between Red and Blue (pokered data/sgb/ +-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the +-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must +-- read these from the ROM-imported table or the title ribbon stays red +-- and the Game Corner reels keep Red's pink (issue #128). +local BLUE_VERSIONED = { + LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true, +} + +local function romNamedPal(data, name) + local p = data and data.palettes + return p and p.palettes and p.palettes[name] +end + -- named palette from the active pack (nil on stale builds / missing name). -- RED++ falls back to the ROM pack for names the gbc table omits (rare). -- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette @@ -251,11 +265,15 @@ end -- GBC_OBJ green), so this stays a BG-only hook. function PaletteFX.pal(data, name) if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end + if GameVersion.isBlue() and BLUE_VERSIONED[name] then + local fromRom = romNamedPal(data, name) + if fromRom then return fromRom end + end local p = PaletteFX.pack(data) local c = p and p.palettes[name] if c then return c end - if PaletteFX.usesGbcPack() and data and data.palettes then - return data.palettes.palettes[name] + if PaletteFX.usesGbcPack() then + return romNamedPal(data, name) end return nil end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index b1aa2407..96ed0ebf 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -438,10 +438,18 @@ function Commands.play_sound(ctx, soundId) require("src.core.Sound").play(ctx.game.data, soundId) end --- play_once : one-shot jingle (Music_PkmnHealed, etc.); the map --- theme resumes when it ends (Music.playOnce / pendingRestore) +-- play_once : one-shot jingle (Music_PkmnHealed, etc.); blocks +-- until it finishes so heal-rest scripts (Mom, captain text_asm) match +-- Gen1's wait-on-channel loop. The map theme resumes when it ends +-- (Music.playOnce / pendingRestore). function Commands.play_once(ctx, songId) - require("src.core.Music").playOnce(ctx.game.data, songId) + local Music = require("src.core.Music") + if not Music.playOnce(ctx.game.data, songId) then return end + local runner = ctx.runner + runner.waitingCheck = function() + return not Music.oneShotPlaying() + end + runner:yield() end -- play_cry : PlayCry (home/audio.asm). The text_asm bodies that @@ -515,12 +523,29 @@ function Commands.give_money(ctx, amount) ctx.save.money = math.max(0, ctx.save.money + amount) end +-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the +-- live overworld memory in sync so a scripted home warp from the HoF PC +-- does not leave Red's house mats aimed at Indigo Plateau (#103). +function Commands.remember_outdoor(ctx, mapId, x, y) + local outdoor = { id = mapId, x = x, y = y } + ctx.save.lastOutdoor = outdoor + if ctx.overworld and ctx.overworld.rememberOutdoor then + ctx.overworld:rememberOutdoor(mapId, x, y) + elseif ctx.overworld then + ctx.overworld.lastOutdoor = outdoor + end +end + -- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside -- AnimateHallOfFame), run the induction showcase and the end credits, -- autosave while THE END is up, then soft-reset to the title -- the whole -- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript -- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm, -- scripts/HallOfFame.asm). +-- +-- Departure from pokered: CONTINUE lands in the NewGameWarp bedroom with +-- a healed party and lastOutdoor on Pallet Town, instead of remaining +-- softlocked in HALL_OF_FAME with LAST_MAP still aimed at Indigo (#103). function Commands.record_hall_of_fame(ctx) ctx.save.hallOfFame = ctx.save.hallOfFame or {} local entry = {} @@ -536,17 +561,25 @@ function Commands.record_hall_of_fame(ctx) Screens.push(game, "Credits", function() runner:resume() end, function() - -- THE END is on screen: HallOfFameResetEventsAndSaveScript sets - -- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the - -- save keeps the player standing in the HALL_OF_FAME room. (The - -- E4 room-script/event resets that precede the save in pokered are - -- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.) - -- pokered writes PALLET_TOWN here as a literal, not as "the spawn" -- - -- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries - -- that split (and lets a total conversion redirect it). + -- THE END is on screen: heal, place the player at post-game home, + -- retarget LAST_MAP, then SaveGameData. (E4 room-script/event + -- resets that precede the save in pokered are the Indigo lobby's + -- re-entry reset here, data/scripts/story6.lua.) + local SaveData = require("src.core.SaveData") + local Pokemon = require("src.pokemon.Pokemon") local boot = game.data.field and game.data.field.boot or {} - ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot) + for _, mon in ipairs(ctx.save.party or {}) do + Pokemon.heal(mon) + end + SaveData.applyPostGameHome(ctx.save, boot) + if game.overworld then + game.overworld.lastOutdoor = ctx.save.lastOutdoor + end if game.writeSave then game:writeSave() end + -- writeSave's captureSave re-stamps the live HALL_OF_FAME coords; + -- re-apply home and persist so CONTINUE resumes in the bedroom. + SaveData.applyPostGameHome(ctx.save, boot) + SaveData.save(ctx.save) end) end) runner:yield() @@ -861,15 +894,21 @@ function Commands.push_screen(ctx, screenId, args) runner:yield() end --- fade "out"|"in" [frames]: screen fade without warping (the Transition --- ramp startWarpTo uses, split in two). "out" pushes a black overlay --- that stays up; the held state keeps ticking the runner's frame-waits --- so a script can wait/replace_block under it. "in" ramps it away. +-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]: +-- screen fade without warping (the Transition ramp startWarpTo uses, +-- split in two). "out" pushes an overlay that stays up; the held state +-- keeps ticking the runner's frame-waits so a script can wait / +-- heal_party / play_once under it. "in" ramps it away. +-- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite +-- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to +-- 24 frames (3 palettes x 8); black defaults to 12 (Transition). local FadeOverlay = {} FadeOverlay.__index = FadeOverlay -function FadeOverlay.new(game, ow) - return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay) +function FadeOverlay.new(game, ow, color) + return setmetatable({ + game = game, ow = ow, alpha = 0, color = color or "black", + }, FadeOverlay) end function FadeOverlay:update() @@ -895,28 +934,51 @@ function FadeOverlay:update() end function FadeOverlay:draw() - love.graphics.setColor(0, 0, 0, self.alpha) + if self.color == "white" then + love.graphics.setColor(1, 1, 1, self.alpha) + else + love.graphics.setColor(0, 0, 0, self.alpha) + end love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) end -function Commands.fade(ctx, dir, frames) +local function parseFadeArgs(a, b) + local frames, color + if type(a) == "string" then + color = a + if type(b) == "number" then frames = b end + elseif type(a) == "number" then + frames = a + if type(b) == "string" then color = b end + end + color = color or "black" + if not frames then + frames = (color == "white") and 24 or 12 + end + return frames, color +end + +function Commands.fade(ctx, dir, framesOrColor, colorOrFrames) local ow = ctx.overworld if not ow then return end local runner = ctx.runner - frames = frames or 12 -- Transition's ramp length + local frames, color = parseFadeArgs(framesOrColor, colorOrFrames) local overlay = ow.fadeOverlay if dir == "out" then if not overlay then - overlay = FadeOverlay.new(ctx.game, ow) + overlay = FadeOverlay.new(ctx.game, ow, color) ow.fadeOverlay = overlay ctx.game.stack:push(overlay) + else + overlay.color = color end overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0, onDone = function() runner:resume() end } runner:yield() elseif dir == "in" then if not overlay then return end + overlay.color = color or overlay.color overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0, onDone = function() runner:resume() end } runner:yield() @@ -1006,7 +1068,7 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "open_mart", "trade", "push_screen", "record_hall_of_fame", "old_man_demo", "static_battle", "rival_battle", "give_item", "wait", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", - "emote", "fade", "pan_camera" }) do + "emote", "fade", "pan_camera", "play_once" }) do local meta = Commands.meta[verb] or {} Commands.meta[verb] = meta meta.blocking = true diff --git a/src/ui/Credits.lua b/src/ui/Credits.lua index 05bb4ada..07198c98 100644 --- a/src/ui/Credits.lua +++ b/src/ui/Credits.lua @@ -217,7 +217,8 @@ function Credits:update(dt) self.timer = FADE_FRAMES elseif self.phase == "end_fade" then -- Credits returns to HallOfFameResetEventsAndSaveScript here: the - -- save happens now, then 5 x 120 DelayFrames before the button wait + -- save (plus post-game home relocate, #103) happens now, then + -- 5 x 120 DelayFrames before the button wait if self.onTheEnd then self.onTheEnd() end self.phase = "end_hold" self.timer = 600 diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index eb7f3b3c..0d7bb673 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -1,5 +1,11 @@ -- Pokédex entry page: front sprite, kind, height/weight and the real -- dex description (data/pokemon/dex_entries.asm + dex_text.asm). +-- +-- `species` may be a species id string, or a table +-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's +-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the +-- owned bit so Oak's lab ball previews show height/weight/description +-- without permanently marking the mon owned. local Font = require("src.render.Font") @@ -16,8 +22,17 @@ function DexEntryMenu:sgbPalettes(game) P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) } end -function DexEntryMenu.new(game, species) - local self = setmetatable({ game = game }, DexEntryMenu) +local function resolveArgs(speciesOrOpts) + if type(speciesOrOpts) == "table" then + return speciesOrOpts.species or speciesOrOpts[1], + speciesOrOpts.forceOwned and true or false + end + return speciesOrOpts, false +end + +function DexEntryMenu.new(game, speciesOrOpts) + local species, forceOwned = resolveArgs(speciesOrOpts) + local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) self.def = game.data.pokemon[species] local ok, img = pcall(love.graphics.newImage, self.def.spriteFront) self.sprite = ok and img or nil @@ -42,12 +57,16 @@ function DexEntryMenu:draw() love.graphics.setColor(0, 0, 0, 1) Font.draw(def.name, 72, 8) local e = def.dexEntry or {} - Font.draw((e.kind or "?") .. " POKéMON", 72, 20) + -- English R/B prints only the kind string (hlcoord 9,4 PlaceString). + -- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm; + -- appending " POKéMON" here clipped longer kinds ("LIZARD POKé"). + Font.draw(e.kind or "?", 72, 20) -- same number width as the list (constants.dexDigits), so a dex past 999 -- prints the extra digit everywhere at once local digits = (self.game.data.constants or {}).dexDigits or 3 Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32) - local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id] + local owned = self.forceOwned + or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id]) -- height/weight print only once owned, like the description -- (pokedex.asm: "if the pokemon has not been owned, don't print the -- height, weight, or description") diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 817125e8..01be2e36 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -69,10 +69,26 @@ local function drawIcon(game, mon, x, y, selected, counter) local icons = game.data.icons if not icons then return end local def = game.data.pokemon[mon.species] - -- byDex is the vanilla lookup, but the icons registry can bring the table - -- into existence on its own, so it may be the only key present - local name = def and def.dex and icons.byDex and icons.byDex[def.dex] - local path = name and icons.icons[name] + -- Per-species override first: the icons registry folds into + -- icons.bySpecies, and a pokemon record may carry its own `icon` field. + -- Either is a built-in icon name (resolved through icons.icons) or a + -- { image = , frames? } table pointing at bundled art. Falling + -- through to icons.byDex[def.dex] keeps the vanilla dex-indexed default; + -- without the override a modded or dex-renumbered species could never + -- change its menu icon. + local entry = (icons.bySpecies and icons.bySpecies[mon.species]) + or (def and def.icon) + local name, path + if type(entry) == "string" then + name = entry + path = icons.icons and icons.icons[entry] + elseif type(entry) == "table" then + path = entry.image + end + if not path then + name = def and def.dex and icons.byDex and icons.byDex[def.dex] + path = name and icons.icons and icons.icons[name] + end if not path then return end if iconImages[path] == nil then local ok, img = pcall(love.graphics.newImage, path) @@ -332,7 +348,11 @@ function PartyMenu:update(dt) local items = { { label = "STATS", action = "stats" }, { label = "SWITCH", action = "switch" } } local ow = self.game.overworld - if not self.battle and ow and mon.hp > 0 then + -- Field moves (HMs/TMs) are usable out of battle even when the mon + -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. + -- Battle still excludes this list via `not self.battle`. Softboiled + -- can appear for a fainted user; its heal transfer then no-ops. + if not self.battle and ow then for _, mv in ipairs(mon.moves) do if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD" and self.game.save.inventory.THUNDERBADGE then diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index b765b91f..6a9f9379 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -1,6 +1,7 @@ -- The player's item-storage PC (engine/menus/players_pc.asm): -- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over --- game.save.pcItems ({ ITEM_ID = count }, created lazily). Withdraw and +-- game.save.pcItems ({ ITEM_ID = count }; New Game seeds { POTION = 1 }, +-- older saves may still be empty until first deposit). Withdraw and -- deposit ask "How many?" via the quantity selector (key items always -- move one); toss discards after a YES/NO confirm. Follows the -- BoxMenu/BagMenu list idioms. diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 213437fc..82c888f1 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1122,7 +1122,9 @@ end -- ------------------------------------------------------------------------- -- HM field moves are gated by badges like the original --- (constants.hmBadges; distinct from constants.hmMoves, the forget gate) +-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate). +-- Gen 1 allows field use from fainted party members (party menu + name +-- lookup for Cut/Surf messages); do not require mon.hp > 0 here. function OverworldState:partyKnows(moveId) local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId] local badge = gate and gate.badge @@ -1130,10 +1132,8 @@ function OverworldState:partyKnows(moveId) return nil end for _, mon in ipairs(Game.save.party) do - if mon.hp > 0 then - for _, mv in ipairs(mon.moves) do - if mv.id == moveId then return mon end - end + for _, mv in ipairs(mon.moves) do + if mv.id == moveId then return mon end end end return nil @@ -1887,7 +1887,7 @@ end -- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is -- a side-effect-free check that reports which text/flow the caller should -- use; the actual mount happens in trySurf on "ok". Returns: --- "no_badge" -> SOULBADGE missing / no live SURF mon (_NewBadgeRequiredText) +-- "no_badge" -> SOULBADGE missing / no SURF mon (_NewBadgeRequiredText) -- "forced_bike" -> on the Cycling Road (_CyclingIsFunText) -- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText) -- "dismount" -> already surfing, facing dry land; caller steps forward @@ -1930,7 +1930,7 @@ end -- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free -- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok". -- Returns: --- "no_badge" -> CASCADEBADGE missing / no live CUT mon (_NewBadgeRequiredText) +-- "no_badge" -> CASCADEBADGE missing / no CUT mon (_NewBadgeRequiredText) -- "nothing" -> not facing a cuttable tree (_NothingToCutText) -- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy) function OverworldState:useCutFieldMove() @@ -2351,6 +2351,8 @@ end -- Badges/items awarded after specific battles (data/scripts/victories.lua). -- `deactivate` retires unfought gym/dojo trainers the way the originals' -- SetEvent / SetEventRange do after the leader victory. +-- `hide` is { { mapId, objName }, ... } — HideObject on those toggles +-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1). function OverworldState:checkVictoryRewards(trainerClass, partyIndex) local victories = require("data.scripts.victories") local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)] @@ -2364,6 +2366,13 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) Game.save.flags[flag] = true end end + if reward.hide then + local Commands = require("src.script.Commands") + local ctx = { game = Game, save = Game.save, overworld = self } + for _, entry in ipairs(reward.hide) do + Commands.hide_object(ctx, entry[1], entry[2]) + end + end local lines = {} if reward.badge then Game.save.inventory[reward.badge] = 1 diff --git a/tests/drivers/hof_pc_test.lua b/tests/drivers/hof_pc_test.lua index 5a15b356..3c05763c 100644 --- a/tests/drivers/hof_pc_test.lua +++ b/tests/drivers/hof_pc_test.lua @@ -64,6 +64,13 @@ return function(game) check(warped, "YES warps to REDS_HOUSE_2F") check(ow.player.cellX == 3 and ow.player.cellY == 6, "landed on the bedroom spawn (3,6)") + check(ow.lastOutdoor and ow.lastOutdoor.id == "PALLET_TOWN", + "PC home warp retargets lastOutdoor to Pallet (#103)") + local lead = game.save.party and game.save.party[1] + if lead and lead.stats then + check(lead.hp == lead.stats.hp and lead.status == nil, + "PC home warp heals the party") + end U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index c8bc31de..69dd7c67 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -4165,21 +4165,8 @@ function ops.fieldMove(s, where) say(("fieldMove %s: nobody in the party knows it"):format(move)) return false end - -- The knower must also be CONSCIOUS: the party submenu hides field - -- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so - -- the menu walk below finds only STATS/SWITCH and fails. Route 23's - -- mandatory surf strip is where this bit: WARTORTLE arrived dead from - -- the Viridian Gym attrition, "no SURF entry" x3, the water was never - -- crossed, and the whole league skip-cascaded with all eight badges - -- in the bag. Heal, fail this attempt loudly, and let the caller's - -- retry run the step again with a live user. - local knower = (G.save.party or {})[slot] - if knower and (knower.hp or 0) <= 0 then - say(("fieldMove %s: its only user has fainted -- visiting a centre " - .. "before retrying"):format(move)) - visitPokeCenter(where) - return false - end + -- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows + -- no longer gate on hp > 0). No Poké Center detour needed here. -- CUT acts on the tile the player is FACING (OverworldState:tryCut), and -- a `goto` leaves us facing whatever direction we last stepped. Without -- turning to the tree first the move fires into empty ground, the game diff --git a/tests/love_stub.lua b/tests/love_stub.lua index e643af6c..784ecd4a 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -127,7 +127,12 @@ stub.sound = { stub.keyboard = { isDown = function() return false end } -stub.mouse = { getPosition = function() return 0, 0 end } +stub.mouse = { + getPosition = function() return 0, 0 end, + isCursorSupported = function() return false end, + getSystemCursor = function(name) return name end, + setCursor = function() end, +} stub.timer = { getTime = function() return 0 end } diff --git a/tests/mod_constants_tests.lua b/tests/mod_constants_tests.lua index 00e75052..ba8f94ae 100644 --- a/tests/mod_constants_tests.lua +++ b/tests/mod_constants_tests.lua @@ -320,6 +320,9 @@ check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3 "the seeded boot config reproduces the NewGameWarp bedroom spawn") check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE" and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start") +-- issue #109: vanilla New Game puts 1 Potion in the player's item PC +check(vanillaSave.pcItems and vanillaSave.pcItems.POTION == 1, + "new game seeds 1 Potion in the player's PC") -- the heal point is deliberately NOT the spawn: wLastBlackoutMap is -- zero-filled at new game and PALLET_TOWN is map 0 check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5 @@ -329,6 +332,8 @@ local bareSave = SaveData.newGame() check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000 and bareSave.lastHeal.map == "PALLET_TOWN", "newGame without a boot config is unchanged") +check(bareSave.pcItems and bareSave.pcItems.POTION == 1, + "newGame without a boot config still seeds the PC Potion") local bootData, bootLoader = withMod("total_conversion", [[ return function(mod) diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 87b3dc14..394a1c83 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -618,6 +618,33 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE, "RED++ still has ROUTE (aliased from VIRIDIAN)") check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON, "RED++ passes zone colors through like GBC") +-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1 +-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red +do + local GameVersion = require("src.core.GameVersion") + local prevVer = GameVersion.get() + local blueLogo1 = { + { 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 }, + } + local blueSlots2 = { + { 255, 239, 255 }, { 255, 255, 140 }, { 132, 156, 239 }, { 25, 16, 16 }, + } + local rom = { palettes = { palettes = { + LOGO1 = blueLogo1, SLOTS2 = blueSlots2, ROUTE = { { 1, 2, 3 }, { 0, 0, 0 }, + { 0, 0, 0 }, { 0, 0, 0 } }, + } } } + GameVersion.set("blue") + check(PaletteFX.pal(rom, "LOGO1") == blueLogo1, + "Blue + RED++ prefers ROM LOGO1 over the Red gbc pack") + check(PaletteFX.pal(rom, "SLOTS2") == blueSlots2, + "Blue + RED++ prefers ROM SLOTS2 over the Red gbc pack") + check(PaletteFX.pal(rom, "ROUTE") == gbc.palettes.ROUTE, + "Blue + RED++ still uses the gbc pack for shared names") + GameVersion.set("red") + check(PaletteFX.pal(rom, "LOGO1") == gbc.palettes.LOGO1, + "Red + RED++ keeps the gbc-pack LOGO1 (title stays red)") + GameVersion.set(prevVer) +end PaletteFX.setMode(prevMode) -- ------- the transitions registry diff --git a/tests/mod_save_tests.lua b/tests/mod_save_tests.lua index 609fa56d..9641c084 100644 --- a/tests/mod_save_tests.lua +++ b/tests/mod_save_tests.lua @@ -536,6 +536,32 @@ do "newGame starts an empty modData") check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0, "newGame stamps a vanilla meta") + check(plain.pcItems and plain.pcItems.POTION == 1, + "unhooked newGame seeds 1 Potion in pcItems (issue #109)") +end + +-- issue #109: loading a pre-fix save that never had pcItems must not +-- invent a free Potion (player may already have withdrawn/tossed it, or +-- the empty PC is intentional). Seeding is New Game only. +do + local files = {} + love.filesystem = memfs(files) + local legacy = { + meta = { format = Version.saveFormat, mods = {} }, + player = { map = "PALLET_TOWN", x = 5, y = 6, facing = "down", + name = "RED", rival = "BLUE", id = 1 }, + flags = {}, inventory = {}, party = {}, box = {}, money = 3000, + defeatedTrainers = {}, pokedex = { seen = {}, owned = {} }, + lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 }, + options = SaveData.defaultOptions(), + } + check(legacy.pcItems == nil, "fixture omits pcItems on purpose") + check(SaveData.save(legacy), "legacy save without pcItems writes") + local loaded = SaveData.load() + check(loaded ~= nil, "legacy save loads") + check(loaded.pcItems == nil or loaded.pcItems.POTION == nil, + "load does not invent a PC Potion for existing saves") + love.filesystem = realFS end -- a throwing mod migration is skipped, never fatal: an uncaught error here diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 237858ab..d994c6f5 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -475,6 +475,38 @@ check(title.version and title.version.path == "assets/generated/title/red_version.png", "the shipped version ribbon loads") +-- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon), +-- not Red's, when the ROM pack carries Blue SuperPalettes +do + local GameVersion = require("src.core.GameVersion") + local PaletteFX = require("src.render.PaletteFX") + local prevVer, prevMode = GameVersion.get(), PaletteFX.mode + local blueLogo1 = { + { 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 }, + } + local mewmon = { + { 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 }, + } + local logo2 = { + { 255, 239, 255 }, { 247, 247, 140 }, { 148, 148, 197 }, { 58, 58, 132 }, + } + local game = { data = { palettes = { palettes = { + LOGO1 = blueLogo1, LOGO2 = logo2, MEWMON = mewmon, + } } } } + PaletteFX.setMode("redpp") + GameVersion.set("blue") + local zones = TitleState.sgbPalettes(title, game) + check(zones and zones[2] and zones[2].colors == blueLogo1, + "Blue title ribbon zone uses ROM LOGO1 under RED++") + GameVersion.set("red") + zones = TitleState.sgbPalettes(title, game) + local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1 + check(zones and zones[2] and zones[2].colors == gbcLogo1, + "Red title under RED++ keeps gbc-pack LOGO1 even if ROM has Blue's") + GameVersion.set(prevVer) + PaletteFX.setMode(prevMode) +end + local OakSpeech = require("src.ui.OakSpeech") local ogame = { data = { field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } }, diff --git a/tests/parity_A.lua b/tests/parity_A.lua index e082401f..09c6786d 100644 --- a/tests/parity_A.lua +++ b/tests/parity_A.lua @@ -338,6 +338,15 @@ do end check(pewterNpc and ow:trainerDefeated(pewterNpc), "unfought Pewter gym trainer is defeated after badge") + -- PewterGym.asm .gymVictory HideObject TOGGLE_GYM_GUY / + -- TOGGLE_ROUTE_22_RIVAL_1 (persists via objectToggles) + check(Game.save.objectToggles + and Game.save.objectToggles.PEWTER_CITY + and Game.save.objectToggles.PEWTER_CITY.PEWTERCITY_YOUNGSTER == false, + "Brock victory hides PEWTERCITY_YOUNGSTER") + check(Game.save.objectToggles.ROUTE_22 + and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false, + "Brock victory hides ROUTE22_RIVAL1") while Game.stack:top() do Game.stack:pop() end Game.save = SaveData.newGame() diff --git a/tests/parity_C.lua b/tests/parity_C.lua index a156c297..30bd90d9 100644 --- a/tests/parity_C.lua +++ b/tests/parity_C.lua @@ -281,11 +281,21 @@ end -- the digit only), plus the LIFT_KEY gate. -- =================================================================== do - -- without the key: text-only, no floor menu - local gated = openElevator("ROCKET_HIDEOUT_ELEVATOR", {}) + local hideoutSnap = snapshotWarps("ROCKET_HIDEOUT_ELEVATOR") + -- ROM car defaults to B1F; entering from another floor without the key + -- must still seed a walk-out back to that floor (#90 / #105). + eq(hideoutSnap[1].destMap, "ROCKET_HIDEOUT_B1F", + "Rocket Hideout ROM car warps still default to B1F") + + -- without the key: text-only, no floor menu, but exit is seeded + local gated, _, _, gatedOw = + openElevator("ROCKET_HIDEOUT_ELEVATOR", {}, "ROCKET_HIDEOUT_B2F") check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something") check(gated ~= nil and getmetatable(gated) ~= ListMenu, "Rocket Hideout without LIFT_KEY does not open the floor menu") + eq(gatedOw.map.def.warps[1].destMap, "ROCKET_HIDEOUT_B2F", + "Rocket Hideout without LIFT_KEY seeds exit warps to the entry floor") + restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap) -- with the key: full B1F/B2F/B4F menu, same as the other elevators local menu, warpCalls, stack, ow = openElevator("ROCKET_HIDEOUT_ELEVATOR", { LIFT_KEY = 1 }) @@ -324,6 +334,7 @@ do eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y") end end + restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap) end Sound.play = origSoundPlay diff --git a/tests/parity_F.lua b/tests/parity_F.lua index cf83c7a8..7964565c 100644 --- a/tests/parity_F.lua +++ b/tests/parity_F.lua @@ -69,6 +69,12 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered") check(Flags.get(Game.save, "EVENT_OAK_GOT_PARCEL"), "delivery flag set") check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex flag set") +-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 (#106) +local labToggles = Game.save.objectToggles and Game.save.objectToggles.OAKS_LAB +check(labToggles and labToggles.OAKSLAB_POKEDEX1 == false, + "POKEDEX1 hidden after receiving Pokédex") +check(labToggles and labToggles.OAKSLAB_POKEDEX2 == false, + "POKEDEX2 hidden after receiving Pokédex") eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat") -- talking to Oak again before beating the Route 22 rival should fall @@ -89,4 +95,16 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), "Oak talk (post-grant) script completes") eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk") +-- === 5) onEnter re-hides table Pokédex for pre-#106 saves === +Game.save.objectToggles = { OAKS_LAB = { + OAKSLAB_POKEDEX1 = true, OAKSLAB_POKEDEX2 = true, +} } +local oaksLab = mapScripts.get("OAKS_LAB") +check(oaksLab and type(oaksLab.onEnter) == "function", "OAKS_LAB onEnter present") +oaksLab.onEnter(Game, nil) +local repaired = Game.save.objectToggles.OAKS_LAB +check(repaired.OAKSLAB_POKEDEX1 == false + and repaired.OAKSLAB_POKEDEX2 == false, + "onEnter hides both Pokédex table sprites when EVENT_GOT_POKEDEX is set") + S.finish() diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 82306315..1c4d1d33 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -408,6 +408,55 @@ check(strFlash ~= page2 and strFlash ~= ow and strFlash.pages == nil, for _ = 1, 10 do frame({}) end eq(Game.stack:top(), ow, "back on the map after the blink") +-- =========================================================================== +-- #107: fainted Pokémon can still use field moves (party submenu + partyKnows) +-- =========================================================================== +local fainted = mkMon("PIDGEOTTO", "FLY", "CUT", "STRENGTH", "SURF") +fainted.hp = 0 +Game.save.party = { fainted } +Game.save.inventory = { + THUNDERBADGE = true, CASCADEBADGE = true, + RAINBOWBADGE = true, SOULBADGE = true, +} +ow = pushOW("PALLET_TOWN", 4, 13, "down") +check(ow:partyKnows("SURF") == fainted, + "partyKnows finds SURF on a fainted mon") +check(ow:partyKnows("CUT") == fainted, + "partyKnows finds CUT on a fainted mon") +local pmFaint = PartyMenu.new(Game) +Game.stack:push(pmFaint) +frame({ "a" }) +local actsFaint = submenuActions(pmFaint) +-- FLY needs OVERWORLD tileset (Pallet); CUT/SURF/STRENGTH need badges only +check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength, + "fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu") +popToOW() + +-- STRENGTH activation from a fainted user (name text + strengthActive) +ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") +clearCaptured() +local pmFaintStr = PartyMenu.new(Game) +-- Seafoam is not OVERWORLD, so FLY is omitted: STATS, SWITCH, CUT, STRENGTH, SURF +selectSubItem(pmFaintStr, 4) +eq(Game.overworld.strengthActive, true, + "fainted mon can activate STRENGTH from the party menu") +check(sawText("used") and sawText("STRENGTH"), + "fainted STRENGTH still prints _UsedStrengthText") +drainText() + +-- SURF mount from a fainted user when facing water +Game.save.party = { fainted } +Game.save.inventory.SOULBADGE = true +ow = pushOW("PALLET_TOWN", 4, 13, "down") +ow.player.surfing = false +eq(ow:useSurfFieldMove(), "ok", "useSurfFieldMove ok with only a fainted SURF mon") +clearCaptured() +-- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon) +local pmFaintSurf = PartyMenu.new(Game) +selectSubItem(pmFaintSurf, 6) +eq(ow.player.surfing, true, "fainted mon can SURF from the party menu") +check(not onStack(pmFaintSurf), "party menu closes after fainted SURF") + -- restore the spied constructor so later dofile'd suites are unaffected TextBox.new = realTextBoxNew popAll() diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index 4a840bb0..d798339a 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -88,8 +88,12 @@ local SaveData = require("src.core.SaveData") local stack2 = newStack() local game2 = { data = Data, input = fakeInput, stack = stack2, save = SaveData.newGame() } -game2.save.party = { { species = "PIKACHU", level = 81 } } +-- battered party: record_hall_of_fame must heal before the autosave (#103) +game2.save.party = { { species = "PIKACHU", level = 81, hp = 1, + stats = { hp = 100 }, status = "PAR", + moves = { { id = "THUNDERBOLT", pp = 0 } } } } game2.save.player.map = "HALL_OF_FAME" +game2.save.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 } local wrote = false function game2:writeSave() wrote = true; SaveData.save(self.save) end @@ -129,9 +133,39 @@ local saved = savedRaw and SaveData.decode(savedRaw) or nil check(saved ~= nil, "save.lua written and decodable") eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN", "wLastBlackoutMap := PALLET_TOWN before the save") -eq(saved and saved.player and saved.player.map, "HALL_OF_FAME", - "save keeps the player in the HALL_OF_FAME room") +eq(saved and saved.player and saved.player.map, "REDS_HOUSE_2F", + "CONTINUE lands in the bedroom (post-game home, #103)") +eq(saved and saved.player and saved.player.x, 3, "bedroom spawn x") +eq(saved and saved.player and saved.player.y, 6, "bedroom spawn y") +eq(saved and saved.lastOutdoor and saved.lastOutdoor.id, "PALLET_TOWN", + "LAST_MAP exits aim at Pallet Town, not Indigo") +check(saved and saved.postGameHomeOk, "postGameHomeOk marks the relocate done") eq(saved and #(saved.hallOfFame or {}), 1, "hall of fame team persisted") +local healed = saved and saved.party and saved.party[1] +local thunderPp = Data.moves and Data.moves.THUNDERBOLT and Data.moves.THUNDERBOLT.pp +check(healed and healed.hp == healed.stats.hp and healed.status == nil + and healed.moves[1].pp == thunderPp, + "party is fully healed in the post-credits save") + +-- house exit mats resolve to Pallet's door, not Indigo +local Warp = require("src.world.Warp") +local houseExit = { destMap = "LAST_MAP", destWarp = 1, x = 2, y = 7 } +local destMap, dx, dy = Warp.destination(Data, houseExit, saved.lastOutdoor) +eq(destMap, "PALLET_TOWN", "Red's house LAST_MAP -> PALLET_TOWN") +eq(dx, 5, "Pallet door x") +eq(dy, 5, "Pallet door y") + +-- stuck-save rescue: HoF + hallOfFame + no postGameHomeOk -> bedroom +local stuck = SaveData.newGame() +stuck.player.map = "HALL_OF_FAME" +stuck.player.x, stuck.player.y = 4, 2 +stuck.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 } +stuck.hallOfFame = { { { species = "PIKACHU", level = 81 } } } +check(SaveData.needsPostGameRescue(stuck), "pre-fix softlock save needs rescue") +SaveData.applyPostGameHome(stuck, Data.field.boot) +check(not SaveData.needsPostGameRescue(stuck), "rescue is one-shot") +eq(stuck.player.map, "REDS_HOUSE_2F", "rescue warps home") +eq(stuck.lastOutdoor.id, "PALLET_TOWN", "rescue retargets LAST_MAP") -- `jp Init`: everything popped, boot sequence pushed (intro -> title) eq(#stack2.states, 1, "soft reset leaves exactly the boot state") diff --git a/tests/parity_lift_key.lua b/tests/parity_lift_key.lua new file mode 100644 index 00000000..932876ac --- /dev/null +++ b/tests/parity_lift_key.lua @@ -0,0 +1,129 @@ +-- Parity: Rocket Hideout B4F Lift Key drop (#90 / #105). +-- +-- Oracle: scripts/RocketHideoutB4F.asm RocketHideoutB4FRocket3AfterBattleText +-- (PrintText + CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject +-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). The LIFT_KEY object_event starts +-- hidden; talking to Rocket3 after his defeat reveals it. +-- +-- Self-contained: `luajit tests/parity_lift_key.lua`; also dofile'd by +-- tests/run_tests.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.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity lift key") +local check, eq = S.check, S.eq + +local mapScripts = require("data.scripts.init") +local OverworldState = require("src.world.OverworldController") + +local liftKeyObj +for _, o in ipairs(Data.maps.ROCKET_HIDEOUT_B4F.objects) do + if o.name == "ROCKETHIDEOUTB4F_LIFT_KEY" then liftKeyObj = o break end +end +check(liftKeyObj and liftKeyObj.hidden == true, + "LIFT_KEY object_event is extracted as hidden") + +local script = mapScripts.talkScript("ROCKET_HIDEOUT_B4F", + "TEXT_ROCKETHIDEOUTB4F_ROCKET3") +check(type(script) == "function", + "Rocket3 after-battle drop is a hand-ported talk handler") + +-- Capture TextBox payloads without driving the typewriter / font path. +local realTB = package.loaded["src.render.TextBox"] +package.loaded["src.render.TextBox"] = { + new = function(game, text, done) return { text = text, onDone = done } end, +} + +-- Drive the defeated-talk path headless: show after text, then ShowObject. +do + local pushed, engaged + local save = { + flags = {}, + inventory = {}, + defeatedTrainers = { ROCKET_HIDEOUT_B4F_obj_4 = true }, + objectToggles = {}, + } + local game = { + data = Data, + save = save, + stack = { + push = function(_, box) pushed = box end, + }, + } + local ow = { + map = { id = "ROCKET_HIDEOUT_B4F", def = Data.maps.ROCKET_HIDEOUT_B4F }, + npcs = {}, + entities = {}, + trainerDefeated = function(_, npc) + return save.defeatedTrainers[npc.id] == true + end, + engageTrainer = function() engaged = true end, + } + local npc = { + id = "ROCKET_HIDEOUT_B4F_obj_4", + def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4, + text = "TEXT_ROCKETHIDEOUTB4F_ROCKET3", + trainerClass = "OPP_ROCKET", trainerParty = 18 }, + } + local doneCalled = false + script(game, ow, npc, function() doneCalled = true end) + + check(not engaged, "defeated Rocket3 talk does not re-engage battle") + check(pushed ~= nil, "defeated Rocket3 talk pushes the after-battle text") + eq(pushed and pushed.text, Data.text._RocketHideoutB4FRocket3AfterBattleText, + "after-battle text is the dropped-key line") + check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY, + "drop flag unset until the text box closes") + check(not OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj), + "LIFT_KEY still hidden before the after-battle text finishes") + + -- dismiss the box: CheckAndSetEvent + ShowObject + pushed.onDone() + check(doneCalled, "talk done() runs after ShowObject") + check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true, + "EVENT_ROCKET_DROPPED_LIFT_KEY set on first after-battle talk") + check(save.objectToggles.ROCKET_HIDEOUT_B4F + and save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY == true, + "ShowObject toggles ROCKETHIDEOUTB4F_LIFT_KEY visible") + check(OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj), + "LIFT_KEY objectVisible after ShowObject") + + -- second talk: reprint only, no double-toggle churn + pushed, doneCalled = nil, false + local togglesBefore = save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY + script(game, ow, npc, function() doneCalled = true end) + check(pushed ~= nil, "second after-battle talk still shows text") + pushed.onDone() + check(doneCalled, "second talk done() runs") + eq(save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY, togglesBefore, + "second talk does not re-ShowObject") +end + +-- Undefeated path still engages the trainer (TalkToTrainer). +do + local engaged = false + local save = { flags = {}, inventory = {}, defeatedTrainers = {} } + local game = { + data = Data, save = save, + stack = { push = function() end }, + } + local ow = { + map = { id = "ROCKET_HIDEOUT_B4F" }, + trainerDefeated = function() return false end, + engageTrainer = function(_, npc, done) + engaged = true + if done then done() end + end, + } + local npc = { + id = "ROCKET_HIDEOUT_B4F_obj_4", + def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4 }, + } + script(game, ow, npc, function() end) + check(engaged, "undefeated Rocket3 talk engages the trainer battle") +end + +package.loaded["src.render.TextBox"] = realTB +S.finish() diff --git a/tests/parity_mom_heal.lua b/tests/parity_mom_heal.lua new file mode 100644 index 00000000..99d74ef7 --- /dev/null +++ b/tests/parity_mom_heal.lua @@ -0,0 +1,100 @@ +-- Parity test: Mom heal rest (#113) and Silph Co. 9F nurse fade. +-- +-- pokered RedsHouse1FMomText / RedsHouse1FMomHealScript: +-- pre-starter -> WakeUpText; else YouShouldRest -> GBFadeOutToWhite -> +-- HealParty -> MUSIC_PKMN_HEALED (wait) -> GBFadeInFromWhite -> +-- LookingGreatText. +-- SilphCo9FNurseText: heal then white fade / Delay3 / fade in (no jingle). +-- +-- Self-contained; run via `luajit tests/parity_mom_heal.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity mom heal") +local check, eq = S.check, S.eq + +local reds = dofile("data/scripts/reds_house.lua") +local silph = dofile("data/scripts/flavor/silph_co_9f.lua") + +local function cmds(rows) + local out = {} + for _, row in ipairs(rows) do out[#out + 1] = row[1] end + return out +end + +local function find(rows, verb, arg) + for i, row in ipairs(rows) do + if row[1] == verb and (arg == nil or row[2] == arg) then + return i, row + end + end +end + +-- --- Mom: pre-starter branch + heal rest sequence +do + local rows = reds.talk.TEXT_REDSHOUSE1F_MOM + check(rows ~= nil, "Mom talk script exists") + + local iFlag = find(rows, "check_flag", "EVENT_GOT_STARTER") + check(iFlag, "Mom checks EVENT_GOT_STARTER") + local iWake = find(rows, "show_text", "_RedsHouse1FMomWakeUpText") + check(iWake and iWake > iFlag, "WakeUpText follows the starter check") + local iRest = find(rows, "show_text", "_RedsHouse1FMomYouShouldRestText") + check(iRest and iRest > iWake, "heal path is after the wake-up branch") + + local iFadeOut = find(rows, "fade", "out") + local iHeal = find(rows, "heal_party") + local iJingle = find(rows, "play_once", "Music_PkmnHealed") + local iFadeIn = find(rows, "fade", "in") + local iGreat = find(rows, "show_text", "_RedsHouse1FMomLookingGreatText") + check(iFadeOut and rows[iFadeOut][3] == "white", + "Mom fades out to white after rest text") + check(iHeal and iHeal > iFadeOut, "heal_party runs under the white fade") + check(iJingle and iJingle > iHeal, + "Music_PkmnHealed plays after heal_party") + check(iFadeIn and rows[iFadeIn][3] == "white" and iFadeIn > iJingle, + "Mom fades in from white after the jingle") + check(iGreat and iGreat > iFadeIn, + "LookingGreatText follows the fade-in") + + -- jump_if_true must land on the heal path (rest text), not wake-up + local jumpRow + for _, row in ipairs(rows) do + if row[1] == "jump_if_true" then jumpRow = row break end + end + check(jumpRow, "Mom has jump_if_true after starter check") + eq(rows[jumpRow[2]][2], "_RedsHouse1FMomYouShouldRestText", + "EVENT_GOT_STARTER jumps to the heal rest text") +end + +-- --- Silph Co. 9F nurse: white fade, no heal jingle +do + local rows = silph.SILPH_CO_9F.talk.TEXT_SILPHCO9F_NURSE + check(rows ~= nil, "Silph nurse talk script exists") + + local iHeal = find(rows, "heal_party") + local iFadeOut = find(rows, "fade", "out") + local iWait = find(rows, "wait") + local iFadeIn = find(rows, "fade", "in") + check(iHeal, "Silph nurse heals the party") + check(iFadeOut and rows[iFadeOut][3] == "white" and iFadeOut > iHeal, + "Silph nurse fades out to white after heal (pokered order)") + check(iWait and rows[iWait][2] == 3 and iWait > iFadeOut, + "Silph nurse Delay3 between fades") + check(iFadeIn and rows[iFadeIn][3] == "white" and iFadeIn > iWait, + "Silph nurse fades in from white") + check(not find(rows, "play_once", "Music_PkmnHealed"), + "Silph nurse does not play Music_PkmnHealed") + + local sequence = table.concat(cmds(rows), ",") + check(sequence:find("heal_party,fade,wait,fade,show_text", 1, true), + "Silph heal rest sequence is heal → fade out → wait → fade in → text") +end + +-- --- play_once is a blocking command (Mom / captain wait loops) +do + local Commands = require("src.script.Commands") + check(Commands.meta.play_once and Commands.meta.play_once.blocking, + "play_once is marked blocking so heal jingles wait to finish") +end + +S.finish() diff --git a/tests/parity_pewter_escort.lua b/tests/parity_pewter_escort.lua new file mode 100644 index 00000000..67344631 --- /dev/null +++ b/tests/parity_pewter_escort.lua @@ -0,0 +1,117 @@ +-- Parity test: Pewter City youngster gym escort paths. +-- Self-contained: run via `luajit tests/parity_pewter_escort.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +-- +-- Sources: scripts/PewterCity.asm, engine/overworld/auto_movement.asm +-- (RLEList_PewterGymPlayer / RLEList_PewterGymGuy), +-- engine/events/pewter_guys.asm (PewterGymGuyCoords). +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.PEWTER_CITY) then Data:load() end +local S = require("tests.harness").suite("parity pewter escort") +local check, eq = S.check, S.eq + +local mapScripts = require("data.scripts.init") +local pewter = mapScripts.get("PEWTER_CITY") +check(pewter and pewter.escort, "PEWTER_CITY exposes the escort tables") +local escort = pewter.escort + +local function joined(t) return table.concat(t, ",") end + +local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +eq(#escort.guySteps, 41, "youngster takes 41 steps (RLEList_PewterGymGuy)") +eq(joined({ escort.guySteps[1], escort.guySteps[2], escort.guySteps[#escort.guySteps] }), + "down,down,right", "guy path starts with DOWN×2 and ends RIGHT") +eq(#escort.guyReturnSteps, 41, "return path mirrors the escort") +do + local opp = { up = "down", down = "up", left = "right", right = "left" } + eq(escort.guyReturnSteps[1], opp[escort.guySteps[#escort.guySteps]], + "return starts with opposite of escort's last step") + local gx, gy = 12, 18 + for _, d in ipairs(escort.guyReturnSteps) do + gx, gy = gx + D[d][1], gy + D[d][2] + end + check(gx == 35 and gy == 16, + "return path from the gym lands on his spawn (35,16)") +end + +-- every east-exit / talk tile lands both sprites by the gym road +local triggers = { { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 }, { 34, 16 } } +for _, pos in ipairs(triggers) do + local px, py = pos[1], pos[2] + local plan = escort.playerPlan(px, py) + check(plan, ("playerPlan for (%d,%d)"):format(px, py)) + local gx, gy = 35, 16 + local gi = 0 + for _ = 1, plan.guyHeadStart do + gi = gi + 1 + local d = escort.guySteps[gi] + gx, gy = gx + D[d][1], gy + D[d][2] + end + for i, ps in ipairs(plan.steps) do + px, py = px + D[ps][1], py + D[ps][2] + local gs = escort.guySteps[plan.guyHeadStart + i] + if gs then + gx, gy = gx + D[gs][1], gy + D[gs][2] + end + end + eq(px, 11, ("player from (%d,%d) ends at x=11"):format(pos[1], pos[2])) + eq(py, 18, ("player from (%d,%d) ends at y=18"):format(pos[1], pos[2])) + eq(gx, 12, ("guy from (%d,%d) ends at x=12"):format(pos[1], pos[2])) + eq(gy, 18, ("guy from (%d,%d) ends at y=18"):format(pos[1], pos[2])) +end + +-- the short east-road trigger uses no head-start; the (36,17)/(37,18) +-- tiles pause for one guy step (eight NO_INPUT frames) +eq(escort.playerPlan(35, 17).guyHeadStart, 0, "(35,17) no head-start") +eq(escort.playerPlan(36, 17).guyHeadStart, 1, "(36,17) one-step head-start") +eq(escort.playerPlan(37, 18).guyHeadStart, 1, "(37,18) one-step head-start") + +-- path stays off the PEWTER_GYM door warp and the blocked gym building +-- cells: sample the (35,17) walk and confirm no step lands on (16,17) +do + local plan = escort.playerPlan(35, 17) + local px, py = 35, 17 + local gx, gy = 35, 16 + for i, ps in ipairs(plan.steps) do + px, py = px + D[ps][1], py + D[ps][2] + local gs = escort.guySteps[i] + if gs then gx, gy = gx + D[gs][1], gy + D[gs][2] end + check(not (px == 16 and py == 17), + ("player beat %d does not phase onto gym door"):format(i)) + check(not (gx == 16 and gy == 17), + ("guy beat %d does not phase onto gym door"):format(i)) + end +end + +-- gym door warp is still where the player is meant to walk afterward +local MapLoader = require("src.world.MapLoader") +local city = MapLoader.load(Data, "PEWTER_CITY") +local w = city:warpAtCell(16, 17) +check(w and w.def.destMap == "PEWTER_GYM", "(16,17) is the Pewter Gym door") + +-- object spawn matches pokered object_event 35, 16 +local young +for _, o in ipairs(Data.maps.PEWTER_CITY.objects) do + if o.name == "PEWTERCITY_YOUNGSTER" then young = o; break end +end +check(young and young.x == 35 and young.y == 16, + "PEWTERCITY_YOUNGSTER spawns at (35,16)") + +-- Brock victory permanently HideObject's him (PewterGym.asm .gymVictory) +do + local victories = require("data.scripts.victories") + local hide = victories["OPP_BROCK#1"] and victories["OPP_BROCK#1"].hide + check(hide, "OPP_BROCK#1 lists objects to hide") + local found + for _, entry in ipairs(hide or {}) do + if entry[1] == "PEWTER_CITY" and entry[2] == "PEWTERCITY_YOUNGSTER" then + found = true + end + end + check(found, "Brock victory hides PEWTERCITY_YOUNGSTER") +end + +S.finish() diff --git a/tests/parity_starter_dex.lua b/tests/parity_starter_dex.lua new file mode 100644 index 00000000..30ba9cb4 --- /dev/null +++ b/tests/parity_starter_dex.lua @@ -0,0 +1,117 @@ +-- Parity: Oak's lab starter-ball Pokédex preview (#110). +-- pret StarterDex (engine/events/starter_dex.asm) temporarily sets the +-- owned bits so ShowPokedexData prints the full entry before the player +-- has caught anything. Also: English R/B prints only the kind string +-- (no " POKéMON" suffix — that clipped "LIZARD" to "LIZARD POKé"). +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.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity starter dex") +local check, eq = S.check, S.eq + +local Font = require("src.render.Font") +Font.load(Data) + +local DexEntryMenu = require("src.ui.DexEntryMenu") +local SaveData = require("src.core.SaveData") +local mapScripts = require("data.scripts.init") + +local function fakeGame() + return { + data = Data, + save = SaveData.newGame(), + input = { wasPressed = function() return false end }, + stack = { pop = function() end }, + } +end + +local function drawCapture(menu) + local drawn = {} + local saved = Font.draw + Font.draw = function(text, x, y) + drawn[#drawn + 1] = { text = tostring(text), x = x, y = y } + return Font.width(text) + end + menu:draw() + Font.draw = saved + return drawn +end + +local function findText(drawn, needle) + for _, d in ipairs(drawn) do + if d.text == needle or d.text:find(needle, 1, true) then return d end + end + return nil +end + +-- === 1) unowned entry without forceOwned stays "Data unknown." === +do + local game = fakeGame() + game.save.pokedex = { seen = {}, owned = {} } + local menu = DexEntryMenu.new(game, "CHARMANDER") + local drawn = drawCapture(menu) + check(findText(drawn, "Data unknown."), + "unowned Charmander shows Data unknown without forceOwned") + check(not findText(drawn, "Obviously prefers"), + "unowned Charmander hides description without forceOwned") + check(not findText(drawn, "HT "), + "unowned Charmander hides height without forceOwned") +end + +-- === 2) forceOwned shows full entry without mutating save === +do + local game = fakeGame() + game.save.pokedex = { seen = {}, owned = {} } + local menu = DexEntryMenu.new(game, { species = "CHARMANDER", forceOwned = true }) + check(menu.forceOwned, "forceOwned flag sticks on the menu") + local drawn = drawCapture(menu) + check(findText(drawn, "Obviously prefers"), + "forceOwned Charmander shows dex description") + check(findText(drawn, "HT "), + "forceOwned Charmander shows height") + check(not findText(drawn, "Data unknown."), + "forceOwned Charmander does not show Data unknown") + check(not game.save.pokedex.owned.CHARMANDER, + "forceOwned preview does not mark Charmander owned") +end + +-- === 3) kind is the bare English string (no POKéMON suffix) === +do + local game = fakeGame() + game.save.pokedex = { seen = {}, owned = { CHARMANDER = true } } + local menu = DexEntryMenu.new(game, "CHARMANDER") + local drawn = drawCapture(menu) + local kind = findText(drawn, "LIZARD") + check(kind and kind.text == "LIZARD", + "kind draws as LIZARD only (English R/B PlaceString)") + check(not findText(drawn, "POKéMON"), + "kind line does not append POKéMON") + check(kind.x + Font.width(kind.text) <= 160, + "LIZARD kind fits on-screen (no clip)") +end + +-- === 4) Oak's lab starter scripts request forceOwned === +do + local balls = { + "TEXT_OAKSLAB_CHARMANDER_POKE_BALL", + "TEXT_OAKSLAB_SQUIRTLE_POKE_BALL", + "TEXT_OAKSLAB_BULBASAUR_POKE_BALL", + } + for _, textId in ipairs(balls) do + local script = mapScripts.talkScript("OAKS_LAB", textId) + local found + for _, row in ipairs(script) do + if row[1] == "push_screen" and row[2] == "DexEntryMenu" then + found = row[3] + break + end + end + check(type(found) == "table" and found.forceOwned == true + and type(found.species) == "string", + textId .. " pushes DexEntryMenu with forceOwned") + end +end + +S.finish() diff --git a/tests/parity_tower_rival.lua b/tests/parity_tower_rival.lua new file mode 100644 index 00000000..6e734f05 --- /dev/null +++ b/tests/parity_tower_rival.lua @@ -0,0 +1,138 @@ +-- Parity test: Pokémon Tower 2F rival leaves after defeat (#111). +-- +-- pokered PokemonTower2FDefeatedRivalScript (scripts/PokemonTower2F.asm): +-- after EVENT_BEAT_POKEMON_TOWER_RIVAL, walk RightThenDown or +-- DownThenRight (EVENT_POKEMON_TOWER_RIVAL_ON_LEFT when the player is on +-- (15,5)), then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. The port used +-- to end after DefeatedText, so he stayed and HowsYourDex could re-fire. +-- +-- Self-contained; run via `luajit tests/parity_tower_rival.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity tower rival") +local check, eq = S.check, S.eq + +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { text = text, done = done } end, +} +package.loaded["src.core.Music"] = { + play = function() end, + playOnce = function() return true end, + stop = function() end, +} + +local story = dofile("data/scripts/story.lua") +local tower = story.POKEMON_TOWER_2F +check(tower ~= nil, "POKEMON_TOWER_2F map script exists") +check(type(tower.rivalScript) == "function", "rivalScript helper exported") +check(type(tower.onStep) == "function", "onStep ambush exists") +check(type(tower.onEnter) == "function", "onEnter hide repair exists") + +local RIGHT_THEN_DOWN = + { "right", "down", "down", "right", "down", "down", "right", "right" } +local DOWN_THEN_RIGHT = + { "down", "down", "right", "right", "right", "right", "down", "down" } + +local function dirsEqual(a, b) + if type(a) ~= "table" or #a ~= #b then return false end + for i = 1, #b do if a[i] ~= b[i] then return false end end + return true +end + +local function findWalk(rows) + for _, r in ipairs(rows) do + if r[1] == "walk_npc" then return r end + end +end + +-- (1) both approach tiles pick the pokered exit movement +do + local below = tower.rivalScript(14) + local left = tower.rivalScript(15) + local wBelow, wLeft = findWalk(below), findWalk(left) + check(wBelow ~= nil, "player-below script has walk_npc") + check(wLeft ~= nil, "rival-on-left script has walk_npc") + eq(wBelow[2], 1, "walk targets object index 1 (rival)") + check(dirsEqual(wBelow[3], RIGHT_THEN_DOWN), + "player at x=14 uses RightThenDownMovement") + check(dirsEqual(wLeft[3], DOWN_THEN_RIGHT), + "player at x=15 uses DownThenRightMovement") +end + +-- (2) win path: flag, defeat text, walk, hide -- in order +do + local rows = tower.rivalScript(14) + local preds = { + { "set_flag EVENT_BEAT_POKEMON_TOWER_RIVAL", + function(r) return r[1] == "set_flag" + and r[2] == "EVENT_BEAT_POKEMON_TOWER_RIVAL" end }, + { "show_text DefeatedText", + function(r) return r[1] == "show_text" + and r[2] == "_PokemonTower2FRivalDefeatedText" end }, + { "walk_npc exit", + function(r) return r[1] == "walk_npc" end }, + { "hide_object POKEMONTOWER2F_RIVAL", + function(r) return r[1] == "hide_object" + and r[2] == "POKEMON_TOWER_2F" + and r[3] == "POKEMONTOWER2F_RIVAL" end }, + } + local pi = 1 + for _, r in ipairs(rows) do + if pi <= #preds and preds[pi][2](r) then pi = pi + 1 end + end + for i = 1, #preds do + check(i < pi, "rival script has, in order: " .. preds[i][1]) + end +end + +-- (3) onStep: only the encounter coords, and not after the beat flag +do + local ran + local game = { save = { flags = {} }, data = {} } + local ow = { + runner = { + isRunning = function() return false end, + run = function(_, rows) ran = rows end, + }, + player = { facing = "down" }, + npcByIndex = function() return { def = { name = "POKEMONTOWER2F_RIVAL" } } end, + } + check(tower.onStep(game, ow, 15, 5), "onStep fires on (15,5)") + check(ran ~= nil and findWalk(ran) ~= nil, "onStep runs exit-walk script") + check(dirsEqual(findWalk(ran)[3], DOWN_THEN_RIGHT), + "onStep (15,5) picks DownThenRight") + eq(ow.player.facing, "left", "onStep (15,5) faces the rival") + + ran = nil + check(tower.onStep(game, ow, 14, 6), "onStep fires on (14,6)") + check(dirsEqual(findWalk(ran)[3], RIGHT_THEN_DOWN), + "onStep (14,6) picks RightThenDown") + + check(not tower.onStep(game, ow, 14, 5), "onStep ignores rival's own tile") + game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL = true + check(not tower.onStep(game, ow, 15, 5), "beaten: onStep is inert") +end + +-- (4) onEnter hides a stuck rival once the beat flag is set +do + local Commands = require("src.script.Commands") + local hidden = {} + local realHide = Commands.hide_object + Commands.hide_object = function(_, mapId, name) + hidden[#hidden + 1] = { mapId, name } + end + + tower.onEnter({ save = { flags = {} } }, {}) + eq(#hidden, 0, "onEnter: no hide while unbeaten") + + tower.onEnter({ + save = { flags = { EVENT_BEAT_POKEMON_TOWER_RIVAL = true } }, + }, {}) + eq(#hidden, 1, "onEnter: hide once beaten") + eq(hidden[1][1], "POKEMON_TOWER_2F", "onEnter hide map") + eq(hidden[1][2], "POKEMONTOWER2F_RIVAL", "onEnter hide object") + + Commands.hide_object = realHide +end + +S.finish() diff --git a/tests/rom_importer_cursor_test.lua b/tests/rom_importer_cursor_test.lua new file mode 100644 index 00000000..21e8ad13 --- /dev/null +++ b/tests/rom_importer_cursor_test.lua @@ -0,0 +1,39 @@ +-- #114: launcher must restore the arrow cursor when leaving for boot. +-- Self-contained: `luajit tests/rom_importer_cursor_test.lua`; also dofile'd +-- by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer cursor") +local eq = S.eq + +local currentCursor = "arrow" +love.mouse.isCursorSupported = function() return true end +love.mouse.getSystemCursor = function(name) return name end +love.mouse.setCursor = function(c) currentCursor = c or "arrow" end + +local RomImporter = require("src.import.RomImporter") + +local booted = nil +local ri = setmetatable({ + android = false, + workState = nil, + ready = { red = true, blue = false }, + onComplete = function(version) booted = version end, +}, RomImporter) + +-- Simulate leaving Play while the hand cursor is still active (hover). +currentCursor = "hand" +ri:play("red") +eq(booted, "red", "play boots the chosen version") +eq(currentCursor, "arrow", "play restores the arrow cursor before boot") + +-- Android / unsupported cursors must not error. +booted = nil +currentCursor = "hand" +ri.android = true +ri:play("red") +eq(booted, "red", "android play still boots") +eq(currentCursor, "hand", "android play leaves the cursor alone") + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 18028b32..19948154 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2672,6 +2672,9 @@ end -- ---------------------------------------------- input hold regressions runSuites({ "tests/input_hold_test.lua" }) +-- ---------------------------------------------- launcher cursor (#114) +runSuites({ "tests/rom_importer_cursor_test.lua" }) + -- ---------------------------------------------- parity workstream tests -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- error()s if any assertion fails). Globbed, so dropping a new parity