Open Kanto: the S.S. Aqua, the Kanto gyms, the Magnet Train, Mt. Silver and Red (Closes #33)
All 146 Kanto maps, Route 22, Route 28 and Silver Cave are open. The Magnet Train, Snorlax and the Poké Flute channel, the Diploma, the Trainer House, Bill's grandpa, Oak's Pokédex rating in Pallet and Goldenrod's postgame move tutor are ported; big objects cover 2x2 tiles. The Magnet Train and the Diploma are compared with the cartridge frame by frame. The playthrough continues from the Hall of Fame through the S.S. Aqua, the eight Kanto badges, Oak opening Mt. Silver and Red's defeat and credits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f05fd8ebee
commit
d3be732118
22 files changed
+1877
-31
No files matched your search
@@ -0,0 +1,321 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { snorlaxAwake } from "../src/game/scripts/kanto";
|
||||
import { TEARDOWN_FRAMES } from "../src/game/scripts/magnet-train";
|
||||
import { SOUND } from "../src/game/audio/constants";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Kanto with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const VERMILION = "12:3",
|
||||
BILLS_HOUSE = "7:11",
|
||||
TRAINER_HOUSE_B1F = "23:7",
|
||||
SAFFRON_STATION = "25:9",
|
||||
GOLDENROD_STATION = "11:7",
|
||||
CELADON_MANSION_3F = "21:14",
|
||||
OAKS_LAB = "13:6",
|
||||
GOLDENROD = "11:2",
|
||||
MOUNT_MOON = "3:85",
|
||||
INDIGO_PLATEAU_CENTER = "16:2";
|
||||
const LICKITUNG = 108,
|
||||
EVERSTONE = 0x70,
|
||||
PASS = 0x86,
|
||||
EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA = 0x320,
|
||||
EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA = 0x31b,
|
||||
EVENT_ENABLE_DIPLOMA_PRINTING = 0xd6,
|
||||
EVENT_RESTORED_POWER_TO_KANTO = 0xcd,
|
||||
EVENT_OPENED_MT_SILVER = 0x74f,
|
||||
EVENT_TALKED_TO_OAK_IN_KANTO = 0xe1,
|
||||
EVENT_BEAT_ELITE_FOUR = 0x44,
|
||||
COIN_CASE = 0x36,
|
||||
PIKACHU = 25,
|
||||
THUNDERBOLT = 85,
|
||||
ENGINE_DAILY_MOVE_TUTOR = 94,
|
||||
EVENT_BEAT_RIVAL_IN_MT_MOON = 0x319;
|
||||
const world = (date = "2026-09-22T12:00:00") => {
|
||||
let seed = 33;
|
||||
const game = new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => new Date(date),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
game.state.party = [mon(game, 157, 60)];
|
||||
return game;
|
||||
};
|
||||
const mon = (game: GameWorld, species: number, level = 30) =>
|
||||
generatePartyMon(game.assets.pokemon, {
|
||||
species,
|
||||
level,
|
||||
item: 0,
|
||||
otId: game.playerId,
|
||||
ot: "KAWA",
|
||||
dvs: 0x8888,
|
||||
caughtData: 0,
|
||||
});
|
||||
/** Stand at (x, y) on `map` facing `face`, press A and answer. */
|
||||
const talk = (
|
||||
game: GameWorld,
|
||||
map: string,
|
||||
x: number,
|
||||
y: number,
|
||||
face: "up" | "down" | "left" | "right",
|
||||
answers: (number | string)[] = [],
|
||||
) => {
|
||||
game.warpTo(map, x, y);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x, y, direction: face });
|
||||
game.interact();
|
||||
const shown = settle(game, answers).join(" ");
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
return shown;
|
||||
};
|
||||
/** Run scripts until a battle starts, answering questions with YES. */
|
||||
const untilBattle = (game: GameWorld) => {
|
||||
const shown: string[] = [];
|
||||
for (let tick = 0; tick < 3000 && !game.battle; tick++) {
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
const dialogue = game.dialogue;
|
||||
if (dialogue) {
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (!shown.includes(dialogue.pages[dialogue.index])) shown.push(dialogue.pages[dialogue.index]);
|
||||
if (last && dialogue.choice) game.choose(1);
|
||||
else if (last && dialogue.holdTicks !== undefined) game.tick();
|
||||
else game.advanceDialogue();
|
||||
} else game.tick();
|
||||
}
|
||||
return shown.join(" ");
|
||||
};
|
||||
/** Run a screen that plays on its own to its end, counting its frames. */
|
||||
const watch = (game: GameWorld) => {
|
||||
let frames = 0;
|
||||
for (let tick = 0; tick < 2000 && game.dialogue?.choice?.kind === "screen"; tick++) {
|
||||
frames += 2;
|
||||
game.tick();
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
describe("Vermilion's Snorlax", () => {
|
||||
it("wakes only to the Poké Flute channel, from beside it", () => {
|
||||
const r = new CrystalAssets(bytes).reader;
|
||||
const flute = SOUND.MUSIC_POKE_FLUTE_CHANNEL;
|
||||
for (const [x, y] of [[33, 8], [34, 10], [35, 10], [36, 8], [36, 9]])
|
||||
expect(snorlaxAwake(r, flute, { x, y })).toBe(1);
|
||||
expect(snorlaxAwake(r, flute, { x: 34, y: 11 })).toBe(0);
|
||||
expect(snorlaxAwake(r, SOUND.MUSIC_NONE, { x: 34, y: 10 })).toBe(0);
|
||||
});
|
||||
it("covers two tiles by two and snores until the flute plays", () => {
|
||||
const game = world();
|
||||
// The big Snorlax at (34, 8) also fills (35, 9): no step there.
|
||||
game.warpTo(VERMILION, 36, 9);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 36, y: 9, direction: "left" });
|
||||
game.move("left");
|
||||
settle(game);
|
||||
expect({ x: game.x, y: game.y }).toEqual({ x: 36, y: 9 });
|
||||
expect(talk(game, VERMILION, 34, 10, "up")).toContain("SNORLAX is snoring\npeacefully…");
|
||||
// The Pokégear's radio left on the Poké Flute channel (wMapMusic).
|
||||
game.restore({ ...game.save(), x: 34, y: 10, direction: "up" });
|
||||
game.sound.engine.mapMusic = SOUND.MUSIC_POKE_FLUTE_CHANNEL;
|
||||
game.interact();
|
||||
expect(untilBattle(game)).toContain("SNORLAX");
|
||||
expect(game.battle?.engine.enemy.mon.species).toBe(143);
|
||||
expect(game.battle?.engine.enemy.mon.level).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
it("has Bill's grandpa take a look at a Lickitung for the Everstone", () => {
|
||||
const game = world();
|
||||
game.state.party.push(mon(game, LICKITUNG));
|
||||
// YES to show him, then the party's second Pokémon.
|
||||
const shown = talk(game, BILLS_HOUSE, 2, 4, "up", [1, 1]);
|
||||
expect(shown).toContain("Hm? You know BILL?");
|
||||
expect(shown).toContain("LICKITUNG");
|
||||
expect(game.state.events.has(EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA)).toBe(true);
|
||||
expect(game.state.events.has(EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA)).toBe(true);
|
||||
expect(game.state.bag.items.some((slot) => slot.item === EVERSTONE)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the Trainer House's default trainer, CAL", () => {
|
||||
const game = world();
|
||||
// The receptionist's coordinate event at (7, 3), from the stairs' side.
|
||||
game.warpTo(TRAINER_HOUSE_B1F, 8, 3);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 8, y: 3, direction: "left" });
|
||||
expect(game.move("left")).toBe(true);
|
||||
// TrainerHouse returns 0 without a Mystery Gift partner: CAL3.
|
||||
expect(untilBattle(game)).toContain("CAL");
|
||||
expect(game.battle?.engine.enemy.mon).toBeDefined();
|
||||
});
|
||||
|
||||
it("rides the Magnet Train both ways with the PASS once the power is back", () => {
|
||||
const game = world();
|
||||
game.state.bag.keyItems.push(PASS);
|
||||
game.state.events.add(EVENT_RESTORED_POWER_TO_KANTO);
|
||||
// Saffron's officer at (9, 9): YES, then the ride to Goldenrod.
|
||||
game.warpTo(SAFFRON_STATION, 9, 10);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 9, y: 10, direction: "up" });
|
||||
game.interact();
|
||||
for (let tick = 0; tick < 400 && game.dialogue?.choice?.kind !== "screen"; tick++) {
|
||||
if (game.dialogue?.choice) game.choose(1);
|
||||
else if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
expect(game.dialogue?.choice?.kind).toBe("screen");
|
||||
const frames = watch(game);
|
||||
// The first run through, its palette frame, the ride and the teardown.
|
||||
expect(frames).toBeGreaterThan(300 + TEARDOWN_FRAMES);
|
||||
settle(game);
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
expect(game.mapId).toBe(GOLDENROD_STATION);
|
||||
// And back from Goldenrod's officer.
|
||||
game.restore({ ...game.save(), x: 9, y: 10, direction: "up" });
|
||||
game.interact();
|
||||
for (let tick = 0; tick < 400 && game.dialogue?.choice?.kind !== "screen"; tick++) {
|
||||
if (game.dialogue?.choice) game.choose(1);
|
||||
else if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
watch(game);
|
||||
settle(game);
|
||||
expect(game.mapId).toBe(SAFFRON_STATION);
|
||||
});
|
||||
|
||||
it("shows the Diploma for a complete Pokédex, then cannot print it", () => {
|
||||
const game = world();
|
||||
for (let species = 1; species <= 251; species++) {
|
||||
game.state.pokedex.seen.add(species);
|
||||
game.state.pokedex.caught.add(species);
|
||||
}
|
||||
// The game designer at (3, 6), from below.
|
||||
game.warpTo(CELADON_MANSION_3F, 3, 7);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 3, y: 7, direction: "up" });
|
||||
game.interact();
|
||||
let diploma = "";
|
||||
for (let tick = 0; tick < 400; tick++) {
|
||||
const choice = game.dialogue?.choice;
|
||||
if (choice?.kind === "screen") {
|
||||
diploma = choice.screen.label;
|
||||
game.choose("a");
|
||||
break;
|
||||
}
|
||||
if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
expect(diploma).toBe(`Diploma: ${game.name}`);
|
||||
settle(game);
|
||||
expect(game.state.events.has(EVENT_ENABLE_DIPLOMA_PRINTING)).toBe(true);
|
||||
// The graphic artist at (3, 4) prints it: no printer, B cancels.
|
||||
game.restore({ ...game.save(), x: 3, y: 5, direction: "up" });
|
||||
game.interact();
|
||||
let printer = "";
|
||||
for (let tick = 0; tick < 400; tick++) {
|
||||
const choice = game.dialogue?.choice;
|
||||
if (choice?.kind === "yesno") game.choose(1);
|
||||
else if (choice?.kind === "screen") {
|
||||
printer = choice.screen.label;
|
||||
game.choose("b");
|
||||
break;
|
||||
} else if (game.dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
expect(printer).toMatch(/B/);
|
||||
settle(game);
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
});
|
||||
|
||||
it("has Professor Oak open Mt. Silver for sixteen badges and rate the Pokédex", () => {
|
||||
const game = world();
|
||||
for (let badge = 0x1b; badge < 0x1b + 16; badge++) game.state.flags.add(badge);
|
||||
for (const species of [1, 4, 7, 25]) game.state.pokedex.seen.add(species);
|
||||
game.state.pokedex.caught.add(25);
|
||||
// Oak at (4, 2), from below.
|
||||
const shown = talk(game, OAKS_LAB, 4, 3, "up");
|
||||
expect(game.state.events.has(EVENT_TALKED_TO_OAK_IN_KANTO)).toBe(true);
|
||||
expect(game.state.events.has(EVENT_OPENED_MT_SILVER)).toBe(true);
|
||||
expect(shown).toContain("4 POKéMON seen\n1 POKéMON owned");
|
||||
expect(shown).toContain("Look for POKéMON\nin grassy areas!");
|
||||
});
|
||||
it("has Goldenrod's move tutor teach Thunderbolt for 4000 coins on a Wednesday after the League", () => {
|
||||
// 2026-09-23 is a Wednesday.
|
||||
const game = world("2026-09-23T12:00:00");
|
||||
game.state.party.push(mon(game, PIKACHU, 5));
|
||||
game.state.events.add(EVENT_BEAT_ELITE_FOUR);
|
||||
game.state.bag.keyItems.push(COIN_CASE);
|
||||
game.state.coins = 5000;
|
||||
// The tutor at (12, 22), from below.
|
||||
game.warpTo(GOLDENROD, 12, 23);
|
||||
settle(game);
|
||||
expect(game.objects().some((object) => object.x === 12 && object.y === 22)).toBe(true);
|
||||
game.restore({ ...game.save(), x: 12, y: 23, direction: "up" });
|
||||
game.interact();
|
||||
// YES, YES to 4000 coins, THUNDERBOLT, then Typhlosion (NOT ABLE:
|
||||
// back to the menu) and Pikachu.
|
||||
const party = [0, 1],
|
||||
details: string[][] = [];
|
||||
for (let tick = 0; tick < 3000 && game.busy; tick++) {
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
const dialogue = game.dialogue;
|
||||
const last = dialogue && dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice?.kind === "yesno") game.choose(1);
|
||||
else if (last && dialogue.choice?.kind === "menu" && dialogue.choice.items.includes("THUNDERBOLT"))
|
||||
game.choose(dialogue.choice.items.indexOf("THUNDERBOLT"));
|
||||
else if (last && dialogue.choice?.kind === "menu") {
|
||||
details.push(dialogue.choice.details ?? []);
|
||||
game.choose(party.shift()!);
|
||||
} else if (dialogue) game.advanceDialogue();
|
||||
else game.tick();
|
||||
}
|
||||
expect(details).toEqual([
|
||||
["NOT ABLE", "ABLE"],
|
||||
["NOT ABLE", "ABLE"],
|
||||
]);
|
||||
const pikachu = game.state.party[1];
|
||||
expect(pikachu.moves).toContain(THUNDERBOLT);
|
||||
expect(game.state.coins).toBe(1000);
|
||||
expect(game.state.flags.has(ENGINE_DAILY_MOVE_TUTOR)).toBe(true);
|
||||
// Into the Game Corner: gone from the street.
|
||||
expect(game.objects().some((object) => object.x === 12 && object.y === 22)).toBe(false);
|
||||
});
|
||||
describe("the rival in Kanto", () => {
|
||||
it("waits in Mt. Moon", () => {
|
||||
const game = world();
|
||||
// MountMoonRivalEncounterScene, from the Route 3 entrance.
|
||||
game.warpTo(MOUNT_MOON, 3, 3);
|
||||
expect(untilBattle(game)).toContain(`It's been a while,\n${game.name}.`);
|
||||
expect(game.battle).toBeDefined();
|
||||
});
|
||||
// PlateauRivalBattle1 and 2: Mondays and Wednesdays, after Mt. Moon.
|
||||
for (const [date, day, fights] of [
|
||||
["2026-09-21T12:00:00", "a Monday", true],
|
||||
["2026-09-22T12:00:00", "a Tuesday", false],
|
||||
] as const)
|
||||
it(`${fights ? "battles" : "stays away"} at Indigo Plateau on ${day}`, () => {
|
||||
const game = world(date);
|
||||
game.state.events.add(EVENT_BEAT_RIVAL_IN_MT_MOON);
|
||||
game.warpTo(INDIGO_PLATEAU_CENTER, 16, 5);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x: 16, y: 5, direction: "up" });
|
||||
expect(game.move("up")).toBe(true);
|
||||
if (fights) {
|
||||
expect(untilBattle(game)).toContain("Hold it.");
|
||||
expect(game.battle).toBeDefined();
|
||||
} else {
|
||||
settle(game);
|
||||
expect(game.battle).toBeUndefined();
|
||||
expect({ x: game.x, y: game.y }).toEqual({ x: 16, y: 4 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user