Compare commits

...

4 Commits

Author SHA1 Message Date
bryanthaboi a2aa9ce637 Merge origin/main into squadhing-som-bug
Keep elevator seed-exit helpers and blocking play_once across the squashy squash merge.
2026-07-23 16:54:48 -04:00
bryanthaboi d683be205b brock guy fix
CLOSES #39
2026-07-23 16:47:42 -04:00
bryanthaboi d2f6735b0a squassshheee
CLOSES #128
CLOSES #114
CLOSES #113
CLOSES #111
CLOSES #110
CLOSES #109
CLOSES #107
CLOSES #106
CLOSES #105
CLOSES #90
CLOSES #103
2026-07-23 16:11:27 -04:00
bryanthaboi bfa68e6f7d squashy squash 2026-07-23 15:39:35 -04:00
40 changed files with 1563 additions and 215 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 753 KiB

+5 -18
View File
@@ -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,
},
}
+16 -12
View File
@@ -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
},
},
},
+45 -25
View File
@@ -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)
+15 -7
View File
@@ -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
},
},
}
+48 -15
View File
@@ -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,
}
+141 -33
View File
@@ -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 +
+167 -25
View File
@@ -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,
}
+11 -3
View File
@@ -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) },
+7
View File
@@ -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
+6 -9
View File
@@ -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)
+34
View File
@@ -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,
+15 -2
View File
@@ -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
+6 -2
View File
@@ -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 = <bundled file path>, 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
+20 -2
View File
@@ -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
+85 -23
View File
@@ -438,10 +438,18 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); the map
-- theme resumes when it ends (Music.playOnce / pendingRestore)
-- play_once <songId>: 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 <species>: 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
+2 -1
View File
@@ -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
+23 -4
View File
@@ -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")
+25 -5
View File
@@ -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 = <path>, 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
+2 -1
View File
@@ -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.
+16 -7
View File
@@ -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
+7
View File
@@ -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
+2 -15
View File
@@ -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
+6 -1
View File
@@ -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 }
+5
View File
@@ -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)
+27
View File
@@ -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
+26
View File
@@ -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
+32
View File
@@ -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" } },
+9
View File
@@ -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()
+13 -2
View File
@@ -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
+18
View File
@@ -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()
+49
View File
@@ -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()
+37 -3
View File
@@ -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")
+129
View File
@@ -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()
+100
View File
@@ -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()
+117
View File
@@ -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()
+117
View File
@@ -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()
+138
View File
@@ -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()
+39
View File
@@ -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()
+3
View File
@@ -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