mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2aa9ce637 | |||
| d683be205b | |||
| d2f6735b0a | |||
| bfa68e6f7d |
Binary file not shown.
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 753 KiB |
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user