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:
1 parent
7d03f5c5ca
commit
e49bc6de9a
49 files changed
+6028
-88
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user