- 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>
198 lines
6.9 KiB
TypeScript
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/);
|
|
}
|
|
});
|
|
});
|