Files
Crystal/tests/olivine-cianwood.test.ts
T
kawaandClaude Opus 5.5 e49bc6de9a 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>
2026-10-17 08:52:30 +02:00

283 lines
12 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 { caughtData, generatePartyMon } from "../src/game/party/pokemon";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Olivine and Cianwood side content with the local ROM", () => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const ROUTE_39_BARN = "1:10",
MANIAS_HOUSE = "22:4",
PHOTO_STUDIO = "22:8",
SEERS_HOUSE = "22:10";
const SHUCKLE = 213,
BERRY = 0xad,
MANIA_OT_ID = 518,
LANDMARK_NEW_BARK_TOWN = 1,
LANDMARK_GIFT = 0x7e,
LANDMARK_EVENT = 0x7f,
DAY = 1; // DAY_F
const EVENT_GOT_SHUCKIE = 0x45,
EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM = 0x46,
ENGINE_GOT_SHUCKIE_TODAY = 0x55,
EVENT_HEALED_MOOMOO = 0x3d,
EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO = 0x3f;
/** A world whose clock `now` moves. */
const world = (date = "2026-09-22T12:00:00") => {
let seed = 13;
const clock = { now: new Date(date) };
const game = new GameWorld(
new CrystalAssets(bytes),
() => clock.now,
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
return { game, clock };
};
const mon = (
game: GameWorld,
species: number,
level: number,
extra: Partial<ReturnType<typeof generatePartyMon>> = {},
) =>
Object.assign(
generatePartyMon(game.assets.pokemon, {
species,
level,
item: 0,
otId: game.playerId,
ot: "KAWA",
dvs: 0x8888,
caughtData: 0,
}),
extra,
);
/** Enter `map`, stand at (x, y) facing `face`, press A and answer. */
const talk = (
game: GameWorld,
map: string,
x: number,
y: number,
face: "up" | "down" | "left" | "right",
answers: (number | string)[] = [],
) => {
game.warpTo(map, x, y);
settle(game);
game.restore({ ...game.save(), x, y, direction: face });
game.interact();
const shown = settle(game, answers).join(" ");
expect(game.notice, game.diagnostic).toBe("");
return shown;
};
/** Mania stands at (2, 4); talk to him from below. */
const mania = (game: GameWorld, answers: number[] = []) =>
talk(game, MANIAS_HOUSE, 2, 5, "up", answers);
it("nurses Route 39's Miltank back to health with seven Berries", () => {
const { game } = world();
game.state.party = [mon(game, 155, 20)];
// MooMoo (3, 3) only asks for Berries once the farmer has explained.
const moomoo = (answers: number[] = []) => talk(game, ROUTE_39_BARN, 3, 4, "up", answers);
const before = moomoo();
expect(before).toContain("MILTANK: …Moo…");
expect(before).not.toContain("Give a BERRY");
game.state.events.add(EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO);
expect(moomoo()).toContain("has no\nBERRIES…");
game.state.bag.items = [{ item: BERRY, quantity: 7 }];
expect(moomoo([0])).toContain("wouldn't\ngive a BERRY.");
const fed = Array.from({ length: 7 }, () => moomoo([1]));
expect(fed[2]).toContain("a\nlittle healthier!");
expect(fed[4]).toContain("quite healthy!");
expect(fed[6]).toContain("totally healthy!");
expect(fed[0]).not.toContain("healthier");
// wMooMooBerries is saved with the other script bytes.
expect(game.save().memory).toMatchObject({ wMooMooBerries: 7 });
expect(game.state.bag.items).toEqual([]);
expect(game.state.events.has(EVENT_HEALED_MOOMOO)).toBe(true);
expect(moomoo()).toContain("MILTANK: Mooo!");
});
describe("Mania's Shuckie (GiveShuckle, ReturnShuckie)", () => {
it("lends Shuckie with its own OT, ID, nickname, Berry and gift caught data", () => {
const { game } = world();
game.state.party = [mon(game, 155, 20)];
const shown = mania(game);
expect(shown).toContain("after my #MON".replace("#", "POKé"));
expect(shown).toContain("received a\nPOKéMON.");
const shuckie = game.state.party[1];
expect(shuckie).toMatchObject({
species: SHUCKLE,
level: 15,
item: BERRY,
otId: MANIA_OT_ID,
ot: "MANIA",
nickname: "SHUCKIE",
caughtData: LANDMARK_GIFT,
});
expect(game.state.pokedex.caught.has(SHUCKLE)).toBe(true);
expect(game.state.events.has(EVENT_GOT_SHUCKIE)).toBe(true);
expect(game.state.flags.has(ENGINE_GOT_SHUCKIE_TODAY)).toBe(true);
// The same day he only asks the player to look after it.
expect(mania(game)).toContain("Take good care of");
expect(game.state.party).toHaveLength(2);
});
it("keeps Shuckie when the party is full, or when the player says no", () => {
const { game } = world();
game.state.party = Array.from({ length: 6 }, () => mon(game, 155, 20));
expect(mania(game)).toContain("Your POKéMON party\nis full.");
expect(game.state.events.has(EVENT_GOT_SHUCKIE)).toBe(false);
game.state.party = [mon(game, 155, 20)];
expect(mania(game, [0])).toContain("What'll\nI do if he comes");
expect(game.state.party).toHaveLength(1);
});
/** Shuckie lent yesterday; today Mania asks for it back. */
const lent = () => {
const { game, clock } = world();
game.state.party = [mon(game, 155, 20)];
mania(game);
clock.now = new Date("2026-09-23T12:00:00");
return game;
};
it("takes Shuckie back the next day", () => {
const game = lent();
const shown = mania(game, [1, 1]);
expect(shown).toContain("may I have\nit back?");
expect(shown).toContain("Thank you!");
expect(game.state.party.map((m) => m.species)).toEqual([155]);
expect(game.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(true);
expect(mania(game)).toContain("piness is being");
});
it("lets a happy Shuckie (150 or more) stay, and refuses another Pokémon", () => {
const wrong = lent();
expect(mania(wrong, [1, 0])).toContain("you don't\nhave my POKéMON");
expect(wrong.state.party).toHaveLength(2);
// Declining his question, or backing out of the party menu, is "robbed".
expect(mania(wrong, [0])).toContain("same as being\nrobbed.");
expect(mania(wrong, [1, -1])).toContain("same as being\nrobbed.");
const happy = lent();
happy.state.party[1].happiness = 150;
expect(mania(happy, [1, 1])).toContain("come to like you.");
expect(happy.state.party).toHaveLength(2);
expect(happy.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(true);
});
it("keeps Shuckie when every other Pokémon has fainted, with the wrong speech", () => {
// CheckCurPartyMonFainted checks the party apart from Shuckie;
// Mania then answers SHUCKIE_FAINTED with his happiness speech
// (docs/bugs_and_glitches.md: the "last Pokémon" text is unused).
const game = lent();
game.state.party[0].hp = 0;
const shown = mania(game, [1, 1]);
expect(shown).toContain("piness is being");
expect(shown).not.toContain("If I take my");
expect(game.state.party).toHaveLength(2);
expect(game.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(false);
});
});
describe("the Poké Seer (PokeSeer)", () => {
/** The Seer stands at (2, 3); choose party slot `slot`. */
const seer = (game: GameWorld, slot: number) =>
talk(game, SEERS_HOUSE, 2, 4, "up", [slot]);
it("reads where, when and at what level the player met a Pokémon", () => {
const { game } = world();
game.state.party = [
mon(game, 155, 40, {
nickname: "EMBER",
caughtData: caughtData(DAY, 10, 0, LANDMARK_NEW_BARK_TOWN),
}),
];
const shown = seer(game, 0);
expect(shown).toContain("I see all.");
expect(shown).toMatch(/I see you met\nEMBER here:\nNEW BARK/);
expect(shown).toContain("The time was\nDay!");
expect(shown).toContain("Its level was 10!");
// Level 40 now, 30 levels on: SeerAdviceTexts' 59 row.
expect(shown).toContain("EMBER has\ngrown. It's gained\nmuch strength.");
});
it("compares only the high byte of the OT ID, so some trades read as met", () => {
const { game } = world();
const caught = caughtData(DAY, 10, 0, LANDMARK_NEW_BARK_TOWN);
game.state.party = [
mon(game, 155, 12, { otId: game.playerId ^ 0x0100, ot: "RED", caughtData: caught }),
mon(game, 155, 12, { otId: game.playerId ^ 0x0001, ot: "RED", caughtData: caught }),
];
expect(seer(game, 0)).toContain("came from RED\nin a trade?");
expect(seer(game, 1)).toContain("I see you met");
});
it("cannot read gifts or blank data, reads only levels for events, and refuses Eggs", () => {
const { game } = world();
game.state.party = [
mon(game, 155, 20, { caughtData: (15 << 8) | LANDMARK_GIFT }),
mon(game, 155, 20, { caughtData: 0 }),
mon(game, 155, 20, { caughtData: (15 << 8) | LANDMARK_EVENT }),
mon(game, 175, 5, { egg: true }),
];
expect(seer(game, 0)).toContain("I can't\ntell a thing!");
expect(seer(game, 1)).toContain("I can't\ntell a thing!");
const event = seer(game, 2);
expect(event).toContain("What!? Incredible!");
expect(event).not.toContain("The time was");
expect(seer(game, 3)).toContain("That's an EGG!");
expect(seer(game, -1)).toContain("you'd do nothing!");
});
it("keeps the last caught level when a Pokémon's is unknown", () => {
// GetCaughtLevel prints ??? and leaves wSeerCaughtLevel as it was,
// which SeerAdvice still subtracts from the level. The port keeps the
// Seer's own last reading until the game is loaded again.
const { game } = world();
game.state.party = [
mon(game, 155, 40, { caughtData: caughtData(DAY, 30, 0, LANDMARK_NEW_BARK_TOWN) }),
mon(game, 155, 35, { nickname: "SMOKE", caughtData: LANDMARK_NEW_BARK_TOWN }),
];
seer(game, 0);
game.interact(); // still facing the Seer
const shown = settle(game, [1]).join(" ");
expect(shown).toContain("Its level was ???!");
expect(shown).toContain("The time was\nUnknown!");
// 35 - 30 = 5: the first row (9), not 35 - 0.
expect(shown).toContain("little more care.");
// After a load the scratch byte starts at 0 again: 35 - 0 = 35.
expect(seer(game, 1)).toContain("much strength.");
});
});
describe("the Photo Studio (PhotoStudio)", () => {
/** The photographer stands at (2, 3). */
const photo = (game: GameWorld, answers: (number | string)[]) =>
talk(game, PHOTO_STUDIO, 2, 4, "up", answers);
it("finds no printer and takes no photo", () => {
const { game } = world();
game.state.party = [mon(game, 155, 20), mon(game, 175, 5, { egg: true })];
const shown = photo(game, [1, 0, "b"]);
expect(shown).toContain("Which POKéMON\nshould I photo-\ngraph?");
expect(shown).toContain("Hold\nstill for a bit.");
// PrintPartymon's handshake reads $ff: PRINTER_ERROR_2.
expect(shown).toContain(
" Printer Error 2\n\nCheck the Game Boy\nPrinter Manual.\nPress B to Cancel",
);
expect(shown).toContain("Oh, no picture?\nCome again, OK?");
expect(shown).not.toContain("Presto");
expect(photo(game, [1, 1])).toContain("An EGG? My talent\nis worth more…");
expect(photo(game, [1, -1])).toContain("Oh, no picture?");
});
});
});