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>
226 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
},
|
|
);
|