Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)
Enable Route 35, Route 36, Route 37, the National Park and its gates, Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's room and both floors of the Burned Tower. The story runs from Floria, the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the rival in the Burned Tower and the beasts' release to Ecruteak Gym, Morty, the FOGBADGE and TM30. Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons, CheckEncounterRoamMon) with their state in the save; only Continue moves them on restore, so a failed import's rollback keeps them in place. Port the Bug-Catching Contest: registration on contest days, Park Balls, contestants, the timer, QUIT, the catch-and-swap prompt, judging from the ROM's contestant table, prizes and the saved contest state. Port the Game Corner's SlotMachine and CardFlip on a new realtime tile screen that advances two frames per tick with buffered buttons: reel strips and scrolling, bias tables, the seven streak, Golem and Chansey, payouts, the Card Flip board, cursor and navigation. Their tilemaps are checked against the ROM's own data. Also port getnum, a plain warp that continues its script on the new map, and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the Burned Tower rival) is no longer sighted once its own flag is set, as the original's read of bit 1 of wNumKeyItems gives with the usual key items. The playthrough walks through the National Park and continues to Morty; its driver chooses moves by type matchup, buys at Marts and uses Super Potions and Awakenings in trainer battles. New tests cover roaming, the contest, the Game Corner and Ecruteak's side content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0930b780fc
commit
2640fa2725
31 files changed
+4743
-91
No files matched your search
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { Playthrough } from "./playthrough-driver";
|
||||
import { findPath } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
@@ -42,7 +43,22 @@ const ROUTE_32 = "10:1",
|
||||
GOLDENROD_CENTER = "11:20",
|
||||
GOLDENROD_GYM = "11:3",
|
||||
BIKE_SHOP = "11:4",
|
||||
FLOWER_SHOP = "11:8",
|
||||
RADIO_TOWER_1F = "3:17";
|
||||
const ROUTE_35 = "10:2",
|
||||
ROUTE_35_NATIONAL_PARK_GATE = "10:15",
|
||||
NATIONAL_PARK = "3:15",
|
||||
ROUTE_36_NATIONAL_PARK_GATE = "10:17",
|
||||
ROUTE_35_GATE = "10:14",
|
||||
ROUTE_36 = "10:3",
|
||||
ROUTE_37 = "10:4",
|
||||
ECRUTEAK = "4:9",
|
||||
ECRUTEAK_CENTER = "4:3",
|
||||
ECRUTEAK_MART = "4:6",
|
||||
ECRUTEAK_GYM = "4:7",
|
||||
DANCE_THEATER = "4:5",
|
||||
BURNED_TOWER_1F = "3:13",
|
||||
BURNED_TOWER_B1F = "3:14";
|
||||
const MIRACLE_SEED = 0x75,
|
||||
LURE_BALL = 0xa0,
|
||||
HM01 = 0xf3,
|
||||
@@ -68,6 +84,27 @@ const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
|
||||
EVENT_GOT_TM31_MUD_SLAP = 0x8,
|
||||
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
|
||||
ENGINE_ZEPHYRBADGE = 0x1b;
|
||||
const SQUIRTBOTTLE = 0xaf;
|
||||
const EVENT_MET_FLORIA = 0xb9,
|
||||
EVENT_GOT_SQUIRTBOTTLE = 0x5c,
|
||||
EVENT_FOUGHT_SUDOWOODO = 0x2a,
|
||||
EVENT_GOT_HM03_SURF = 0x12,
|
||||
EVENT_RIVAL_BURNED_TOWER = 0x6c5,
|
||||
EVENT_HOLE_IN_BURNED_TOWER = 0x332;
|
||||
const EVENT_RELEASED_THE_BEASTS = 0x7b,
|
||||
EVENT_BURNED_TOWER_MORTY = 0x764,
|
||||
EVENT_ECRUTEAK_GYM_GRAMPS = 0x7a8,
|
||||
EVENT_ECRUTEAK_CITY_GRAMPS = 0x7a9,
|
||||
EVENT_BEAT_SAGE_JEFFREY = 0x415,
|
||||
EVENT_BEAT_SAGE_PING = 0x416,
|
||||
EVENT_BEAT_MEDIUM_MARTHA = 0x58b,
|
||||
EVENT_BEAT_MEDIUM_GRACE = 0x58c,
|
||||
EVENT_BEAT_MORTY = 0x4c0,
|
||||
EVENT_GOT_TM30_SHADOW_BALL = 0xc,
|
||||
ENGINE_FOGBADGE = 30,
|
||||
RAIKOU = 0xf3,
|
||||
ENTEI = 0xf4,
|
||||
ROUTE_42 = "2:5";
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -98,6 +135,7 @@ describe.skipIf(!existsSync(romPath))(
|
||||
choose,
|
||||
choosePack,
|
||||
talkTo,
|
||||
buy,
|
||||
} = p;
|
||||
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
|
||||
const restInViolet = () => {
|
||||
@@ -108,6 +146,25 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(ROUTE_31, "right");
|
||||
};
|
||||
|
||||
/** Route 35 and Route 36 meet only through the National Park and
|
||||
* its two gates (Route 36's Sudowoodo still blocks the way from
|
||||
* Violet City). */
|
||||
const toRoute36 = () => {
|
||||
stairs(ROUTE_35_GATE);
|
||||
stairs(ROUTE_35);
|
||||
stairs(ROUTE_35_NATIONAL_PARK_GATE);
|
||||
stairs(NATIONAL_PARK);
|
||||
stairs(ROUTE_36_NATIONAL_PARK_GATE);
|
||||
stairs(ROUTE_36);
|
||||
};
|
||||
const toGoldenrod = () => {
|
||||
stairs(ROUTE_36_NATIONAL_PARK_GATE);
|
||||
stairs(NATIONAL_PARK);
|
||||
stairs(ROUTE_35_NATIONAL_PARK_GATE);
|
||||
stairs(ROUTE_35);
|
||||
stairs(ROUTE_35_GATE);
|
||||
stairs(GOLDENROD);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
p.log.length = 0;
|
||||
@@ -559,5 +616,184 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(GOLDENROD);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("meets Floria on Route 36 and gets the SquirtBottle from the flower shop", () => {
|
||||
traceFailures();
|
||||
toRoute36();
|
||||
// ROUTE36_FLORIA is the eighth object in Route36_MapEvents.
|
||||
talkTo(p.world.map.objects[7].id);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_MET_FLORIA)).toBe(true);
|
||||
toGoldenrod();
|
||||
enter(FLOWER_SHOP, "up");
|
||||
// Floria walked here from Route 36; talk to her first
|
||||
// (EVENT_TALKED_TO_FLORIA_AT_FLOWER_SHOP), then the Teacher gives
|
||||
// the SquirtBottle since the PLAINBADGE is already held.
|
||||
talkTo(p.world.map.objects[1].id);
|
||||
play();
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
// The Squirtbottle is a key item (ItemAttributes' pocket byte).
|
||||
expect(p.world.state.bag.keyItems).toContain(SQUIRTBOTTLE);
|
||||
expect(p.world.state.events.has(EVENT_GOT_SQUIRTBOTTLE)).toBe(true);
|
||||
exit(GOLDENROD);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("waters the weird tree on Route 36 and battles Sudowoodo, then crosses Route 37 to Ecruteak City", () => {
|
||||
traceFailures();
|
||||
toRoute36();
|
||||
const tree = p.world.map.objects[2].id; // ROUTE36_WEIRD_TREE
|
||||
talkTo(tree);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_FOUGHT_SUDOWOODO)).toBe(true);
|
||||
cross("up", ROUTE_37);
|
||||
cross("up", ECRUTEAK);
|
||||
expect(p.world.mapId).toBe(ECRUTEAK);
|
||||
heal(ECRUTEAK_CENTER, ECRUTEAK);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("beats all five Kimono Girls for HM03 Surf from the Dance Theater's gentleman", () => {
|
||||
traceFailures();
|
||||
enter(DANCE_THEATER, "up");
|
||||
// The five Kimono Girls are objects 2 through 6 (file order); the
|
||||
// gentleman who gives Surf is object 7. An already-beaten Kimono
|
||||
// Girl's trainer header just shows her after-text, so healing
|
||||
// between fights (five single-Pokémon trainers in a row otherwise
|
||||
// wear the lead down) is safe to repeat.
|
||||
for (let id = 2; id <= 6; id++) {
|
||||
if (lead().hp < lead().stats[0]) {
|
||||
exit(ECRUTEAK);
|
||||
heal(ECRUTEAK_CENTER, ECRUTEAK);
|
||||
enter(DANCE_THEATER, "up");
|
||||
}
|
||||
talkTo(id);
|
||||
play();
|
||||
}
|
||||
talkTo(7);
|
||||
play([1]);
|
||||
expect(p.world.state.bag.tmsHms[52]).toBe(1); // HM03 (50 TMs + HM03 - 1)
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM03_SURF)).toBe(true);
|
||||
exit(ECRUTEAK);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("meets Eusine and the rival at the Burned Tower, falling to the basement", () => {
|
||||
traceFailures();
|
||||
// Stock up on Super Potions first: the rival's Croconaw hits the
|
||||
// Fire-type lead for super-effective damage on top of Haunter's
|
||||
// Curse, so a careful player restocks before a hard fight rather
|
||||
// than going in on whatever is left from Route 36.
|
||||
enter(ECRUTEAK_MART, "up");
|
||||
const money = p.world.state.money;
|
||||
buy(p.world.map.objects[0].id, C.SUPER_POTION, 10);
|
||||
expect(p.world.state.money).toBe(money - 700 * 10);
|
||||
expect(p.world.state.bag.items).toContainEqual({ item: C.SUPER_POTION, quantity: 10 });
|
||||
exit(ECRUTEAK);
|
||||
enter(BURNED_TOWER_1F, "up");
|
||||
// BurnedTower1FMeetEusineScene runs automatically on the first idle
|
||||
// tick (sdefer), as Ecruteak's Pokémon Center's Bill scene does.
|
||||
expect(p.log.join(" ")).toContain("EUSINE: My name's");
|
||||
// The only way to any tile by the rival crosses the coordinate event
|
||||
// at (11, 9); walking there triggers BurnedTowerRivalBattleScript,
|
||||
// which ends with the player falling to the basement, so the usual
|
||||
// walk() helper (which expects to stay on this map unless stepping
|
||||
// onto a listed warp) does not fit here.
|
||||
// A loss whites out (healed) to Ecruteak's spawn point outside the
|
||||
// Pokémon Center with the scene still set, so the player tries again.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
for (const direction of findPath(p.world, 11, 9)) {
|
||||
if (!p.world.move(direction)) break;
|
||||
play();
|
||||
if (p.world.mapId !== BURNED_TOWER_1F) break;
|
||||
}
|
||||
if (p.world.mapId === BURNED_TOWER_B1F) break;
|
||||
expect(p.world.mapId, "whited out to Ecruteak").toBe(ECRUTEAK);
|
||||
enter(BURNED_TOWER_1F, "up");
|
||||
}
|
||||
expect(p.world.state.events.has(EVENT_RIVAL_BURNED_TOWER)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_HOLE_IN_BURNED_TOWER)).toBe(true);
|
||||
expect(p.world.mapId).toBe(BURNED_TOWER_B1F);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("releases the beasts, says goodbye to Eusine and climbs back to Ecruteak", () => {
|
||||
traceFailures();
|
||||
// The coordinate event at (10, 6) runs ReleaseTheBeasts: the three
|
||||
// beasts flee one by one through InitRoamMons, which starts Raikou
|
||||
// and Entei roaming (Suicune stays put until Cianwood).
|
||||
walk(10, 6);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_RELEASED_THE_BEASTS)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BURNED_TOWER_MORTY)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_ECRUTEAK_GYM_GRAMPS)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_ECRUTEAK_CITY_GRAMPS)).toBe(false);
|
||||
expect(p.world.state.roamMons[0]).toMatchObject({ species: RAIKOU, mapId: ROUTE_42 });
|
||||
expect(p.world.state.roamMons[1]).toMatchObject({ species: ENTEI, mapId: ROUTE_37 });
|
||||
// Eusine appears once the beasts are gone (BurnedTowerB1F_EUSINE's
|
||||
// own flag, set by `appear` rather than a scene variable) and says
|
||||
// goodbye before leaving through the tower's own exit sound.
|
||||
talkTo(p.world.map.objects[8].id);
|
||||
play();
|
||||
expect(p.log.join(" ")).toContain("I'm all choked up!");
|
||||
// Multiple small stairs lead back to 1F now that the ladder at (6,
|
||||
// 14) is in place (BurnedTowerB1FLadderCallback); any of them works.
|
||||
stairs(BURNED_TOWER_1F);
|
||||
exit(ECRUTEAK);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("beats Ecruteak Gym's trainers and Morty for the FOGBADGE and TM30", () => {
|
||||
traceFailures();
|
||||
// Morty's own Gengar leans on Hypnosis and Dream Eater; a careful
|
||||
// player also stocks Awakenings, not just Super Potions, before a
|
||||
// Ghost-type gym.
|
||||
enter(ECRUTEAK_MART, "up");
|
||||
buy(p.world.map.objects[0].id, C.AWAKENING, 5);
|
||||
expect(p.world.state.bag.items).toContainEqual({ item: C.AWAKENING, quantity: 5 });
|
||||
exit(ECRUTEAK);
|
||||
// EcruteakGymForcedToLeaveScene (the default scene) no longer plays:
|
||||
// ReleaseTheBeasts already set SCENE_ECRUTEAKGYM_NOOP for this map.
|
||||
enter(ECRUTEAK_GYM, "up");
|
||||
// The trainers stand past Morty's own invisible-floor maze; findPath
|
||||
// already treats every unlisted pit tile as impassable (the same
|
||||
// warps the player would fall through), so it finds the real floor
|
||||
// on its own, the way a player who knows the safe path would.
|
||||
const [morty, jeffrey, ping, martha, grace] = p.world.map.objects.map(
|
||||
(object) => object.id,
|
||||
);
|
||||
// Ghost types shrug off Normal moves entirely ("No normal-type
|
||||
// attack can harm them!", straight from Sage Ping's own text), so
|
||||
// EMBER is the lead's only way to damage any of them; a cautious
|
||||
// player heals between fights (restoring its PP, not just HP) so
|
||||
// a long multi-Pokémon trainer never finds EMBER run dry.
|
||||
for (const trainer of [jeffrey, ping, martha, grace]) {
|
||||
exit(ECRUTEAK);
|
||||
heal(ECRUTEAK_CENTER, ECRUTEAK);
|
||||
enter(ECRUTEAK_GYM, "up");
|
||||
talkTo(trainer);
|
||||
play();
|
||||
}
|
||||
expect(p.world.state.events.has(EVENT_BEAT_SAGE_JEFFREY)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_SAGE_PING)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_MEDIUM_MARTHA)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_MEDIUM_GRACE)).toBe(true);
|
||||
exit(ECRUTEAK);
|
||||
heal(ECRUTEAK_CENTER, ECRUTEAK);
|
||||
enter(ECRUTEAK_GYM, "up");
|
||||
talkTo(morty);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_MORTY), p.log.join("|")).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_FOGBADGE)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("FOGBADGE");
|
||||
// MortyText_FogBadgeSpeech and the TM30 Shadow Ball gift follow in
|
||||
// the same conversation (EVENT_GOT_TM30_SHADOW_BALL was not yet set).
|
||||
expect(p.world.state.events.has(EVENT_GOT_TM30_SHADOW_BALL)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[29]).toBe(1); // TM30
|
||||
expect(p.log.join(" ")).toContain("SHADOW BALL");
|
||||
exit(ECRUTEAK);
|
||||
reload();
|
||||
}, 240_000);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user