- 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>
206 lines
7.0 KiB
TypeScript
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);
|
|
});
|
|
},
|
|
);
|