Open the League: Victory Road, the Elite Four, the Hall of Fame and the credits (#33)
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m31s

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>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 14:52:14 +02:00
1 parent b6fdc23508
commit f05fd8ebee
23 files changed
+1943 -107

No files matched your search

+273
View File
@@ -0,0 +1,273 @@
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);
});
});