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
@@ -47,7 +47,7 @@ test("the Map tab shows the Johto atlas with the player and open areas", async (
|
||||
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
|
||||
await expect(page.locator("#atlas-open")).toContainText("AZALEA TOWN");
|
||||
await expect(page.locator("#atlas-open")).toContainText("BLACKTHORN CITY");
|
||||
// Mt. Silver's cave belongs to Kanto's postgame (board #33).
|
||||
await expect(page.locator("#atlas-open")).not.toContainText("SILVER CAVE");
|
||||
// Mt. Silver's cave is on Johto's map, opened with Kanto's postgame.
|
||||
await expect(page.locator("#atlas-open")).toContainText("SILVER CAVE");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
+3
-4
@@ -102,9 +102,8 @@ describe.skipIf(!existsSync(romPath))("the region atlas", () => {
|
||||
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
|
||||
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
expect(at(LANDMARK_BLACKTHORN_CITY)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
// Mt. Silver's cave belongs to Kanto's postgame (board #33).
|
||||
expect(at(LANDMARK_SILVER_CAVE)).toMatchObject({ open: false, flypoint: { visited: false } });
|
||||
// Kanto stays closed, so asking for it shows Johto.
|
||||
expect(at(LANDMARK_SILVER_CAVE)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
// Before Indigo Plateau or a step into Kanto, asking for it shows Johto.
|
||||
expect(world.atlas("kanto").region).toBe("johto");
|
||||
});
|
||||
|
||||
@@ -114,7 +113,7 @@ describe.skipIf(!existsSync(romPath))("the region atlas", () => {
|
||||
const kanto = world.atlas("kanto");
|
||||
expect(kanto.region).toBe("kanto");
|
||||
expect(kanto.player).toBeUndefined();
|
||||
expect(kanto.landmarks[0]).toMatchObject({ landmark: LANDMARK_PALLET_TOWN, open: false });
|
||||
expect(kanto.landmarks[0]).toMatchObject({ landmark: LANDMARK_PALLET_TOWN, open: true });
|
||||
expect(kanto.landmarks).toHaveLength(OW.KANTO_LANDMARK_LAST - OW.KANTO_LANDMARK + 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { emptyScriptState } from "../src/game/scripts/interpreter";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import type { Direction } from "../src/game/world/types";
|
||||
import { settle } from "./world-driver";
|
||||
import { NATIVE_MAPS } from "../src/game/rom/profile";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
@@ -208,8 +209,15 @@ describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope",
|
||||
for (let press = 0; press < 4 && flyMap(world)?.name !== "SILVER CAVE"; press++)
|
||||
world.moveCursor("down");
|
||||
expect(flyMap(world)?.name).toBe("SILVER CAVE");
|
||||
world.choose("a");
|
||||
until(world);
|
||||
// Every flypoint is open now: close Mt. Silver's for this test.
|
||||
const silverCave = NATIVE_MAPS["19:2"];
|
||||
delete NATIVE_MAPS["19:2"];
|
||||
try {
|
||||
world.choose("a");
|
||||
until(world);
|
||||
} finally {
|
||||
NATIVE_MAPS["19:2"] = silverCave;
|
||||
}
|
||||
expect(world.notice).toBe("This destination is not ported yet.");
|
||||
close(world);
|
||||
expect([world.mapId, world.x, world.y]).toEqual([ROUTE_29, 50, 10]);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,12 @@ export class Playthrough {
|
||||
item: world.liveState.bag.balls[ball].item,
|
||||
slot: ball,
|
||||
});
|
||||
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||
else if (
|
||||
engine.battleMode === C.WILD_BATTLE &&
|
||||
mon.hp * 2 < mon.maxHp &&
|
||||
// A careful player trapped by Bind or Wrap fights back.
|
||||
!(this.careful && log.slice(-4).includes("Can't escape!"))
|
||||
)
|
||||
world.battleAction({ action: "run" });
|
||||
else if (
|
||||
engine.battleMode !== C.WILD_BATTLE &&
|
||||
@@ -524,8 +529,10 @@ export class Playthrough {
|
||||
this.walk(warp.x, warp.y);
|
||||
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
||||
if (world.mapId === from && exit && world.move(exit)) this.play();
|
||||
if (world.mapId !== from && this.route(target, maps, depth - 1, goal))
|
||||
return true;
|
||||
// A warp to another place on the same map lands at its partner.
|
||||
const arrival = warp.targetMap === from ? world.map.warps[warp.targetWarp - 1] : undefined;
|
||||
const moved = arrival ? world.x === arrival.x && world.y === arrival.y : world.mapId !== from;
|
||||
if (moved && this.route(target, maps, depth - 1, goal)) return true;
|
||||
world.restore(here);
|
||||
}
|
||||
return false;
|
||||
@@ -710,6 +717,74 @@ export class Playthrough {
|
||||
}
|
||||
this.play();
|
||||
};
|
||||
/** Face a tile (a hidden item) from a side reachable on foot or by
|
||||
* Surf and press A. From land a step towards it only turns the player
|
||||
* (a surfer would move onto it), so land sides come first. */
|
||||
inspect = (x: number, y: number) => {
|
||||
const world = this.world,
|
||||
land = (dx: number, dy: number) =>
|
||||
world.assets.permission(world.assets.collision(world.map, x + dx, y + dy)) === 0;
|
||||
const sides = (
|
||||
[
|
||||
["up", 0, 1],
|
||||
["down", 0, -1],
|
||||
["left", 1, 0],
|
||||
["right", -1, 0],
|
||||
] as const
|
||||
)
|
||||
.slice()
|
||||
.sort(([, ax, ay], [, bx, by]) => Number(!land(ax, ay)) - Number(!land(bx, by)));
|
||||
for (const [face, dx, dy] of sides) {
|
||||
try {
|
||||
findPath(world, x + dx, y + dy, { water: true, ledges: this.ledges });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
this.sail(x + dx, y + dy);
|
||||
if (world.save().direction !== face) world.move(face);
|
||||
world.interact();
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
throw new Error(`No side to inspect ${x},${y}`);
|
||||
};
|
||||
/** Ride up a downhill slope (ENGINE_DOWNHILL rolls an idle bike down,
|
||||
* so stopping between steps loses ground): the planned direction is held
|
||||
* on every tick to (x, y), then up until the map changes. */
|
||||
rideUp = (x: number, y: number) => {
|
||||
const world = this.world,
|
||||
map = world.mapId;
|
||||
for (let tick = 0; tick < 20_000 && world.mapId === map; tick++) {
|
||||
if (world.dialogue || world.battle) {
|
||||
this.play();
|
||||
continue;
|
||||
}
|
||||
const path = world.x === x && world.y === y ? [] : findPath(world, x, y);
|
||||
world.tick(path[0] ?? "up");
|
||||
}
|
||||
expect(world.mapId, `riding up ${map}`).not.toBe(map);
|
||||
this.play();
|
||||
};
|
||||
/** The Pokégear's radio card from the start menu: turn the knob to
|
||||
* `frequency`, then close the Pokégear and the menu; the station keeps
|
||||
* playing (ExitPokegearRadio_HandleMusic). */
|
||||
tuneRadio = (frequency: string) => {
|
||||
const world = this.world;
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
this.choose("POKéGEAR");
|
||||
for (let press = 0; press < 3 && world.pokegear?.card !== "radio"; press++) world.choose("right");
|
||||
expect(world.pokegear?.card).toBe("radio");
|
||||
for (let press = 0; press < 80 && world.pokegear?.radio?.frequency !== frequency; press++) {
|
||||
world.bufferScreenButton(press < 60 ? "up" : "down");
|
||||
world.tick();
|
||||
}
|
||||
expect(world.pokegear?.radio?.frequency).toBe(frequency);
|
||||
for (let tick = 0; tick < 30; tick++) world.tick();
|
||||
world.bufferScreenButton("b");
|
||||
for (let tick = 0; tick < 30 && world.pokegear; tick++) world.tick();
|
||||
if (world.dialogue?.choice?.kind === "menu") world.choose(-1);
|
||||
for (let tick = 0; tick < 30 && world.busy; tick++) world.tick();
|
||||
};
|
||||
/** Push Strength boulder `id` one tile in `direction` from its far
|
||||
* side: the first bump only turns the player, a boulder Strength is not
|
||||
* yet active for asks first (AskStrengthScript, then YES), and the
|
||||
|
||||
@@ -238,6 +238,98 @@ const HM07 = 0xf9,
|
||||
EVENT_BEAT_ELITE_4_KAREN = 0x5bb,
|
||||
EVENT_BEAT_CHAMPION_LANCE = 0x5bc,
|
||||
EVENT_BEAT_ELITE_FOUR = 0x44;
|
||||
const FAST_SHIP_1F = "15:3",
|
||||
FAST_SHIP_CABINS_NNW = "15:4",
|
||||
FAST_SHIP_CABINS_SE = "15:6",
|
||||
FAST_SHIP_B1F = "15:7",
|
||||
OLIVINE_PORT = "15:1",
|
||||
OLIVINE_PORT_PASSAGE = "15:8",
|
||||
VERMILION_PORT_PASSAGE = "15:9",
|
||||
VERMILION = "12:3",
|
||||
VERMILION_CENTER = "12:5",
|
||||
VERMILION_GYM = "12:11",
|
||||
ROUTE_6 = "12:1",
|
||||
ROUTE_6_SAFFRON_GATE = "12:12",
|
||||
SAFFRON = "25:2",
|
||||
ROUTE_5 = "25:1",
|
||||
ROUTE_5_SAFFRON_GATE = "25:14",
|
||||
SAFFRON_GYM = "25:4",
|
||||
SAFFRON_MART = "25:5",
|
||||
SAFFRON_CENTER = "25:6",
|
||||
CERULEAN = "7:17",
|
||||
CERULEAN_CENTER = "7:4",
|
||||
CERULEAN_GYM = "7:6",
|
||||
POWER_PLANT = "7:10",
|
||||
ROUTE_9 = "7:13",
|
||||
ROUTE_10_NORTH = "7:14",
|
||||
ROUTE_24 = "7:15",
|
||||
ROUTE_25 = "7:16",
|
||||
ROCK_TUNNEL = ["3:87", "3:88"],
|
||||
ROUTE_10_SOUTH = "18:3",
|
||||
LAVENDER = "18:4",
|
||||
LAVENDER_CENTER = "18:5",
|
||||
LAVENDER_RADIO_TOWER = "18:12",
|
||||
ROUTE_8 = "18:1",
|
||||
ROUTE_8_SAFFRON_GATE = "18:13",
|
||||
DIGLETTS_CAVE = "3:84",
|
||||
ROUTE_2 = "23:1",
|
||||
VIRIDIAN = "23:3",
|
||||
ROUTE_22 = "23:2",
|
||||
VIRIDIAN_GYM = "23:4",
|
||||
VIRIDIAN_CENTER = "23:9",
|
||||
PEWTER = "14:2",
|
||||
PEWTER_GYM = "14:4",
|
||||
PEWTER_CENTER = "14:6",
|
||||
ROUTE_1 = "13:1",
|
||||
PALLET = "13:2",
|
||||
OAKS_LAB = "13:6",
|
||||
CINNABAR_CENTER = "6:1",
|
||||
ROUTE_19_FUCHSIA_GATE = "6:3",
|
||||
SEAFOAM_GYM = "6:4",
|
||||
ROUTE_19 = "6:5",
|
||||
ROUTE_20 = "6:6",
|
||||
ROUTE_21 = "6:7",
|
||||
CINNABAR = "6:8",
|
||||
FUCHSIA_GYM = "17:8",
|
||||
ROUTE_18 = "17:4",
|
||||
FUCHSIA = "17:5",
|
||||
FUCHSIA_CENTER = "17:10",
|
||||
ROUTE_7 = "21:1",
|
||||
ROUTE_17 = "21:3",
|
||||
CELADON = "21:4",
|
||||
CELADON_CENTER = "21:17",
|
||||
CELADON_GYM = "21:21",
|
||||
ROUTE_7_SAFFRON_GATE = "21:25",
|
||||
ROUTE_17_ROUTE_18_GATE = "21:26",
|
||||
ROUTE_28 = "19:1",
|
||||
SILVER_CAVE_OUTSIDE = "19:2",
|
||||
SILVER_CAVE_CENTER = "19:3",
|
||||
SILVER_CAVE = ["3:74", "3:75"],
|
||||
SILVER_CAVE_ROOM_3 = "3:76";
|
||||
const S_S_TICKET = 0x44,
|
||||
MACHINE_PART = 0x80,
|
||||
ENGINE_EXPN_CARD = 3,
|
||||
ENGINE_BOULDERBADGE = 0x23,
|
||||
ENGINE_CASCADEBADGE = 0x24,
|
||||
ENGINE_THUNDERBADGE = 0x25,
|
||||
ENGINE_RAINBOWBADGE = 0x26,
|
||||
ENGINE_SOULBADGE = 0x27,
|
||||
ENGINE_MARSHBADGE = 0x28,
|
||||
ENGINE_VOLCANOBADGE = 0x29,
|
||||
ENGINE_EARTHBADGE = 0x2a,
|
||||
EARTHQUAKE = 89,
|
||||
EVENT_GOT_SS_TICKET_FROM_ELM = 0x24,
|
||||
EVENT_FAST_SHIP_FIRST_TIME = 0x30,
|
||||
EVENT_RETURNED_MACHINE_PART = 0xc9,
|
||||
EVENT_MET_MANAGER_AT_POWER_PLANT = 0xca,
|
||||
EVENT_RESTORED_POWER_TO_KANTO = 0xcd,
|
||||
EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM = 0xfb,
|
||||
EVENT_TALKED_TO_OAK_IN_KANTO = 0xe1,
|
||||
EVENT_BLUE_IN_CINNABAR = 0x775,
|
||||
EVENT_BEAT_BLUE = 0x4cc,
|
||||
EVENT_OPENED_MT_SILVER = 0x74f,
|
||||
EVENT_FOUGHT_SNORLAX = 0x750,
|
||||
EVENT_RED_IN_MT_SILVER = 0x762;
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -1653,5 +1745,385 @@ describe.skipIf(!existsSync(romPath))(
|
||||
reload();
|
||||
writeTestSave("hall-of-fame-new-bark");
|
||||
}, 300_000);
|
||||
|
||||
/** A Kanto gym leader in front of the player's way in: the battle
|
||||
* and the badge. */
|
||||
const beatLeader = (id: number, badge: number) => {
|
||||
talkTo(id);
|
||||
play();
|
||||
expect(p.world.state.flags.has(badge), p.log.slice(-3).join(" ")).toBe(true);
|
||||
};
|
||||
|
||||
it("takes the S.S. Ticket from Elm and sails the S.S. Aqua to Vermilion", () => {
|
||||
traceFailures();
|
||||
p.world.move("down");
|
||||
play();
|
||||
enter(ELMS_LAB, "up");
|
||||
play();
|
||||
talkTo(2); // Elm
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_SS_TICKET_FROM_ELM)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(S_S_TICKET);
|
||||
stairs(NEW_BARK);
|
||||
cross("left", ROUTE_29);
|
||||
cross("left", CHERRYGROVE);
|
||||
cross("up", ROUTE_30);
|
||||
cross("up", ROUTE_31);
|
||||
stairs(ROUTE_31_GATE);
|
||||
stairs(VIOLET);
|
||||
cross("left", ROUTE_36);
|
||||
cross("up", ROUTE_37);
|
||||
cross("up", ECRUTEAK);
|
||||
stairs(ROUTE_38_GATE);
|
||||
stairs(ROUTE_38);
|
||||
cross("left", ROUTE_39);
|
||||
cross("down", OLIVINE);
|
||||
travel(OLIVINE_PORT, [OLIVINE_PORT_PASSAGE]);
|
||||
// OlivinePortWalkUpToShipScript stops the player on the way to the
|
||||
// gangway: YES, the ticket, and on board.
|
||||
try {
|
||||
walk(7, 16);
|
||||
} catch {
|
||||
// The script takes over the walk.
|
||||
}
|
||||
play();
|
||||
expect(p.world.mapId).toBe(FAST_SHIP_1F);
|
||||
// FastShipB1FSailorBlocksLeft: a sailor steps in front of the
|
||||
// corridor west until the lazy sailor in the north cabins goes back
|
||||
// to work.
|
||||
stairs(FAST_SHIP_B1F);
|
||||
walk(30, 8);
|
||||
p.world.move("up");
|
||||
play();
|
||||
if (p.world.save().direction !== "up") p.world.move("up");
|
||||
p.world.interact();
|
||||
play();
|
||||
walk(31, 13); // back up the east ladder
|
||||
play();
|
||||
expect(route(FAST_SHIP_CABINS_NNW, [FAST_SHIP_1F], 3, { x: 4, y: 27 })).toBe(true);
|
||||
talkTo(6); // the lazy sailor
|
||||
play();
|
||||
// Down to B1F and along it to the captain's cabin, where the lost
|
||||
// girl is found and the ship comes in.
|
||||
expect(route(FAST_SHIP_CABINS_SE, [FAST_SHIP_B1F, FAST_SHIP_1F], 6, { x: 1, y: 26 })).toBe(true);
|
||||
talkTo(5);
|
||||
play();
|
||||
// Off the ship: the sailor at the gangway lets the player out.
|
||||
expect(route(FAST_SHIP_1F, [FAST_SHIP_B1F], 4, { x: 25, y: 3 })).toBe(true);
|
||||
talkTo(2);
|
||||
play();
|
||||
travel(VERMILION, [VERMILION_PORT_PASSAGE]);
|
||||
expect(p.world.state.events.has(EVENT_FAST_SHIP_FIRST_TIME)).toBe(true);
|
||||
reload();
|
||||
writeTestSave("vermilion-city");
|
||||
}, 300_000);
|
||||
|
||||
it("wins the THUNDERBADGE from Lt. Surge", () => {
|
||||
traceFailures();
|
||||
heal(VERMILION_CENTER, VERMILION);
|
||||
// A tree hides the gym's yard; Bellsprout cuts it.
|
||||
p.cut(13, 18);
|
||||
enter(VERMILION_GYM, "up");
|
||||
beatLeader(p.world.map.objects[0].id, ENGINE_THUNDERBADGE);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("wins the MARSHBADGE from Sabrina", () => {
|
||||
traceFailures();
|
||||
exit(VERMILION);
|
||||
p.cut(13, 18); // the tree has grown back
|
||||
cross("up", ROUTE_6);
|
||||
stairs(ROUTE_6_SAFFRON_GATE);
|
||||
stairs(SAFFRON);
|
||||
heal(SAFFRON_CENTER, SAFFRON);
|
||||
writeTestSave("saffron-city");
|
||||
enter(SAFFRON_GYM, "up");
|
||||
// Sabrina at (9, 8), past the gym's warp panels.
|
||||
expect(route(SAFFRON_GYM, [SAFFRON_GYM], 12, { x: 9, y: 9 })).toBe(true);
|
||||
beatLeader(p.world.map.objects[0].id, ENGINE_MARSHBADGE);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("meets the Power Plant's manager, finds the Machine Part and wins the CASCADEBADGE", () => {
|
||||
traceFailures();
|
||||
expect(route(SAFFRON, [SAFFRON_GYM], 12)).toBe(true);
|
||||
stairs(ROUTE_5_SAFFRON_GATE);
|
||||
stairs(ROUTE_5);
|
||||
cross("up", CERULEAN);
|
||||
heal(CERULEAN_CENTER, CERULEAN);
|
||||
cross("right", ROUTE_9);
|
||||
p.cut(5, 8);
|
||||
// Route 10's fence lets no one down from its Pokémon Center: the
|
||||
// river from Route 9's east end leads down to the Power Plant.
|
||||
sail(56, 17);
|
||||
p.world.move("down");
|
||||
play();
|
||||
sail(3, 11);
|
||||
stairs(POWER_PLANT);
|
||||
talkTo(7); // the manager
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_MET_MANAGER_AT_POWER_PLANT)).toBe(true);
|
||||
stairs(ROUTE_10_NORTH);
|
||||
// Back up the river, ashore at its top and west to Cerulean; the
|
||||
// tree has grown back.
|
||||
sail(16, 0);
|
||||
p.world.move("up");
|
||||
play();
|
||||
sail(50, 4);
|
||||
p.cut(5, 8);
|
||||
cross("left", CERULEAN);
|
||||
// The thief Rocket's scene on the way in, and the grunt on Route 24
|
||||
// tells where he hid the part.
|
||||
enter(CERULEAN_GYM, "up");
|
||||
play();
|
||||
exit(CERULEAN);
|
||||
cross("up", ROUTE_24);
|
||||
talkTo(2);
|
||||
play();
|
||||
cross("down", CERULEAN);
|
||||
enter(CERULEAN_GYM, "up");
|
||||
p.inspect(3, 8);
|
||||
expect(p.world.state.events.has(EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(MACHINE_PART);
|
||||
exit(CERULEAN);
|
||||
cross("up", ROUTE_24);
|
||||
cross("up", ROUTE_25);
|
||||
// Route25MistyDate: the date breaks up as the player walks by.
|
||||
walk(41, 6);
|
||||
p.world.move("right");
|
||||
play();
|
||||
expect(p.log.join(" ")).toContain("MISTY");
|
||||
cross("down", ROUTE_24);
|
||||
cross("down", CERULEAN);
|
||||
heal(CERULEAN_CENTER, CERULEAN);
|
||||
enter(CERULEAN_GYM, "up");
|
||||
beatLeader(3, ENGINE_CASCADEBADGE); // Misty, on the pool's island
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("restores the power, crosses Rock Tunnel and takes the EXPN card in Lavender", () => {
|
||||
traceFailures();
|
||||
exit(CERULEAN);
|
||||
cross("right", ROUTE_9);
|
||||
p.cut(5, 8);
|
||||
sail(56, 17);
|
||||
p.world.move("down");
|
||||
play();
|
||||
sail(3, 11);
|
||||
stairs(POWER_PLANT);
|
||||
talkTo(7);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_RETURNED_MACHINE_PART)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_RESTORED_POWER_TO_KANTO)).toBe(true);
|
||||
stairs(ROUTE_10_NORTH);
|
||||
sail(16, 0);
|
||||
p.world.move("up");
|
||||
play();
|
||||
sail(50, 4);
|
||||
// Rock Tunnel's door on Route 9 is reached from below: Route 10's
|
||||
// Pokémon Center grounds lead north into Route 9's corner by it.
|
||||
cross("down", ROUTE_10_NORTH);
|
||||
walk(8, 0);
|
||||
p.world.move("up");
|
||||
play();
|
||||
walk(48, 16);
|
||||
p.world.move("up");
|
||||
play();
|
||||
travel(ROUTE_10_SOUTH, ROCK_TUNNEL);
|
||||
cross("down", LAVENDER);
|
||||
heal(LAVENDER_CENTER, LAVENDER);
|
||||
enter(LAVENDER_RADIO_TOWER, "up");
|
||||
talkTo(5); // the director, by the stairs
|
||||
play();
|
||||
expect(p.world.state.flags.has(ENGINE_EXPN_CARD)).toBe(true);
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("wakes Vermilion's Snorlax with the Poké Flute channel", () => {
|
||||
traceFailures();
|
||||
exit(LAVENDER);
|
||||
cross("left", ROUTE_8);
|
||||
stairs(ROUTE_8_SAFFRON_GATE);
|
||||
stairs(SAFFRON);
|
||||
stairs(ROUTE_6_SAFFRON_GATE);
|
||||
stairs(ROUTE_6);
|
||||
cross("down", VERMILION);
|
||||
// SnorlaxAwake: the radio left on the Poké Flute channel, beside it.
|
||||
walk(34, 10);
|
||||
p.tuneRadio("20.0");
|
||||
if (p.world.save().direction !== "up") p.world.move("up");
|
||||
p.world.interact();
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_FOUGHT_SNORLAX)).toBe(true);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("goes through Diglett's Cave and wins the BOULDERBADGE from Brock", () => {
|
||||
traceFailures();
|
||||
heal(VERMILION_CENTER, VERMILION);
|
||||
travel(ROUTE_2, [DIGLETTS_CAVE]);
|
||||
p.cut(5, 8);
|
||||
cross("up", PEWTER);
|
||||
heal(PEWTER_CENTER, PEWTER);
|
||||
enter(PEWTER_GYM, "up");
|
||||
beatLeader(p.world.map.objects[0].id, ENGINE_BOULDERBADGE);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("surfs from Pallet Town to Cinnabar and wins the VOLCANOBADGE at Seafoam", () => {
|
||||
traceFailures();
|
||||
exit(PEWTER);
|
||||
cross("down", ROUTE_2);
|
||||
p.cut(5, 8);
|
||||
cross("down", VIRIDIAN);
|
||||
heal(VIRIDIAN_CENTER, VIRIDIAN);
|
||||
cross("down", ROUTE_1);
|
||||
cross("down", PALLET);
|
||||
cross("down", ROUTE_21, { water: true });
|
||||
cross("down", CINNABAR, { water: true });
|
||||
heal(CINNABAR_CENTER, CINNABAR);
|
||||
cross("right", ROUTE_20, { water: true });
|
||||
travel(SEAFOAM_GYM, [], { water: true });
|
||||
beatLeader(2, ENGINE_VOLCANOBADGE); // Blaine
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("hears Blue out on Cinnabar and wins the SOULBADGE from Janine", () => {
|
||||
traceFailures();
|
||||
stairs(ROUTE_20);
|
||||
cross("left", CINNABAR, { water: true });
|
||||
talkTo(2); // Blue
|
||||
play();
|
||||
expect(p.log.join(" ")).toContain("Who are you?");
|
||||
expect(p.world.state.events.has(EVENT_BLUE_IN_CINNABAR)).toBe(true);
|
||||
cross("right", ROUTE_20, { water: true });
|
||||
cross("right", ROUTE_19, { water: true });
|
||||
travel(FUCHSIA, [ROUTE_19_FUCHSIA_GATE], { water: true });
|
||||
heal(FUCHSIA_CENTER, FUCHSIA);
|
||||
enter(FUCHSIA_GYM, "up");
|
||||
beatLeader(2, ENGINE_SOULBADGE);
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("rides up Cycling Road and wins the RAINBOWBADGE from Erika", () => {
|
||||
traceFailures();
|
||||
exit(FUCHSIA);
|
||||
cross("left", ROUTE_18);
|
||||
// Cycling Road: on the Bicycle through the gate and up Route 17,
|
||||
// which continues north into Route 16 between x 4 and 9.
|
||||
p.world.useBicycle();
|
||||
play();
|
||||
stairs(ROUTE_17_ROUTE_18_GATE);
|
||||
stairs(ROUTE_17);
|
||||
p.rideUp(6, 0);
|
||||
// Through the Route 16 gate to its east side, then Celadon.
|
||||
walk(9, 7);
|
||||
p.world.move("right");
|
||||
play();
|
||||
walk(9, 5);
|
||||
p.world.move("right");
|
||||
play();
|
||||
cross("right", CELADON);
|
||||
if (p.world.state.playerState === 1) {
|
||||
p.world.useBicycle();
|
||||
play();
|
||||
}
|
||||
heal(CELADON_CENTER, CELADON);
|
||||
writeTestSave("celadon-city");
|
||||
p.cut(28, 35); // the tree by the gym's yard
|
||||
enter(CELADON_GYM, "up");
|
||||
beatLeader(2, ENGINE_RAINBOWBADGE);
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("restocks in Saffron and wins the EARTHBADGE from Blue", () => {
|
||||
traceFailures();
|
||||
exit(CELADON);
|
||||
p.cut(28, 35); // grown back
|
||||
heal(CELADON_CENTER, CELADON);
|
||||
cross("right", ROUTE_7);
|
||||
stairs(ROUTE_7_SAFFRON_GATE);
|
||||
stairs(SAFFRON);
|
||||
// Saffron's mart sells Max Potions.
|
||||
enter(SAFFRON_MART, "up");
|
||||
buy(2, C.MAX_POTION, Math.min(99, Math.floor((p.world.state.money - 3000) / 2500)));
|
||||
exit(SAFFRON);
|
||||
stairs(ROUTE_6_SAFFRON_GATE);
|
||||
stairs(ROUTE_6);
|
||||
cross("down", VERMILION);
|
||||
travel(ROUTE_2, [DIGLETTS_CAVE]);
|
||||
p.cut(5, 8);
|
||||
cross("down", VIRIDIAN);
|
||||
heal(VIRIDIAN_CENTER, VIRIDIAN);
|
||||
enter(VIRIDIAN_GYM, "up");
|
||||
beatLeader(2, ENGINE_EARTHBADGE);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_BLUE)).toBe(true);
|
||||
reload();
|
||||
}, 300_000);
|
||||
|
||||
it("has Oak open Mt. Silver for sixteen badges", () => {
|
||||
traceFailures();
|
||||
exit(VIRIDIAN);
|
||||
heal(VIRIDIAN_CENTER, VIRIDIAN);
|
||||
cross("down", ROUTE_1);
|
||||
cross("down", PALLET);
|
||||
enter(OAKS_LAB, "up");
|
||||
talkTo(2); // Oak
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_TALKED_TO_OAK_IN_KANTO)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_OPENED_MT_SILVER)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("POKéMON seen");
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("reaches Mt. Silver by Route 28 and trains outside Silver Cave", () => {
|
||||
traceFailures();
|
||||
exit(PALLET);
|
||||
cross("up", ROUTE_1);
|
||||
cross("up", VIRIDIAN);
|
||||
cross("left", ROUTE_22);
|
||||
stairs(VICTORY_ROAD_GATE);
|
||||
stairs(ROUTE_28);
|
||||
cross("left", SILVER_CAVE_OUTSIDE);
|
||||
heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
|
||||
writeTestSave("mt-silver");
|
||||
// Red's team is in the 70s and 80s: the lead trains in steps.
|
||||
travel(SILVER_CAVE_ROOM_3, SILVER_CAVE);
|
||||
travel(SILVER_CAVE_OUTSIDE, SILVER_CAVE);
|
||||
const rest = () => heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
|
||||
for (const level of [84, 88, 91, 93, 95]) train(level, rest);
|
||||
expect(lead().level).toBeGreaterThanOrEqual(95);
|
||||
reload();
|
||||
}, 900_000);
|
||||
|
||||
it("takes TM26 from Victory Road, beats Red and sees the credits", () => {
|
||||
traceFailures();
|
||||
cross("right", ROUTE_28);
|
||||
stairs(VICTORY_ROAD_GATE);
|
||||
stairs(VICTORY_ROAD);
|
||||
expect(route(VICTORY_ROAD, [VICTORY_ROAD], 8, { x: 4, y: 28 })).toBe(true);
|
||||
talkTo(3); // TM26's ball
|
||||
play();
|
||||
p.teach(tmhmItem(26), 0, 2); // Earthquake for Smokescreen
|
||||
expect(lead().moves[2]).toBe(EARTHQUAKE);
|
||||
travel(VICTORY_ROAD_GATE, [VICTORY_ROAD]);
|
||||
stairs(ROUTE_28);
|
||||
cross("left", SILVER_CAVE_OUTSIDE);
|
||||
heal(SILVER_CAVE_CENTER, SILVER_CAVE_OUTSIDE);
|
||||
travel(SILVER_CAVE_ROOM_3, SILVER_CAVE);
|
||||
talkTo(2); // Red
|
||||
play();
|
||||
const log = p.log.join(" ");
|
||||
expect(log).toContain("RED\nwas defeated!");
|
||||
// RedCredits, then SPAWN_MT_SILVER outside the cave, with Red gone
|
||||
// for good and the party healed.
|
||||
expect(p.world.state.events.has(EVENT_RED_IN_MT_SILVER)).toBe(true);
|
||||
expect(p.world.mapId).toBe(SILVER_CAVE_OUTSIDE);
|
||||
expect({ x: p.world.x, y: p.world.y }).toEqual({ x: 23, y: 20 });
|
||||
expect(p.world.state.party.every((mon) => mon.hp === mon.stats[0])).toBe(true);
|
||||
expect(p.world.busy).toBe(false);
|
||||
reload();
|
||||
writeTestSave("red-beaten");
|
||||
}, 300_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM/reference.");
|
||||
|
||||
/** The cartridge's PlaceDiplomaOnScreen with the LCD kept off (so its
|
||||
* graphics load at once) and only frame waits stubbed: the tilemap, the
|
||||
* tiles it shows and DiplomaPalettes' palette 0 match diplomaScreen for
|
||||
* several names and textbox frames. */
|
||||
test("PlaceDiplomaOnScreen matches the native diploma", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const flat = (name: string) => {
|
||||
const { bank, address } = routine(name);
|
||||
return address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
|
||||
};
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
cgb: at("hCGB"),
|
||||
tilemap: at("wTilemap"),
|
||||
palettes: at("wBGPals1"),
|
||||
frame: at("wTextboxFrame"),
|
||||
name: at("wPlayerName"),
|
||||
};
|
||||
const routines = { font: routine("_LoadStandardFont"), place: routine("PlaceDiplomaOnScreen") };
|
||||
const skip = ["WaitBGMap", "DelayFrame", "DelayFrames", "EnableLCD", "DisableLCD"].map(flat);
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(
|
||||
async ({ a, routines, skip }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { diplomaScreen } = await import("/src/game/scripts/kanto.ts");
|
||||
const { textTiles } = await import("/src/game/rom/font.ts");
|
||||
const { rgb } = await import("/src/game/scripts/screen.ts");
|
||||
const rom = new Uint8Array(
|
||||
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
|
||||
);
|
||||
const assets = new CrystalAssets(rom);
|
||||
for (const p of skip) rom[p] = 0xc9;
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, n: number) => reference.writeMemory(p, n & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const call = (r: { bank: number; address: number }) => [
|
||||
0x3e, r.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, r.address & 255, r.address >> 8,
|
||||
];
|
||||
const failures: unknown[] = [];
|
||||
for (const [index, name] of ["KAWA", "ABCDEFG", "ᴾᴷᴹᴺ", "GOLD"].entries()) {
|
||||
write(0xff70, 1);
|
||||
write(0xff40, 0);
|
||||
write(a.cgb, 1);
|
||||
write(a.frame, index * 2);
|
||||
[...textTiles(name), 0x50].forEach((tile, i) => write(a.name + i, tile));
|
||||
const program = [0xf3, 0x31, 0xfe, 0xc7, ...call(routines.font), ...call(routines.place), 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((value, offset) => write(0xc000 + offset, value));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 100 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
const native = diplomaScreen(assets.reader, name, index * 2);
|
||||
const map = Array.from({ length: 360 }, (_, i) => read(a.tilemap + i));
|
||||
write(0xff70, 5);
|
||||
const palette = Array.from({ length: 4 }, (_, i) => rgb(read(a.palettes + i * 2) | (read(a.palettes + i * 2 + 1) << 8)));
|
||||
write(0xff70, 1);
|
||||
write(0xff4f, 0);
|
||||
const tileDiff = [...new Set(native.map)].flatMap((tile) =>
|
||||
Array.from({ length: 16 }, (_, byte) => {
|
||||
const actual = read((tile < 128 ? 0x9000 : 0x8800) + (tile % 128) * 16 + byte);
|
||||
return actual === native.tiles[tile * 16 + byte] ? [] : [{ tile, byte }];
|
||||
}).flat(),
|
||||
);
|
||||
const mapDiff = map.flatMap((tile, i) => (tile === native.map[i] ? [] : [{ i, original: tile, native: native.map[i] }]));
|
||||
if (read(0xc0f0) !== 0x42 || mapDiff.length || tileDiff.length || JSON.stringify(palette) !== JSON.stringify(native.palettes[0]))
|
||||
failures.push({ name, returned: read(0xc0f0) === 0x42, mapDiff: mapDiff.slice(0, 8), tileDiff: tileDiff.slice(0, 4), palette, native: native.palettes[0] });
|
||||
}
|
||||
reference.dispose();
|
||||
return failures;
|
||||
},
|
||||
{ a, routines, skip },
|
||||
);
|
||||
expect(result, JSON.stringify(result)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
||||
|
||||
/** The cartridge's MagnetTrain runs with its interrupts after a boot, in
|
||||
* Saffron's station at day; each frame (sampled in VBlank, after the
|
||||
* cutscene's handler) its OAM, hSCX and wLYOverrides' bands, the BG map
|
||||
* and attributes, the icon's tiles and the palettes in CRAM are compared
|
||||
* with MagnetTrainMachine's, both ways and for both players. */
|
||||
for (const toGoldenrod of [false, true])
|
||||
for (const female of [false, true])
|
||||
test(`MagnetTrain matches the cartridge frame by frame ${toGoldenrod ? "to Goldenrod" : "to Saffron"}${female ? " as Kris" : ""}`, async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
program: at("wOverworldMapBlocks"),
|
||||
stackTop: at("wStackTop"),
|
||||
scriptVar: at("wScriptVar"),
|
||||
gender: at("wPlayerGender"),
|
||||
timeOfDay: at("wTimeOfDay"),
|
||||
mapGroup: at("wMapGroup"),
|
||||
mapNumber: at("wMapNumber"),
|
||||
tileset: at("wMapTileset"),
|
||||
jumptable: at("wJumptableIndex"),
|
||||
position: at("wMagnetTrainPosition"),
|
||||
lyOverrides: at("wLYOverrides"),
|
||||
scx: at("hSCX"),
|
||||
};
|
||||
const train = routine("MagnetTrain");
|
||||
const TEARDOWN = 8;
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(
|
||||
async ({ a, train, toGoldenrod, female }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { MagnetTrainMachine } = await import("/src/game/scripts/magnet-train.ts");
|
||||
const { rgb } = await import("/src/game/scripts/screen.ts");
|
||||
const rom = new Uint8Array(
|
||||
await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer(),
|
||||
);
|
||||
const assets = new CrystalAssets(rom);
|
||||
const station = assets.map("25:9");
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
reference.setMuted(true);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
reference.advanceFrames(600);
|
||||
write(0xff70, 1);
|
||||
write(a.gender, female ? 1 : 0);
|
||||
write(a.timeOfDay, 1);
|
||||
write(a.mapGroup, 25);
|
||||
write(a.mapNumber, 9);
|
||||
write(a.tileset, station.tileset);
|
||||
write(a.scriptVar, toGoldenrod ? 1 : 0);
|
||||
write(a.jumptable, 0xff);
|
||||
const sentinel = a.program + 0x80;
|
||||
const program = [
|
||||
0xf3, 0x31, a.stackTop & 255, a.stackTop >> 8, 0xfb,
|
||||
0x3e, train.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, train.address & 255, train.address >> 8,
|
||||
0x3e, 0x42, 0xea, sentinel & 255, sentinel >> 8,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
write(sentinel, 0);
|
||||
program.forEach((v, i) => write(a.program + i, v));
|
||||
reference.setProgramCounter(a.program);
|
||||
const toLine = (line: number) => {
|
||||
for (let guard = 0; read(0xff44) !== line; guard++) {
|
||||
if (guard > 20000) throw new Error("The LCD does not reach the line.");
|
||||
reference.advanceCycles(16);
|
||||
}
|
||||
};
|
||||
const cram = (index: number, obj: boolean) => {
|
||||
write(obj ? 0xff6a : 0xff68, index);
|
||||
return read(obj ? 0xff6b : 0xff69);
|
||||
};
|
||||
type State = { oam: number[]; bands: number[]; map: number[]; attrs: number[]; tiles: number[]; bg: number[]; obj: number[] };
|
||||
const sample = (): State => {
|
||||
toLine(152);
|
||||
write(0xff4f, 0);
|
||||
const map = Array.from({ length: 18 * 32 }, (_, i) => read(0x9800 + i));
|
||||
write(0xff4f, 1);
|
||||
const attrs = Array.from({ length: 18 * 32 }, (_, i) => read(0x9800 + i) & 7);
|
||||
write(0xff4f, 0);
|
||||
const state = {
|
||||
oam: Array.from({ length: 16 }, (_, i) => read(0xfe00 + i)),
|
||||
// Lines 1-143 take wLYOverrides (pushed at VBlank); line 0
|
||||
// takes hSCX, which already holds the next frame's here.
|
||||
bands: [read(a.lyOverrides), read(a.lyOverrides + 47), read(a.lyOverrides + 95)],
|
||||
map,
|
||||
attrs,
|
||||
tiles: Array.from({ length: 128 }, (_, i) => read(0x8000 + i)),
|
||||
bg: Array.from({ length: 64 }, (_, i) => cram(i, false)),
|
||||
obj: Array.from({ length: 16 }, (_, i) => cram(i, true)),
|
||||
};
|
||||
reference.advanceCycles(456 * 140);
|
||||
return state;
|
||||
};
|
||||
// Wait for the first run through: its jumptable entry leaves 1.
|
||||
let guard = 0;
|
||||
while (read(a.jumptable) !== 1 && guard++ < 300) reference.advanceFrames(1);
|
||||
const original: State[] = [];
|
||||
for (let frame = 0; frame < 600 && read(sentinel) !== 0x42; frame++) original.push(sample());
|
||||
const time = 1;
|
||||
const host = {
|
||||
reader: assets.reader,
|
||||
female,
|
||||
tiles: () => assets.tileset(station).graphics,
|
||||
palettes: () => ({
|
||||
bg: assets.palettes({ ...station, environment: 1 }, time),
|
||||
obj: Array.from({ length: 8 }, (_, i) => assets.objectPalette(i, time)),
|
||||
}),
|
||||
};
|
||||
const machine = new MagnetTrainMachine(host, toGoldenrod);
|
||||
const tiles = machine.tiles();
|
||||
const word = (c: number[]) => Math.round((c[0] * 31) / 255) | (Math.round((c[1] * 31) / 255) << 5) | (Math.round((c[2] * 31) / 255) << 10);
|
||||
const native: State[] = [];
|
||||
const present = () => {
|
||||
const screen = machine.screen(tiles, "");
|
||||
const oam = screen.sprites.flatMap((s) => [
|
||||
(s.y + 16) & 255,
|
||||
(s.x + 8) & 255,
|
||||
s.tile - 0x100,
|
||||
(s.behind ? 0x80 : 0) | (s.flipY ? 0x40 : 0) | (s.flipX ? 0x20 : 0) | s.palette,
|
||||
]);
|
||||
const d = machine.debug();
|
||||
const pal = (list: number[][][]) => list.flatMap((p) => p.flatMap((c) => { const w = word(c); return [w & 255, w >> 8]; }));
|
||||
native.push({
|
||||
oam: [...oam, ...Array(16 - oam.length).fill(-1)].slice(0, 16),
|
||||
bands: [d.bands.bushes, d.bands.train, d.bands.bushes],
|
||||
map: machine.bg.flat(),
|
||||
attrs: machine.attrs.flat(),
|
||||
tiles: Array.from(tiles.subarray(0x100 * 16, 0x108 * 16)),
|
||||
bg: pal(screen.palettes as number[][][]),
|
||||
obj: pal(screen.objPalettes.slice(0, 2) as number[][][]),
|
||||
});
|
||||
};
|
||||
machine.step();
|
||||
present();
|
||||
machine.setPalettes();
|
||||
present();
|
||||
while (!machine.done) {
|
||||
machine.step();
|
||||
present();
|
||||
}
|
||||
// The emulator's OAM keeps cleared sprites' bytes; compare the
|
||||
// four the icon uses, or nothing before it appears.
|
||||
const same = (o: State, n: State) => {
|
||||
const oam = n.oam[0] === -1 ? true : JSON.stringify(o.oam) === JSON.stringify(n.oam);
|
||||
return (
|
||||
oam &&
|
||||
JSON.stringify([o.bands, o.map, o.attrs, o.tiles, o.bg, o.obj]) ===
|
||||
JSON.stringify([n.bands, n.map, n.attrs, n.tiles, n.bg, n.obj])
|
||||
);
|
||||
};
|
||||
// The first sample can fall a frame either side of the first run
|
||||
// through: align on the offset where the frames agree.
|
||||
const offset = [0, 1, -1].find((off) => native.slice(2, 40).every((n, i) => original[i + 2 + off] && same(original[i + 2 + off], n))) ?? 0;
|
||||
const mismatches: unknown[] = [];
|
||||
// The first sample falls in the VBlank of the first run through,
|
||||
// before its overrides are pushed: compare from the next frame.
|
||||
native.forEach((n, i) => {
|
||||
if (i === 0) return;
|
||||
const o = original[i + offset];
|
||||
if (mismatches.length >= 4) return;
|
||||
if (!o) return void mismatches.push({ frame: i, original: "missing" });
|
||||
if (same(o, n)) return;
|
||||
const diff: Record<string, unknown> = { frame: i };
|
||||
for (const key of ["oam", "bands", "map", "attrs", "tiles", "bg", "obj"] as const) {
|
||||
const cells = o[key].flatMap((value, index) => (value === n[key][index] ? [] : [`${index}:${value}/${n[key][index]}`]));
|
||||
if (cells.length) diff[key] = cells.slice(0, 10).join(" ");
|
||||
}
|
||||
mismatches.push(diff);
|
||||
});
|
||||
return { original: original.length - offset, native: native.length, offset, mismatches };
|
||||
},
|
||||
{ a, train, toGoldenrod, female },
|
||||
);
|
||||
expect(result.mismatches, JSON.stringify(result)).toEqual([]);
|
||||
// The cartridge's teardown frames follow the arrival.
|
||||
expect(result.original).toBe(result.native + TEARDOWN);
|
||||
});
|
||||
Reference in new issue
Block a user