- 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>
283 lines
12 KiB
TypeScript
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?");
|
|
});
|
|
});
|
|
});
|