Routes 26 and 27 with their houses, Tohjo Falls, the Victory Road gate, Victory Road, Route 23, Indigo Plateau, the Elite Four's rooms, Lance's room and the Hall of Fame are open. - halloffame (game/scripts/league.ts): the music fades as SAVING RECORD shows, the win and the team (without Eggs) are recorded, each Pokémon's back picture passes and its front picture slides in for its "New Hall of Famer!" page and cry, then the player's card with the play time and Professor Oak's rating; the play time stops meanwhile. The world then continues at New Bark as Continue does after the cartridge's reset. - credits and the credits after the Hall of Fame (CreditsMachine): the ROM's script, strings, banner Pokémon and THE END, frame by frame as VBlank_Credits presents them, with the frames each command group runs over measured on the cartridge. B shortens credits seen before. - wait, appear/disappear of the player, and rival names in battle texts (PlaceEnemysName prints wRivalName alone: "???" before he is named). - The tile-screen renderer wraps hSCX/hSCY at 256 pixels and gives rows their own hSCX. - Reference: the credits match the cartridge on every frame (BG map, banner tiles, CRAM palettes, LY overrides, parser), with and without B; the ceremony's pages match for every species and the player's card for Chris and Kris. - Playthrough: five stages from Tin Tower to the Hall of Fame (HM07 from Ice Path, Tohjo Falls' waterfall, Victory Road's ladders, the Elite Four and Lance); the driver follows warps within a map and fights carefully from here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
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 { CreditsMachine, textboxPages } from "../src/game/scripts/league";
|
|
import { hallOfFameTiles } from "../src/game/scripts/hall-of-fame";
|
|
import { PC, RAM } from "../src/game/rom/profile";
|
|
import { tileScreenBackground } from "../src/game/rendering/tile-screen";
|
|
import { blankScreen } from "../src/game/scripts/screen";
|
|
import { settle } from "./world-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
|
|
describe("tile screens scrolled like the background map", () => {
|
|
// Tile 1 is solid color 3, tile 0 color 0.
|
|
const tiles = new Uint8Array(2 * 16);
|
|
tiles.fill(0xff, 16);
|
|
const screen = () => {
|
|
const s = blankScreen(tiles, "t", [[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]]], "");
|
|
s.map[0] = 1; // the top-left tile
|
|
return s;
|
|
};
|
|
it("wraps hSCX at 256 pixels, with `fill` beyond the tilemap", () => {
|
|
const s = screen();
|
|
s.scroll = { x: 256 - 8, y: 0, fill: 0 };
|
|
const { indices } = tileScreenBackground(s);
|
|
// The tile moves right by one tile; the column scrolled in is fill.
|
|
expect(indices[0]).toBe(0);
|
|
expect(indices[8]).toBe(3);
|
|
expect(indices[16]).toBe(0);
|
|
});
|
|
it("gives a row its own hSCX and background tiles", () => {
|
|
const s = screen();
|
|
s.map[4 * 20] = 1;
|
|
s.rowScroll = [{ row: 4, x: 0xfe, tiles: Array.from({ length: 32 }, (_, x) => (x === 31 ? 1 : 0)) }];
|
|
const { indices } = tileScreenBackground(s);
|
|
// Row 4 shows its 32nd tile's last two columns, then its own tiles.
|
|
expect(indices[32 * 160]).toBe(3);
|
|
expect(indices[32 * 160 + 2]).toBe(0);
|
|
// Row 0 is not scrolled.
|
|
expect(indices[0]).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!existsSync(romPath))("the League with the local ROM", () => {
|
|
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
|
const ROUTE_27 = "24:2",
|
|
VICTORY_ROAD_GATE = "23:13",
|
|
HALL_OF_FAME = "16:8",
|
|
NEW_BARK = "24:4";
|
|
const world = () => {
|
|
let seed = 21;
|
|
return new GameWorld(
|
|
new CrystalAssets(bytes),
|
|
() => new Date("2026-09-22T12:00:00"),
|
|
() => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 16) & 255;
|
|
},
|
|
);
|
|
};
|
|
const mon = (game: GameWorld, species: number, level = 50) =>
|
|
generatePartyMon(game.assets.pokemon, {
|
|
species,
|
|
level,
|
|
item: 0,
|
|
otId: game.playerId,
|
|
ot: "KAWA",
|
|
dvs: 0x8888,
|
|
caughtData: 0,
|
|
});
|
|
/** Run the world to the end of its scripts, pressing A on any screen
|
|
* that waits, and count the ticks the credits' screens take. */
|
|
const drive = (game: GameWorld, b = false) => {
|
|
const shown: string[] = [];
|
|
let screens = 0,
|
|
still = 0,
|
|
label = "";
|
|
for (let tick = 0; tick < 60_000; tick++) {
|
|
expect(game.notice, game.diagnostic).toBe("");
|
|
const dialogue = game.dialogue;
|
|
if (dialogue?.choice?.kind === "screen") {
|
|
screens++;
|
|
const now = dialogue.choice.screen.label;
|
|
if (now !== label) [label, still] = [now, 0];
|
|
if (!shown.includes(now)) shown.push(now);
|
|
// B on the credits' pages only (the rating takes B as well as A).
|
|
if (b && /, /.test(now) && !now.includes("play time")) game.bufferScreenButton("b");
|
|
if (++still > 60) {
|
|
game.bufferScreenButton("a");
|
|
still = 0;
|
|
}
|
|
game.tick();
|
|
continue;
|
|
}
|
|
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(0);
|
|
else if (last && dialogue.holdTicks !== undefined) game.tick();
|
|
else game.advanceDialogue();
|
|
continue;
|
|
}
|
|
if (!game.busy) {
|
|
game.tick();
|
|
if (!game.busy) return { shown, screens };
|
|
continue;
|
|
}
|
|
game.tick();
|
|
}
|
|
throw new Error("The world did not settle.");
|
|
};
|
|
|
|
it("has Route 27's fisher stop a first step into Kanto", () => {
|
|
const game = world();
|
|
game.warpTo(ROUTE_27, 18, 11);
|
|
settle(game);
|
|
game.restore({ ...game.save(), x: 18, y: 11, direction: "up" });
|
|
expect(game.move("up")).toBe(true);
|
|
const shown = settle(game).join(" ");
|
|
expect(shown).toContain("Hey!");
|
|
expect(shown).toContain("Do you know what\nyou just did?");
|
|
expect(game.state.scenes[ROUTE_27]).toBe(1); // SCENE_ROUTE27_NOOP
|
|
});
|
|
|
|
it("turns back a trainer without the eight Johto badges at the Victory Road gate", () => {
|
|
const game = world();
|
|
game.warpTo(VICTORY_ROAD_GATE, 10, 12);
|
|
settle(game);
|
|
game.restore({ ...game.save(), x: 10, y: 12, direction: "up" });
|
|
expect(game.move("up")).toBe(true);
|
|
const shown = settle(game).join(" ");
|
|
expect(shown).toContain("You don't have all\nthe GYM BADGES of\nJOHTO.");
|
|
expect({ x: game.x, y: game.y }).toEqual({ x: 10, y: 12 });
|
|
for (let badge = 0x1b; badge < 0x1b + 8; badge++) game.state.flags.add(badge);
|
|
game.restore({ ...game.save(), x: 10, y: 12, direction: "up" });
|
|
expect(game.move("up")).toBe(true);
|
|
expect(settle(game).join(" ")).toContain("BADGES of JOHTO!");
|
|
game.restore({ ...game.save(), x: 10, y: 10, direction: "down" });
|
|
expect(game.move("down")).toBe(true);
|
|
expect(settle(game)).toEqual([]);
|
|
});
|
|
|
|
it("records the team without its Egg, keeps the play time still and wakes in New Bark", () => {
|
|
const game = world();
|
|
const egg = { ...mon(game, 175, 5), egg: true };
|
|
game.state.party = [mon(game, 157, 60), egg, mon(game, 147, 30)];
|
|
game.warpTo(HALL_OF_FAME, 4, 13);
|
|
let before: { hours: number; minutes: number; seconds: number } | undefined;
|
|
const { shown } = (() => {
|
|
// Note the play time when the ceremony's first screen appears.
|
|
for (let tick = 0; tick < 2000 && !before; tick++) {
|
|
if (game.dialogue?.choice?.kind === "screen") before = { ...game.gameTime };
|
|
else if (game.dialogue) game.advanceDialogue();
|
|
else game.tick();
|
|
}
|
|
return drive(game);
|
|
})();
|
|
expect(before).toBeDefined();
|
|
expect(shown.join(" ")).toContain("Saving record");
|
|
expect(shown).toContain("New Hall of Famer!: TYPHLOSION, TYPHLOSION, level 60, ID " + String(game.playerId).padStart(5, "0"));
|
|
expect(shown.some((label) => label.startsWith("STAFF") || label.includes("CRYSTAL VERSION"))).toBe(true);
|
|
expect(game.state.hallOfFame).toBe(true);
|
|
expect(game.state.hallOfFameCount).toBe(1);
|
|
expect(game.state.hallOfFameTeams).toHaveLength(1);
|
|
expect(game.state.hallOfFameTeams[0]).toMatchObject({ win: 1, mons: [{ species: 157 }, { species: 147 }] });
|
|
expect(game.state.party.every((member) => member.egg || member.hp === member.stats[0])).toBe(true);
|
|
// halloffame clears GAME_TIMER_COUNTING_F for the ceremony and credits.
|
|
expect({ hours: game.gameTime.hours, minutes: game.gameTime.minutes }).toEqual({
|
|
hours: before!.hours,
|
|
minutes: before!.minutes,
|
|
});
|
|
expect(game.mapId).toBe(NEW_BARK);
|
|
expect({ x: game.x, y: game.y }).toEqual({ x: 13, y: 6 });
|
|
// The ceremony does not play again.
|
|
game.warpTo(HALL_OF_FAME, 4, 13);
|
|
expect(drive(game).screens).toBe(0);
|
|
});
|
|
|
|
it("caps the League wins at 200 and keeps the 30 newest teams; B shortens credits seen before", () => {
|
|
const run = (count: number, b: boolean) => {
|
|
const game = world();
|
|
game.state.party = [mon(game, 157, 60)];
|
|
game.state.hallOfFame = count > 0;
|
|
game.state.hallOfFameCount = count;
|
|
game.state.hallOfFameTeams = Array.from({ length: Math.min(30, count) }, (_, i) => ({
|
|
win: count - i,
|
|
mons: [{ species: 25, otId: 1, dvs: 0, level: 5, nickname: "PIKACHU" }],
|
|
}));
|
|
game.warpTo(HALL_OF_FAME, 4, 13);
|
|
return { game, ...drive(game, b) };
|
|
};
|
|
const capped = run(200, false);
|
|
expect(capped.game.state.hallOfFameCount).toBe(200);
|
|
expect(capped.game.state.hallOfFameTeams).toHaveLength(30);
|
|
expect(capped.game.state.hallOfFameTeams[0]).toMatchObject({ win: 200, mons: [{ species: 157 }] });
|
|
// The first time, B does nothing; a team that has entered before can
|
|
// hurry the credits along with it.
|
|
expect(run(0, true).screens).toBe(run(0, false).screens);
|
|
expect(run(5, true).screens).toBeLessThan(run(5, false).screens - 1000);
|
|
});
|
|
|
|
describe("Credits", () => {
|
|
const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined;
|
|
const runCredits = (press: (frame: number) => string) => {
|
|
const machine = new CreditsMachine(assets!.reader, hallOfFameTiles(assets!.pokemon, 0), false);
|
|
const labels: string[] = [];
|
|
for (let frame = 0; frame < 20_000; frame++) {
|
|
const step = machine.step(press(frame));
|
|
let next = step.next();
|
|
while (!next.done) {
|
|
machine.vblank();
|
|
next = step.next();
|
|
}
|
|
if (next.value) return { frame, labels, machine };
|
|
const label = machine.screen().label;
|
|
if (labels.at(-1) !== label) labels.push(label);
|
|
machine.vblank();
|
|
}
|
|
return { frame: -1, labels, machine };
|
|
};
|
|
it("roll from the staff to THE END and close only on A after it", () => {
|
|
// A held throughout ends the credits right after CREDITS_END.
|
|
const { frame, labels, machine } = runCredits(() => "a");
|
|
expect(labels).toContain("POKéMON, CRYSTAL VERSION, STAFF");
|
|
expect(labels).toContain("DIRECTOR, SATOSHI TAJIRI");
|
|
expect(labels).toContain("EXECUTIVE PRODUCER, HIROSHI YAMAUCHI");
|
|
expect(frame).toBeGreaterThan(6900);
|
|
expect(machine.debug().exit).toBe(true);
|
|
// THE END's 16 tiles at (6, 9) and (6, 10).
|
|
expect(machine.shown.slice(9 * 20 + 6, 9 * 20 + 14)).toEqual([0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47]);
|
|
expect(machine.shown.slice(10 * 20 + 6, 10 * 20 + 14)).toEqual([0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f]);
|
|
});
|
|
it("scroll the borders two pixels a cycle and animate the banner", () => {
|
|
const machine = new CreditsMachine(assets!.reader, hallOfFameTiles(assets!.pokemon, 0), false);
|
|
const scrolls = new Set<number>(),
|
|
banners = new Set<string>();
|
|
for (let frame = 0; frame < 800; frame++) {
|
|
const step = machine.step("");
|
|
let next = step.next();
|
|
while (!next.done) {
|
|
machine.vblank();
|
|
next = step.next();
|
|
}
|
|
const screen = machine.screen();
|
|
scrolls.add(screen.rowScroll![1].x);
|
|
banners.add(screen.tileset);
|
|
machine.vblank();
|
|
}
|
|
expect([...scrolls].every((x) => x % 2 === 0)).toBe(true);
|
|
expect(scrolls.size).toBeGreaterThan(40);
|
|
expect(banners.size).toBeGreaterThan(4);
|
|
});
|
|
});
|
|
|
|
it("decodes the rating's text box to tiles, keeping \"'s\" as one tile", () => {
|
|
const assets = new CrystalAssets(bytes);
|
|
const ram = (address: number) =>
|
|
address === RAM.stringBuffer3 ? "42" : address === RAM.stringBuffer4 ? "7" : "";
|
|
const pages = textboxPages(assets.reader, PC.OakPCText3, ram, "KAWA");
|
|
expect(pages).toHaveLength(2);
|
|
// "42 POKéMON seen" / "7 POKéMON owned", then a para.
|
|
expect(pages[0].wait).toBe(true);
|
|
expect(pages[0].lines[0].slice(0, 2)).toEqual([0xfa, 0xf8]);
|
|
expect(pages[0].lines[1][0]).toBe(0xfd);
|
|
// "PROF.OAK's" / "Rating:" ends the text without a wait.
|
|
expect(pages[1].wait).toBe(false);
|
|
expect(pages[1].lines[0]).toContain(0xd4);
|
|
expect(pages[1].lines[0]).toHaveLength(9);
|
|
});
|
|
});
|