Files
Crystal/tests/human-test-saves.test.ts
T
kawaandClaude Opus 5.5 f05fd8ebee
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Failing after 25m31s
Open the League: Victory Road, the Elite Four, the Hall of Fame and the credits (#33)
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>
2026-10-17 14:52:14 +02:00

226 lines
8.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, mkdirSync, readFileSync, writeFileSync } 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 { addHallOfFameEntry } from "../src/game/party/hall-of-fame";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { CRYSTAL_ROM } from "../src/rom/validate";
import { encodeSave } from "../src/storage/saves";
import { foundationFixture } from "./foundation-fixture";
import { settle } from "./world-driver";
// Backups for the manual checks on the project board's "A tester" column,
// built from the local ROM and imported through Savegames → Import backup
// (then "Add as a new save"). They hold names read from the ROM, so they
// stay out of the repository (*.crystal-save.json is ignored).
//
// MAKE_TEST_SAVES=1 npx vitest run tests/human-test-saves.test.ts
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const OUT = "test-saves";
/** NativeRuntime.id, which an imported backup must name. */
const CORE_ID = "crystal-native-world-v1";
describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)(
"saves for manual testing",
() => {
const assets = () =>
new CrystalAssets(new Uint8Array(readFileSync(romPath)));
function write(name: string, world: GameWorld) {
const state = new TextEncoder().encode(JSON.stringify(world.save()));
// The backup must load as the app would load it.
new GameWorld(assets()).restore(
JSON.parse(new TextDecoder().decode(state)),
);
mkdirSync(OUT, { recursive: true });
writeFileSync(
`${OUT}/${name}.crystal-save.json`,
encodeSave({
version: 1,
romHash: CRYSTAL_ROM.sha1,
coreId: CORE_ID,
savedAt: new Date().toISOString(),
state,
}),
);
}
it("writes fly-teleport: New Bark Town with Fly, Teleport and Escape Ropes", () => {
const a = assets();
// Pidgeot with Fly, the Storm Badge, New Bark and Cherrygrove visited.
const world = foundationFixture(a, "fly");
const abra = generatePartyMon(a.pokemon, {
species: 63, // ABRA
level: 20,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
abra.moves = [0x64, 0, 0, 0]; // TELEPORT
abra.pp = [20, 0, 0, 0];
world.state.party.push(abra);
// Violet City too, whose map is not ported yet.
world.state.flags.add(a.spawnEngineFlag(16)!);
world.state.lastSpawnMap = "26:3"; // Cherrygrove City
world.state.bag.items = [{ item: 0x13, quantity: 3 }]; // ESCAPE ROPE
expect(world.mapId).toBe("24:4");
write("fly-teleport", world);
});
it("writes field-items: New Bark Town's shore with rods, MAIL and a Repel", () => {
const a = assets();
// Cyndaquil facing the sea with the Old Rod.
const world = foundationFixture(a, "fishing");
world.state.party.push(
generatePartyMon(a.pokemon, {
species: 16, // PIDGEY
level: 4,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
}),
);
world.state.bag.items = [
{ item: 0x12, quantity: 5 }, // POTION
{ item: 0x9e, quantity: 2 }, // FLOWER_MAIL
{ item: 0x14, quantity: 1 }, // REPEL
];
world.state.bag.keyItems.push(0x3b, 0x3d); // GOOD_ROD, SUPER_ROD
expect(world.mapId).toBe("24:4");
write("field-items", world);
});
it("writes headbutt: Route 29 facing a Headbutt tree", () => {
// Totodile with Headbutt and Tackle, below the first reachable tree.
const world = foundationFixture(assets(), "headbutt");
expect(world.mapId).toBe("24:3");
write("headbutt", world);
});
/** A Pokémon of the player's, from (species, level, DVs). */
const caught = (
a: CrystalAssets,
world: GameWorld,
species: number,
level: number,
dvs = 0x9abc,
) =>
generatePartyMon(a.pokemon, {
species,
level,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs,
caughtData: 0,
});
it("writes day-care: inside the Day-Care with a pair that breeds", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
// A female Cyndaquil and a male Typhlosion (Attack and Speed DVs 0
// and 15), and a Totodile to keep. The Odd Egg is still to come.
world.state.party = [
caught(a, world, 155, 20, 0x0201), // CYNDAQUIL
caught(a, world, 157, 40, 0xf7f2), // TYPHLOSION
caught(a, world, 158, 15), // TOTODILE
];
world.state.money = 50000;
world.warpTo("11:24", 2, 4); // facing the man
settle(world);
world.restore({ ...world.save(), direction: "up" });
expect(world.mapId).toBe("11:24");
write("day-care", world);
});
it("writes egg-hatching: Route 29 with two Eggs two steps from hatching", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
const egg = (species: number, dvs: number, happiness: number) => ({
...caught(a, world, species, 5, dvs),
egg: true as const,
nickname: "EGG",
happiness,
hp: 0,
});
// The Togepi Egg reaches zero on step 128 and stops DoEggStep; the
// shiny Pichu's is already zero, so both hatch.
world.state.party = [
caught(a, world, 155, 20),
egg(175, 0x8888, 1), // TOGEPI
egg(172, 0x2aaa, 0), // PICHU
];
world.restore({
...world.save(),
map: "24:3",
x: 12,
y: 3,
direction: "left",
stepCount: 0x7e,
});
settle(world);
write("egg-hatching", world);
});
it("writes hall-of-fame-ceremony: the Hall of Fame's entrance, where Lance's scene and the ceremony start", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
world.state.flags.add(ENGINE_FLAGS.pokedex);
for (let badge = 0x1b; badge < 0x1b + 8; badge++) world.state.flags.add(badge);
world.state.party = [
caught(a, world, 157, 60, 0x9abc), // TYPHLOSION
caught(a, world, 25, 40, 0x2aaa), // PIKACHU, shiny
caught(a, world, 201, 40, 0xffff), // UNOWN
{ ...caught(a, world, 175, 5), egg: true }, // an Egg, left out
];
for (const species of [157, 25, 201]) {
world.state.pokedex.seen.add(species);
world.state.pokedex.caught.add(species);
}
// The Hall of Fame's own warp; continuing runs HallOfFameEnterScene.
world.restore({ ...world.save(), map: "16:8", x: 4, y: 13, direction: "up", objects: [] });
write("hall-of-fame-ceremony", world);
});
it("writes hall-of-fame: a Pokémon Center whose PC has three teams", () => {
const a = assets();
const world = new GameWorld(a, () => new Date("2026-09-22T12:00:00"));
world.state.flags.add(ENGINE_FLAGS.pokedex);
const record = (party: [number, number, number][]) =>
addHallOfFameEntry(
world.state,
party.map(([species, level, dvs]) =>
caught(a, world, species, level, dvs),
),
);
record([[201, 50, 0xffff]]); // UNOWN
record([
[157, 55, 0x9abc], // TYPHLOSION
[29, 30, 0x0000], // NIDORAN♀
[32, 31, 0xffff], // NIDORAN♂
[25, 100, 0x2aaa], // PIKACHU, shiny
[175, 12, 0x9abc], // TOGEPI
[249, 70, 0x9abc], // LUGIA
]);
record([[158, 42, 0x9abc]]); // TOTODILE
world.state.party = [caught(a, world, 155, 20)];
world.restore({
...world.save(),
map: "26:5",
x: 9,
y: 2,
direction: "up",
objects: [],
});
settle(world);
write("hall-of-fame", world);
});
},
);