- 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>
237 lines
7.6 KiB
TypeScript
237 lines
7.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { GameWorld } from "../src/game/world/world";
|
|
import { doEggStep, stepHappiness } from "../src/game/party/eggs";
|
|
import {
|
|
generatePartyMon,
|
|
caughtData,
|
|
type PartyMon,
|
|
} from "../src/game/party/pokemon";
|
|
import { HatchRun } from "../src/game/scripts/hatching";
|
|
import { emptyScriptState } from "../src/game/scripts/interpreter";
|
|
import { defaultTextContext } from "../src/game/scripts/text";
|
|
import { EVENTS } from "../src/game/rom/profile";
|
|
import { settle } from "./world-driver";
|
|
|
|
const romPath =
|
|
process.env.CRYSTAL_ROM_PATH ??
|
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
|
|
describe("egg step byte rules", () => {
|
|
const egg = (happiness: number) => ({ egg: true, happiness }) as PartyMon;
|
|
it("stops decrementing at the first ready Egg and wraps a zero byte", () => {
|
|
const party = [egg(0), egg(1), egg(5)];
|
|
expect(doEggStep(party)).toBe(true);
|
|
expect(party.map((m) => m.happiness)).toEqual([255, 0, 5]);
|
|
expect(doEggStep(party)).toBe(false);
|
|
expect(party.map((m) => m.happiness)).toEqual([254, 255, 4]);
|
|
});
|
|
it("raises happiness only on alternate wraps, excluding Eggs and capping at 255", () => {
|
|
const counters = { happinessStepCount: 0 };
|
|
const party = [
|
|
egg(20),
|
|
{ happiness: 254 } as PartyMon,
|
|
{ happiness: 255 } as PartyMon,
|
|
];
|
|
stepHappiness(counters, party);
|
|
expect(party.map((m) => m.happiness)).toEqual([20, 254, 255]);
|
|
stepHappiness(counters, party);
|
|
expect(party.map((m) => m.happiness)).toEqual([20, 255, 255]);
|
|
expect(counters.happinessStepCount).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!existsSync(romPath))("hatching with the local ROM", () => {
|
|
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
const egg = (a: CrystalAssets, species = 175, happiness = 1) =>
|
|
Object.assign(
|
|
generatePartyMon(a.pokemon, {
|
|
species,
|
|
level: 5,
|
|
item: 0,
|
|
otId: 111,
|
|
ot: "ELM",
|
|
dvs: 0x8888,
|
|
caughtData: 0,
|
|
}),
|
|
{ egg: true as const, nickname: "EGG", happiness, hp: 0 },
|
|
);
|
|
const world = () => {
|
|
const w = new GameWorld(assets(), () => new Date("2026-09-22T12:00:00"));
|
|
w.restore({ ...w.save(), map: "24:3", x: 12, y: 3, direction: "left" });
|
|
settle(w);
|
|
w.state.noWildEncounters = true;
|
|
return w;
|
|
};
|
|
const walk = (w: GameWorld, direction: "left" | "right") => {
|
|
expect(w.move(direction)).toBe(true);
|
|
for (let i = 0; i < 100 && !w.dialogue && w.busy; i++) w.tick();
|
|
};
|
|
it("hatches every zero-cycle Egg in party order with caught data, ownership and naming", () => {
|
|
const a = assets(),
|
|
state = emptyScriptState();
|
|
state.party = [egg(a, 175, 0), egg(a, 172, 5), egg(a, 173, 0)];
|
|
const run = new HatchRun(state, {
|
|
reader: a.reader,
|
|
data: a.pokemon,
|
|
timeOfDay: () => 2,
|
|
landmark: () => 12,
|
|
player: () => ({ id: 456, gender: 1 }),
|
|
text: () => defaultTextContext("KRIS"),
|
|
});
|
|
const questions: string[] = [];
|
|
let effect = run.resume();
|
|
while (effect) {
|
|
let input: number | string | undefined;
|
|
if (effect.kind === "yesno") {
|
|
questions.push(effect.pages.join(" "));
|
|
input = 1;
|
|
}
|
|
if (effect.kind === "name")
|
|
input = questions.length === 1 ? "SHELLY" : " ";
|
|
effect = run.resume(input);
|
|
}
|
|
expect(state.party.every((mon) => mon.egg)).toBe(true);
|
|
expect(run.state.party.map((mon) => mon.nickname)).toEqual([
|
|
"SHELLY",
|
|
"EGG",
|
|
"CLEFFA",
|
|
]);
|
|
for (const mon of [run.state.party[0], run.state.party[2]]) {
|
|
expect(mon.egg).toBeUndefined();
|
|
expect(mon).toMatchObject({
|
|
happiness: 120,
|
|
otId: 456,
|
|
ot: "KRIS",
|
|
status: 0,
|
|
caughtData: caughtData(2, 1, 1, 12),
|
|
});
|
|
expect(mon.hp).toBe(mon.stats[0]);
|
|
expect(run.state.pokedex.caught.has(mon.species)).toBe(true);
|
|
}
|
|
expect(run.state.events.has(EVENTS.togepiHatched)).toBe(true);
|
|
});
|
|
it("walks the full Togepi cycle and replays a save made during the hatch", () => {
|
|
const w = world();
|
|
w.state.party = [egg(w.assets, 175, 2)];
|
|
for (let i = 0; i < 383; i++) {
|
|
walk(w, i % 2 ? "right" : "left");
|
|
settle(w);
|
|
}
|
|
expect(w.state.party[0].happiness).toBe(1);
|
|
expect(w.state.stepCount).toBe(127);
|
|
const before = w.save();
|
|
walk(w, "right");
|
|
expect(w.dialogue).toBeDefined();
|
|
const saved = w.save();
|
|
expect(saved.party[0]).toEqual(before.party[0]);
|
|
expect(saved.stepCount).toBe(127);
|
|
expect([saved.x, saved.y, saved.tilesWalked]).toEqual([
|
|
before.x,
|
|
before.y,
|
|
before.tilesWalked,
|
|
]);
|
|
// Reach naming while the whole hatch stays uncommitted.
|
|
settle(w, [1]);
|
|
expect(w.dialogue?.choice?.kind).toBe("name");
|
|
expect(w.save().party[0].egg).toBe(true);
|
|
const replay = world();
|
|
replay.restore(w.save());
|
|
walk(replay, "right");
|
|
settle(replay, [0]);
|
|
expect(replay.state.party[0]).toMatchObject({
|
|
happiness: 120,
|
|
nickname: "TOGEPI",
|
|
});
|
|
expect(replay.state.party[0].egg).toBeUndefined();
|
|
expect(replay.state.events.has(EVENTS.togepiHatched)).toBe(true);
|
|
expect(replay.state.stepCount).toBe(128);
|
|
expect(replay.save().tilesWalked).toBe(before.tilesWalked + 1);
|
|
});
|
|
it("counts 512 walking steps for happiness", () => {
|
|
const w = world();
|
|
const mon = egg(w.assets);
|
|
delete mon.egg;
|
|
mon.happiness = 70;
|
|
w.state.party = [mon];
|
|
for (let i = 0; i < 512; i++) {
|
|
walk(w, i % 2 ? "right" : "left");
|
|
settle(w);
|
|
}
|
|
expect(mon.happiness).toBe(71);
|
|
expect(w.state.stepCount).toBe(0);
|
|
expect(w.state.poisonStepCount).toBe(0);
|
|
});
|
|
it("rolls back the triggering step and Egg cycles if presentation fails", () => {
|
|
const w = world();
|
|
w.state.party = [egg(w.assets)];
|
|
w.state.stepCount = 127;
|
|
w.state.repel = 10;
|
|
const before = w.save();
|
|
w.assets.pokemon.eggPic = () => {
|
|
throw Error("Injected hatch picture failure");
|
|
};
|
|
walk(w, "left");
|
|
w.advanceDialogue();
|
|
expect(w.notice).not.toBe("");
|
|
const after = w.save();
|
|
expect([
|
|
after.x,
|
|
after.y,
|
|
after.tilesWalked,
|
|
after.stepCount,
|
|
after.poisonStepCount,
|
|
after.repel,
|
|
]).toEqual([
|
|
before.x,
|
|
before.y,
|
|
before.tilesWalked,
|
|
before.stepCount,
|
|
before.poisonStepCount,
|
|
before.repel,
|
|
]);
|
|
expect(after.party).toEqual(before.party);
|
|
expect(after.events).toEqual(before.events);
|
|
});
|
|
it("does not count a step whose Repel expires", () => {
|
|
const w = world();
|
|
w.state.repel = 1;
|
|
w.state.stepCount = 127;
|
|
w.state.party = [egg(w.assets)];
|
|
walk(w, "left");
|
|
settle(w);
|
|
expect(w.state.stepCount).toBe(127);
|
|
expect(w.state.party[0].happiness).toBe(1);
|
|
expect(w.state.repel).toBe(0);
|
|
});
|
|
it("migrates absent counters, rejects malformed ones and preserves saves atomically", () => {
|
|
const w = world(),
|
|
save = w.save();
|
|
const old = { ...save } as Partial<typeof save>;
|
|
for (const key of [
|
|
"stepCount",
|
|
"poisonStepCount",
|
|
"happinessStepCount",
|
|
"bikeStep",
|
|
] as const)
|
|
delete old[key];
|
|
w.restore(old);
|
|
expect(w.save()).toMatchObject({
|
|
stepCount: 0,
|
|
poisonStepCount: 0,
|
|
happinessStepCount: 0,
|
|
bikeStep: 0,
|
|
});
|
|
for (const change of [
|
|
{ stepCount: 256 },
|
|
{ poisonStepCount: -1 },
|
|
{ happinessStepCount: 2 },
|
|
{ bikeStep: 65536 },
|
|
]) {
|
|
expect(() => w.restore({ ...save, ...change })).toThrow();
|
|
expect(w.save().stepCount).toBe(0);
|
|
}
|
|
});
|
|
});
|