Files
Crystal/tests/hall-of-fame.test.ts
T
kawaandClaude Opus 5.5 e49bc6de9a Breed at the Day-Care, hatch Eggs, draw MAIL stationery and browse the Hall of Fame (Closes #29)
- Day-Care and breeding: deposits, withdrawal pricing and growth,
  compatibility, egg species, DVs and moves, DayCareStep, egg
  collection and the Odd Egg, compared with the cartridge's routines.
- CountStep: walking happiness, DoEggStep, DayCareStep and DoBikeStep
  in the original's order; HatchEggs with the ROM's hatch animation,
  compared frame by frame with the original.
- MAIL: all ten stationery types and the Portrait MAIL picture,
  compared with the cartridge's tilemaps, graphics and palettes.
- Printing: Bill's PC boxes, Pokedex PRNT, MAIL and the Photo Studio
  take the original's absent-printer path.
- Hall of Fame records (saved apart from the League flag, ready for the
  League to call) and the Hall of Fame PC, compared with the cartridge's
  _HallOfFamePC on 255 pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:52:30 +02:00

198 lines
6.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import {
addHallOfFameEntry,
getHallOfFameParty,
validHallOfFame,
type HallOfFameRecords,
} from "../src/game/party/hall-of-fame";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { CrystalAssets } from "../src/game/rom/assets";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { hallOfFamePC } from "../src/game/scripts/hall-of-fame";
import { GameWorld } from "../src/game/world/world";
const minimal = {
species: 25,
otId: 12345,
dvs: 0xaaaa,
level: 42,
nickname: "SPARK",
} as PartyMon;
describe("Hall of Fame records", () => {
it("copies just the historical fields and skips Eggs, retaining order", () => {
const team = getHallOfFameParty(
[
minimal,
{ ...minimal, species: 175, egg: true },
{ ...minimal, species: 133 },
],
1,
);
expect(team).toEqual({
win: 1,
mons: [minimal, { ...minimal, species: 133 }],
});
team.mons[0].nickname = "OLD";
expect(minimal.nickname).toBe("SPARK");
expect(getHallOfFameParty([{ ...minimal, egg: true }], 2)).toEqual({
win: 2,
mons: [],
});
});
it("shifts out the oldest team at 30 and caps wins at 200 independently of the League flag", () => {
const state = {
hallOfFameCount: 0,
hallOfFameTeams: [],
hallOfFame: false,
} as HallOfFameRecords & { hallOfFame: boolean };
for (let i = 0; i < 35; i++)
addHallOfFameEntry(state, [{ ...minimal, otId: i }]);
expect(state.hallOfFameTeams).toHaveLength(30);
expect(state.hallOfFameTeams.map((team) => team.win)).toEqual(
Array.from({ length: 30 }, (_, i) => 35 - i),
);
expect(state.hallOfFameTeams[29].mons[0].otId).toBe(5);
for (let i = 35; i < 205; i++) addHallOfFameEntry(state, [minimal]);
expect(state.hallOfFameCount).toBe(200);
expect(state.hallOfFameTeams.slice(0, 6).map((team) => team.win)).toEqual(
Array(6).fill(200),
);
expect(state.hallOfFame).toBe(false);
expect(validHallOfFame(state.hallOfFameCount, state.hallOfFameTeams)).toBe(
true,
);
});
it("rejects malformed records and count/history contradictions", () => {
expect(validHallOfFame(0, [])).toBe(true);
for (const [count, teams] of [
[null, []],
[201, []],
[1, []],
[0, [getHallOfFameParty([minimal], 1)]],
[1, null],
[1, [{ win: 1, mons: null }]],
[1, [{ win: 1, mons: [{ ...minimal, species: 253 }] }]],
[1, [{ win: 1, mons: Array(7).fill(minimal) }]],
] as const)
expect(validHallOfFame(count, teams)).toBe(false);
});
});
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Hall of Fame with the local ROM", () => {
const assets = existsSync(romPath)
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
: undefined;
const fixture = () => {
const world = new GameWorld(assets!);
world.state.party = [
generatePartyMon(assets!.pokemon, {
species: 25,
level: 42,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0xaaaa,
caughtData: 0,
}),
];
world.restore({
...world.save(),
map: "26:5",
x: 9,
y: 2,
direction: "up",
objects: [],
});
return world;
};
const untilChoice = (world: GameWorld) => {
for (let i = 0; i < 300; i++) {
if (world.dialogue?.choice) return world.dialogue.choice;
if (world.dialogue) world.advanceDialogue();
else if (world.busy) world.tick();
else return;
}
throw Error("PC did not settle");
};
it("uses the count and Pokédex for the PC menu, then routes screen buttons back to the PC", () => {
const world = fixture();
world.state.flags.add(ENGINE_FLAGS.pokedex);
world.state.hallOfFame = true;
world.interact();
let menu = untilChoice(world);
expect(menu?.kind).toBe("menu");
if (menu?.kind !== "menu") return;
expect(menu.items).not.toContain("HALL OF FAME");
world.choose(-1);
untilChoice(world);
world.state.hallOfFame = false;
addHallOfFameEntry(world.state, world.state.party);
world.interact();
menu = untilChoice(world);
if (menu?.kind !== "menu") throw Error("No PC menu");
expect(menu.items).toContain("HALL OF FAME");
world.choose(menu.items.indexOf("HALL OF FAME"));
const page = untilChoice(world);
expect(page?.kind).toBe("screen");
if (page?.kind === "screen") expect(page.screen.label).toContain("PIKACHU");
world.choose("b");
expect(untilChoice(world)?.kind).toBe("menu");
world.choose(-1);
untilChoice(world);
world.state.flags.delete(ENGINE_FLAGS.pokedex);
world.interact();
menu = untilChoice(world);
if (menu?.kind !== "menu") throw Error("No PC menu");
expect(menu.items).not.toContain("HALL OF FAME");
});
it("browses newest first, ignores directions, advances with A and skips teams with Start", () => {
const teams = [
getHallOfFameParty([minimal, { ...minimal, nickname: "SECOND" }], 3),
{ win: 2, mons: [] },
getHallOfFameParty([{ ...minimal, nickname: "OLDER" }], 1),
];
const run = hallOfFamePC(assets!.pokemon, teams, 0);
const label = (result: ReturnType<typeof run.next>) =>
!result.done && result.value.kind === "screen"
? result.value.screen.label
: "";
expect(label(run.next())).toContain("SPARK");
expect(label(run.next("down"))).toContain("SPARK");
expect(label(run.next("a"))).toContain("SECOND");
expect(label(run.next("start"))).toContain("OLDER");
expect(run.next("a").done).toBe(true);
const cancel = hallOfFamePC(assets!.pokemon, teams, 0);
cancel.next();
expect(cancel.next("b").done).toBe(true);
});
it("round-trips records, migrates by absence, and rejects explicit damage", () => {
const world = fixture();
addHallOfFameEntry(world.state, world.state.party);
const saved = JSON.parse(JSON.stringify(world.save()));
const loaded = fixture();
loaded.restore(saved);
expect(loaded.state.hallOfFameTeams).toEqual(world.state.hallOfFameTeams);
saved.hallOfFameTeams[0].mons[0].nickname = "CHANGED";
expect(loaded.state.hallOfFameTeams[0].mons[0].nickname).toBe("PIKACHU");
const legacy = JSON.parse(JSON.stringify(world.save()));
delete legacy.hallOfFameCount;
delete legacy.hallOfFameTeams;
loaded.restore(legacy);
expect(loaded.state.hallOfFameTeams).toEqual([]);
expect(loaded.state.hallOfFameCount).toBe(0);
for (const mutate of [
(s: any) => (s.hallOfFameCount = null),
(s: any) => (s.hallOfFameTeams = null),
(s: any) => (s.hallOfFameTeams[0].mons[0].level = 101),
]) {
const bad = JSON.parse(JSON.stringify(world.save()));
mutate(bad);
expect(() => loaded.restore(bad)).toThrow(/Invalid native save/);
}
});
});