Files
Crystal/tests/human-test-saves.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

206 lines
7.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: 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);
});
},
);