Enable Goldenrod City and its buildings, the department store with its elevator and rooftop, the Underground and its rooms, the Radio Tower floors and the Route 35 gate. Port what their scripts need: the herb shop, bargain shop, pharmacy and rooftop sale marts with ROM stock, prices and text; the Name Rater; the haircut brothers' happiness tables; the Lucky Number show over the party and boxes with its saved number and Friday countdown (RTC day timers with the 140-day wrap); coin and money balance boxes; Game Corner prize registration through NewPokedexEntry; gift Pokémon to the box when the party is full, with OT, nickname and MAIL (Kenya's 15-letter first line now stores). Saves gain hallOfFame, timers and luckyNumber, with defaults for older saves, and wUndergroundSwitchPositions joins the saved script bytes. The playthrough continues to the Bicycle, Whitney, the PLAINBADGE and the radio card. SlotMachine and CardFlip are still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { RomReader } from "../src/game/rom/reader";
|
|
import { GameWorld, migrateSave } from "../src/game/world/world";
|
|
import { toBoxMon } from "../src/game/party/storage";
|
|
import { OW } from "../src/game/world/constants";
|
|
import { rtcDay } from "../src/game/world/day-timers";
|
|
import { generatePartyMon } from "../src/game/party/pokemon";
|
|
import { ScriptRun, emptyScriptState } from "../src/game/scripts/interpreter";
|
|
import { defaultTextContext } from "../src/game/scripts/text";
|
|
import { PokemonData } from "../src/game/rom/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))(
|
|
"Goldenrod City with the local ROM",
|
|
() => {
|
|
let bytes: Uint8Array;
|
|
beforeAll(() => {
|
|
bytes = new Uint8Array(readFileSync(romPath));
|
|
});
|
|
const UNDERGROUND = "3:53",
|
|
ROOFTOP = "11:18",
|
|
NAME_RATER = "11:10",
|
|
RADIO_TOWER_1F = "3:17",
|
|
ROUTE_35_GATE = "10:14",
|
|
GAME_CORNER = "11:19",
|
|
POKECENTER = "11:20";
|
|
const POKE_BALL = 5,
|
|
NUGGET = 0x24,
|
|
HYPER_POTION = 0x10,
|
|
MASTER_BALL = 1,
|
|
COIN_CASE = 0x36,
|
|
FLOWER_MAIL = 0x9e,
|
|
SPEAROW = 21,
|
|
ABRA = 63,
|
|
CYNDAQUIL = 155;
|
|
|
|
/** A world on `date` with a level 20 Cyndaquil caught by the player. */
|
|
const world = (date: string) => {
|
|
let seed = 11;
|
|
const game = new GameWorld(
|
|
new CrystalAssets(bytes),
|
|
() => new Date(date),
|
|
() => {
|
|
seed = (seed * 1103515245 + 12345) >>> 0;
|
|
return (seed >>> 16) & 255;
|
|
},
|
|
);
|
|
game.state.party.push(
|
|
generatePartyMon(game.assets.pokemon, {
|
|
species: CYNDAQUIL,
|
|
level: 20,
|
|
item: 0,
|
|
otId: game.playerId,
|
|
ot: game.name,
|
|
dvs: 0x8888,
|
|
caughtData: 0,
|
|
}),
|
|
);
|
|
game.state.money = 20000;
|
|
return game;
|
|
};
|
|
/** Stand beside the object at (x, y) on `map` (across a counter if one
|
|
* is in between), face it and press A. */
|
|
const talk = (game: GameWorld, map: string, x: number, y: number) => {
|
|
const target = game.assets.map(map);
|
|
const STEP = {
|
|
up: [0, -1],
|
|
down: [0, 1],
|
|
left: [-1, 0],
|
|
right: [1, 0],
|
|
} as const;
|
|
for (const [face, [dx, dy]] of Object.entries(STEP) as [
|
|
keyof typeof STEP,
|
|
readonly [number, number],
|
|
][]) {
|
|
const across =
|
|
game.assets.collision(target, x - dx, y - dy) === 0x90 ? 2 : 1;
|
|
const px = x - dx * across,
|
|
py = y - dy * across;
|
|
try {
|
|
game.warpTo(map, px, py);
|
|
} catch {
|
|
continue;
|
|
}
|
|
settle(game);
|
|
game.move(face);
|
|
settle(game);
|
|
game.interact();
|
|
return;
|
|
}
|
|
throw new Error(`No place to talk to ${x},${y} on ${map}`);
|
|
};
|
|
/** Advance to the next choice, keeping the pages shown. */
|
|
const next = (game: GameWorld, pages: string[] = []) => {
|
|
for (let tick = 0; tick < 4000; tick++) {
|
|
const dialogue = game.dialogue;
|
|
if (dialogue) {
|
|
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
|
if (page && pages.at(-1) !== page) pages.push(page);
|
|
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
|
|
return dialogue.choice;
|
|
game.advanceDialogue();
|
|
} else if (game.busy) game.tick();
|
|
else return undefined;
|
|
}
|
|
throw new Error(`No choice: ${game.diagnostic}`);
|
|
};
|
|
const finish = (game: GameWorld) => {
|
|
const pages: string[] = [];
|
|
for (let step = 0; step < 100; step++) {
|
|
const choice = next(game, pages);
|
|
if (!choice) {
|
|
expect(game.notice, game.diagnostic).toBe("");
|
|
return pages.join(" ");
|
|
}
|
|
game.choose(choice.kind === "yesno" ? 0 : -1);
|
|
}
|
|
throw new Error(
|
|
`Interaction did not finish: ${game.dialogue?.choice?.kind}`,
|
|
);
|
|
};
|
|
|
|
it("runs the herb shop on Sunday with its own texts", () => {
|
|
const game = world("2026-09-20T12:00:00");
|
|
talk(game, UNDERGROUND, 7, 21);
|
|
const pages: string[] = [];
|
|
let choice = next(game, pages);
|
|
expect(choice?.kind).toBe("martlist");
|
|
if (choice?.kind !== "martlist") return;
|
|
const first = choice.entries[0];
|
|
expect(game.assets.pokemon.itemName(first.item)).toBe("ENERGYPOWDER");
|
|
game.choose(0);
|
|
choice = next(game, pages);
|
|
expect(choice).toMatchObject({
|
|
kind: "martquantity",
|
|
price: first.price,
|
|
});
|
|
game.choose(2);
|
|
expect(next(game, pages)?.kind).toBe("yesno");
|
|
game.choose(1);
|
|
const text = finish(game);
|
|
expect(game.state.bag.items).toContainEqual({
|
|
item: first.item,
|
|
quantity: 2,
|
|
});
|
|
expect(game.state.money).toBe(20000 - first.price * 2);
|
|
expect(pages.join(" ") + text).toMatch(/bitter/i);
|
|
});
|
|
|
|
it("sells one of each at the bargain shop, then closes for the day", () => {
|
|
const game = world("2026-09-21T08:00:00");
|
|
talk(game, UNDERGROUND, 7, 11);
|
|
let choice = next(game);
|
|
if (choice?.kind !== "martlist") throw new Error("No bargain list.");
|
|
expect(choice.entries[0]).toMatchObject({ item: NUGGET, price: 4500 });
|
|
game.choose(0);
|
|
expect(next(game)?.kind).toBe("yesno");
|
|
game.choose(1);
|
|
choice = next(game);
|
|
expect(choice?.kind).toBe("martlist");
|
|
// The Nugget is sold out for the rest of this visit.
|
|
game.choose(0);
|
|
const pages: string[] = [];
|
|
choice = next(game, pages);
|
|
expect(pages.join(" ")).toMatch(/sold out/i);
|
|
finish(game);
|
|
expect(game.state.bag.items).toContainEqual({
|
|
item: NUGGET,
|
|
quantity: 1,
|
|
});
|
|
expect(game.state.money).toBe(20000 - 4500);
|
|
expect(
|
|
game.state.flags.has(OW.ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("lists the rooftop sale's prices, and the second list after the Hall of Fame", () => {
|
|
const game = world("2026-09-22T12:00:00");
|
|
// Run the new day's reset before enabling its sale.
|
|
settle(game);
|
|
const ENGINE_GOLDENROD_DEPT_STORE_SALE_IS_ON = 97;
|
|
game.state.flags.add(ENGINE_GOLDENROD_DEPT_STORE_SALE_IS_ON);
|
|
talk(game, ROOFTOP, 1, 4);
|
|
let choice = next(game);
|
|
if (choice?.kind !== "martlist") throw new Error("No sale list.");
|
|
expect(choice.entries[0]).toMatchObject({ item: POKE_BALL, price: 150 });
|
|
finish(game);
|
|
game.state.hallOfFame = true;
|
|
talk(game, ROOFTOP, 1, 4);
|
|
choice = next(game);
|
|
if (choice?.kind !== "martlist") throw new Error("No sale list.");
|
|
expect(choice.entries[0]).toMatchObject({
|
|
item: HYPER_POTION,
|
|
price: 1000,
|
|
});
|
|
finish(game);
|
|
});
|
|
|
|
it("renames the player's own Pokémon and refuses a traded one", () => {
|
|
const game = world("2026-09-22T12:00:00");
|
|
talk(game, NAME_RATER, 2, 4);
|
|
expect(next(game)?.kind).toBe("yesno");
|
|
game.choose(1);
|
|
expect(next(game)?.kind).toBe("menu");
|
|
game.choose(0);
|
|
expect(next(game)?.kind).toBe("yesno");
|
|
game.choose(1);
|
|
expect(next(game)?.kind).toBe("name");
|
|
game.choose("BLAZE");
|
|
finish(game);
|
|
expect(game.state.party[0].nickname).toBe("BLAZE");
|
|
game.state.party[0].otId ^= 1;
|
|
talk(game, NAME_RATER, 2, 4);
|
|
game.choose(1);
|
|
next(game);
|
|
game.choose(0);
|
|
const text = finish(game);
|
|
expect(text).toMatch(/perfect/i);
|
|
expect(game.state.party[0].nickname).toBe("BLAZE");
|
|
});
|
|
|
|
it("gives Kenya with Randy's MAIL", () => {
|
|
const game = world("2026-09-22T12:00:00");
|
|
talk(game, ROUTE_35_GATE, 0, 4);
|
|
expect(next(game)?.kind).toBe("yesno");
|
|
game.choose(1);
|
|
finish(game);
|
|
const kenya = game.state.party.at(-1)!;
|
|
expect(kenya).toMatchObject({
|
|
species: SPEAROW,
|
|
nickname: "KENYA",
|
|
ot: "RANDY",
|
|
otId: 1001,
|
|
item: FLOWER_MAIL,
|
|
});
|
|
expect(kenya.mail).toMatchObject({
|
|
message: "DARK CAVE leads\nto another road",
|
|
author: "RANDY",
|
|
authorId: 1001,
|
|
species: SPEAROW,
|
|
});
|
|
// The save keeps the gift's MAIL.
|
|
const reloaded = world("2026-09-22T12:00:00");
|
|
reloaded.restore(JSON.parse(JSON.stringify(game.save())));
|
|
expect(reloaded.state.party.at(-1)!.mail?.message).toBe(
|
|
"DARK CAVE leads\nto another road",
|
|
);
|
|
});
|
|
|
|
it("trades coins for a prize Pokémon with the coin box on screen", () => {
|
|
const game = world("2026-09-22T12:00:00");
|
|
game.state.bag.keyItems = [COIN_CASE];
|
|
game.state.coins = 250;
|
|
talk(game, GAME_CORNER, 18, 2);
|
|
const choice = next(game);
|
|
expect(choice).toMatchObject({ kind: "menu", balance: "COIN 250" });
|
|
if (choice?.kind !== "menu") return;
|
|
expect(choice.items[0]).toMatch(/^ABRA\s+100$/);
|
|
game.choose(0);
|
|
expect(next(game)?.kind).toBe("yesno");
|
|
game.choose(1);
|
|
expect(next(game)).toMatchObject({
|
|
kind: "pokedex",
|
|
view: { entry: { species: ABRA, page: 1, caught: true, actions: [] } },
|
|
});
|
|
// A save during registration replays the purchase without a partial
|
|
// coin debit, Pokémon reward or Pokédex registration.
|
|
expect(game.save().coins).toBe(250);
|
|
expect(game.save().party).toHaveLength(1);
|
|
expect(game.save().pokedex.caught).not.toContain(ABRA);
|
|
game.choose("right");
|
|
expect(next(game)).toMatchObject({
|
|
kind: "pokedex",
|
|
view: { entry: { page: 1 } },
|
|
});
|
|
game.choose("b");
|
|
expect(next(game)).toMatchObject({
|
|
kind: "pokedex",
|
|
view: { entry: { page: 2 } },
|
|
});
|
|
game.choose("a");
|
|
finish(game);
|
|
expect(game.state.coins).toBe(150);
|
|
expect(game.state.party.at(-1)?.species).toBe(ABRA);
|
|
expect(game.state.pokedex.caught.has(ABRA)).toBe(true);
|
|
// A second prize of the same species skips registration.
|
|
talk(game, GAME_CORNER, 18, 2);
|
|
expect(next(game)?.kind).toBe("menu");
|
|
game.choose(0);
|
|
expect(next(game)?.kind).toBe("yesno");
|
|
game.choose(1);
|
|
expect(next(game)?.kind).toBe("yesno"); // nickname
|
|
finish(game);
|
|
expect(game.state.coins).toBe(50);
|
|
expect(
|
|
game.state.party.filter((mon) => mon.species === ABRA),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it.each(["party", "current box", "other box", "tied box", "worse box"])(
|
|
"finds Lucky Number winners: %s",
|
|
(location) => {
|
|
const game = world("2026-09-22T12:00:00");
|
|
// A number drawn today stays; the show is open again.
|
|
const today = Math.floor(Date.UTC(2026, 8, 22) / 86_400_000);
|
|
game.state.luckyNumber = { id: 12345, day: rtcDay(today) + 1 };
|
|
game.state.party[0].otId = 12345;
|
|
if (location !== "party") {
|
|
const boxed = toBoxMon(
|
|
generatePartyMon(game.assets.pokemon, {
|
|
species: ABRA,
|
|
level: 5,
|
|
item: 0,
|
|
otId: location === "worse box" ? 22345 : 12345,
|
|
ot: "KAWA",
|
|
dvs: 0,
|
|
caughtData: 0,
|
|
}),
|
|
);
|
|
game.state.storage.boxes[location === "other box" ? 1 : 0].push(
|
|
boxed,
|
|
);
|
|
if (location === "current box" || location === "other box")
|
|
game.state.party[0].otId = 11111;
|
|
}
|
|
talk(game, RADIO_TOWER_1F, 8, 6);
|
|
const text = finish(game);
|
|
const boxWins = location === "current box" || location === "other box";
|
|
expect(text).toContain(boxWins ? "ABRA" : "CYNDAQUIL");
|
|
expect(text).toContain(boxWins ? "PC" : "party");
|
|
expect(game.state.bag.balls).toContainEqual({
|
|
item: MASTER_BALL,
|
|
quantity: 1,
|
|
});
|
|
expect(game.state.flags.has(OW.ENGINE_LUCKY_NUMBER_SHOW)).toBe(true);
|
|
// Until next Friday (three days from Tuesday) the show is over.
|
|
expect(game.state.timers.luckyNumber).toEqual({
|
|
remaining: 3,
|
|
day: rtcDay(today),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("migrates older saves and preserves or rejects the new daily state", () => {
|
|
const game = world("2026-09-22T12:00:00");
|
|
const old = JSON.parse(JSON.stringify(game.save()));
|
|
delete old.hallOfFame;
|
|
delete old.timers;
|
|
delete old.luckyNumber;
|
|
expect(migrateSave(old, () => 0)).toMatchObject({
|
|
hallOfFame: false,
|
|
timers: {},
|
|
luckyNumber: { id: 0, day: 0 },
|
|
});
|
|
game.state.hallOfFame = true;
|
|
game.state.timers.luckyNumber = { remaining: 7, day: 139 };
|
|
game.state.luckyNumber = { id: 65535, day: 140 };
|
|
const save = JSON.parse(JSON.stringify(game.save()));
|
|
const reloaded = world("2026-09-22T12:00:00");
|
|
reloaded.restore(save);
|
|
expect(reloaded.save()).toMatchObject({
|
|
hallOfFame: true,
|
|
timers: { luckyNumber: { remaining: 7, day: 139 } },
|
|
luckyNumber: { id: 65535, day: 140 },
|
|
});
|
|
for (const invalid of [
|
|
{ hallOfFame: 1 },
|
|
{ timers: { luckyNumber: { remaining: -1, day: 0 } } },
|
|
{ timers: { luckyNumber: { remaining: 7, day: 140 } } },
|
|
{ luckyNumber: { id: 65536, day: 1 } },
|
|
])
|
|
expect(() => reloaded.restore({ ...save, ...invalid })).toThrow(
|
|
"Invalid native save",
|
|
);
|
|
});
|
|
|
|
it("enters Goldenrod's Pokémon Center past the GS Ball check", () => {
|
|
const game = world("2026-09-22T12:00:00");
|
|
game.restore({ ...game.save(), map: POKECENTER, x: 3, y: 6 });
|
|
settle(game);
|
|
game.move("down");
|
|
settle(game);
|
|
expect(game.notice, game.diagnostic).toBe("");
|
|
});
|
|
|
|
it("sends a given Pokémon to the box when the party is full", () => {
|
|
const reader = new RomReader(bytes);
|
|
const data = new PokemonData(reader);
|
|
const state = emptyScriptState();
|
|
for (let i = 0; i < 6; i++)
|
|
state.party.push(
|
|
generatePartyMon(data, {
|
|
species: CYNDAQUIL,
|
|
level: 5,
|
|
item: 0,
|
|
otId: 1,
|
|
ot: "KAWA",
|
|
dvs: 0,
|
|
caughtData: 0,
|
|
}),
|
|
);
|
|
// givepoke EEVEE, 20 then end, in a free spot of the ROM.
|
|
const rom = new Uint8Array(bytes);
|
|
rom.set([0x2d, 133, 20, 0, 0, 0x91], 0x3ff0);
|
|
const run = new ScriptRun(new RomReader(rom), 0x3ff0, state, {
|
|
mapId: "11:2",
|
|
lastTalked: 0,
|
|
sceneOwner: () => undefined,
|
|
timeOfDay: () => 1,
|
|
clock: () => ({ weekday: 2, hours: 12, minutes: 0 }),
|
|
clockDay: () => 2,
|
|
text: () => defaultTextContext("KAWA"),
|
|
data,
|
|
player: () => ({ id: 1, gender: 0 }),
|
|
random: () => 0,
|
|
landmark: () => 10,
|
|
facing: () => 0,
|
|
position: () => ({ x: 0, y: 0 }),
|
|
objectPosition: () => undefined,
|
|
playerCollision: () => 0,
|
|
environment: () => 0,
|
|
phoneService: () => 0,
|
|
facingObject: () => undefined,
|
|
});
|
|
let effect = run.resume();
|
|
expect(effect).toMatchObject({ kind: "yesno" }); // a nickname?
|
|
effect = run.resume(0);
|
|
expect(effect?.kind).toBe("text");
|
|
if (effect?.kind === "text")
|
|
expect(effect.pages.join(" ").replace(/\n/g, " ")).toContain(
|
|
"EEVEE was sent to BILL's PC.",
|
|
);
|
|
expect(run.resume()).toBeUndefined();
|
|
expect(run.state.party).toHaveLength(6);
|
|
const box = run.state.storage.boxes[run.state.storage.current];
|
|
expect(box[0]).toMatchObject({
|
|
species: 133,
|
|
level: 20,
|
|
ot: "KAWA",
|
|
otId: 1,
|
|
});
|
|
expect(run.state.pokedex.caught.has(133)).toBe(true);
|
|
});
|
|
},
|
|
);
|