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>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-17 08:52:30 +02:00
1 parent 7d03f5c5ca
commit e49bc6de9a
49 files changed
+6028 -88

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
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);
}
});
});