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; 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); } }); });