Open Route 29 and Cherrygrove City
Checks / web (push) Canceled after 0s

- Enable Route 29, its Route 46 gate, Cherrygrove City and its buildings.
- Run item balls through FindItemInBallScript and fruit trees through
  FruitTreeScript, with a native daily reset of the daily engine flags.
- Port the Pokémon Center nurse (HealParty with the PP Up clamp,
  CheckPokerus, phone-call checks) and record the whiteout spawn on
  entering a Pokémon Center, as EnterMapWarp does.
- Port standard Poké Marts with keyboard, D-pad and pointer menus.
- Add the recoil battle command (Struggle) and VAR_BOXSPACE.
- Save fruit trees, the daily reset day and the last spawn map; older
  version-3 backups migrate by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-23 17:25:02 +02:00
1 parent 11d1153802
commit 796fe8f6d5
24 files changed
+1549 -59

No files matched your search

+10
View File
@@ -54,6 +54,16 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
expect(world.save()).toEqual(saved);
});
it("struggles with recoil once every move is out of PP", () => {
const world = fixture();
world.state.party[0].pp = [0, 0, 0, 0];
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toMatch(/STRUGGLE/);
expect(messages).toMatch(/recoil/i);
});
it("escapes without awarding experience and returns to walking", () => {
const world = fixture();
encounter(world);
+78
View File
@@ -0,0 +1,78 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc");
async function start(page: Page) {
// Replace only this test's runtime module response, the same way
// battle-browser.spec.ts substitutes an encounter fixture: no fixture
// import or map override exists in the application or production build.
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch();
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle,
`const { martFixtureWorld } = await import('/tests/mart-fixture.ts');
const world = martFixtureWorld(assets);`) });
});
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
await page.locator("#play").click();
}
async function exportState(page: Page) {
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const path = (await (await download).path())!;
const record = JSON.parse(readFileSync(path, "utf8"));
await page.locator("#tab-game").click();
return JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
}
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM; only map-enabling is a test fixture."));
test("buys a Potion from the Cherrygrove clerk with the keyboard", async ({ page }) => {
await start(page);
// The fixture places the player at the counter, already facing the clerk.
await page.keyboard.press("KeyX");
await expect(page.locator("#mart")).toBeVisible();
await expect(page.locator("#mart-menu")).toContainText("BUY");
await page.keyboard.press("KeyX"); // BUY is already highlighted
await expect(page.locator("#mart-menu")).toContainText("POTION");
await page.keyboard.press("KeyX"); // the Potion is first and already highlighted
await expect(page.locator(".mart-quantity output")).toHaveText(/×1/);
await page.keyboard.press("ArrowUp"); // quantity 2, exercising the stepper
await expect(page.locator(".mart-quantity output")).toHaveText(/×2/);
await page.keyboard.press("KeyX"); // confirm the quantity
await expect(page.locator("#dialogue-text")).toContainText("will be");
await expect(page.locator("#dialogue-choice")).toContainText("YES");
await page.keyboard.press("KeyX"); // YES is already highlighted
await expect(page.locator("#dialogue-text")).toContainText("Here you are");
await page.keyboard.press("KeyX"); // continue
// Back at the item list (BuyMenu loops for another purchase); leave.
await expect(page.locator("#mart-menu")).toContainText("POTION");
await page.keyboard.press("KeyZ"); // back out of the list
await page.keyboard.press("KeyZ"); // quit the BUY/SELL/QUIT menu
await expect(page.locator("#dialogue-text")).toContainText("Please come again");
await page.keyboard.press("KeyX");
await expect(page.locator("#mart")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
const state = await exportState(page);
expect(state.money).toBe(3000 - 300 * 2);
expect(state.bag.items).toEqual([{ item: 18, quantity: 2 }]);
});
+31
View File
@@ -0,0 +1,31 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
/** Places the player at the Cherrygrove Mart counter (facing the clerk
* behind it at 1,3 through the counter tile at 2,3) so `interact()` talks
* to them without walking the overworld from New Bark Town. */
export function martFixtureWorld(assets: CrystalAssets) {
let seed = 1;
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
const save = world.save();
save.map = "26:4";
save.x = 3;
save.y = 3;
save.direction = "left";
world.restore(save);
// The first idle tick runs the new game's daily reset.
world.tick();
return world;
}
/** Talk to the clerk and let the mart's welcome screen present. */
export function talkToClerk(world: GameWorld) {
world.interact();
}
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { C } from "../src/game/battle/constants";
import { martFixtureWorld, talkToClerk } from "./mart-fixture";
import { settle } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = () => martFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))));
// constants/item_constants.asm
const POKE_BALL = 5,
BICYCLE = 7,
POTION = 18;
describe.skipIf(!existsSync(romPath))("Cherrygrove Mart with the local ROM", () => {
it("buys Potions at the ROM price and adds them to the bag", () => {
const world = fixture();
const money = world.state.money;
talkToClerk(world);
// BUY -> Potion (list index 0) -> quantity 3 -> confirm YES -> back -> QUIT
const shown = settle(world, [0, 0, 3, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toContain("Here you are");
expect(world.state.money).toBe(money - 300 * 3);
expect(world.state.bag.items).toEqual([{ item: POTION, quantity: 3 }]);
});
it("refuses to buy without enough money, changing nothing", () => {
const world = fixture();
world.state.money = 100; // less than one Potion (300)
talkToClerk(world);
const committed = world.save();
const shown = settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/enough money/i);
expect(world.save()).toEqual(committed);
});
it("refuses to buy into a full pocket, changing nothing", () => {
const world = fixture();
const data = world.assets.pokemon;
const fillers: number[] = [];
for (let id = 1; fillers.length < 20; id++) {
if (id === POTION) continue;
try {
if (data.itemPocket(id) === "item") fillers.push(id);
} catch {
// Not every id names an item; skip gaps.
}
}
world.state.bag.items = fillers.map((item) => ({ item, quantity: 1 }));
talkToClerk(world);
const committed = world.save();
const shown = settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/can't carry/i);
expect(world.save()).toEqual(committed);
});
it("sells items at half the ROM price", () => {
const world = fixture();
world.state.bag.items = [{ item: POTION, quantity: 5 }];
talkToClerk(world);
const money = world.state.money;
// SELL -> Potion (index 0) -> quantity 2 -> confirm YES -> back -> QUIT
const shown = settle(world, [1, 0, 2, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toContain("Got ¥300");
expect(world.state.money).toBe(money + 300); // 300/2 * 2
expect(world.state.bag.items).toEqual([{ item: POTION, quantity: 3 }]);
});
it("caps money at C.MAX_MONEY when selling", () => {
const world = fixture();
world.state.bag.items = [{ item: POTION, quantity: 1 }];
world.state.money = C.MAX_MONEY - 10;
talkToClerk(world);
const shown = settle(world, [1, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).not.toBe("");
expect(world.state.money).toBe(C.MAX_MONEY);
expect(world.state.bag.items).toEqual([]);
});
it("excludes key items from the sell list", () => {
const world = fixture();
world.state.bag.items = [{ item: POTION, quantity: 1 }];
world.state.bag.keyItems = [BICYCLE];
talkToClerk(world);
world.choose(1); // SELL
const choice = world.dialogue?.choice;
expect(choice?.kind).toBe("martlist");
if (choice?.kind === "martlist") {
expect(choice.entries.map((entry) => entry.item)).toEqual([POTION]);
}
});
it("sells a Poké Ball but refuses to sell key items or unaffordable buys together", () => {
// MART_CHERRYGROVE_DEX stocks a Poké Ball once the mystery egg is given;
// exercising a ball-pocket item confirms pockets beyond "item" work too.
const world = fixture();
world.state.events.add(31); // EVENT_GAVE_MYSTERY_EGG_TO_ELM
talkToClerk(world);
const money = world.state.money;
// BUY -> Poké Ball (list index 0 in the dex mart) -> quantity 1 -> YES -> back -> QUIT
settle(world, [0, 0, 1, 1, -1, 2]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.money).toBe(money - 200);
expect(world.state.bag.balls).toEqual([{ item: POKE_BALL, quantity: 1 }]);
});
it("quits immediately without changing state", () => {
const world = fixture();
const before = world.save();
talkToClerk(world);
settle(world, [2]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.save()).toEqual(before);
});
it("a mid-interaction save restores the committed state and replays it", () => {
const world = fixture();
talkToClerk(world);
const committed = world.save();
world.choose(0); // BUY
world.choose(0); // Potion -> quantity picker
expect(world.dialogue?.choice?.kind).toBe("martquantity");
expect(world.save()).toEqual(committed);
world.restore(committed);
expect(world.dialogue).toBeUndefined();
expect(world.save()).toEqual(committed);
// The world is usable again: a fresh visit still works.
talkToClerk(world);
expect(world.dialogue?.choice?.kind).toBe("martmenu");
});
});
+50
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { VECTORS } from "../src/game/world/types";
@@ -362,6 +363,55 @@ test("a home exit mat survives save/reload and waits for the exit direction", as
await expect(page.locator("#game-notice")).toHaveText("");
});
test("walks from New Bark Town onto Route 29 after receiving a starter", async ({
page,
}, testInfo) => {
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
const bytes = readFileSync(romPath);
const assets = new CrystalAssets(new Uint8Array(bytes));
const world = new GameWorld(assets);
const town = assets.map("24:4");
const edge = Array.from({ length: town.height }, (_, y) => y).find(
(y) => assets.permission(assets.collision(town, 0, y)) === 0,
)!;
world.state.party.push(
generatePartyMon(assets.pokemon, {
species: 155, level: 5, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
}),
);
// SCENE_NEWBARKTOWN_NOOP: after the starter the teacher no longer stops you.
world.restore({ ...world.save(), map: "24:4", x: 0, y: edge, direction: "left", scenes: { "24:4": 1 } });
const backup = testInfo.outputPath("new-bark-edge.crystal-save.json");
writeFileSync(
backup,
JSON.stringify({
version: 1,
romHash: createHash("sha1").update(bytes).digest("hex"),
coreId: "crystal-native-world-v1",
savedAt: new Date().toISOString(),
state: Buffer.from(JSON.stringify(world.save())).toString("base64"),
}),
);
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#tab-saves").click();
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(backup);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.locator("#play").click();
const screen = page.locator("#game-screen");
await page.keyboard.down("ArrowLeft");
await page.waitForFunction(
() => document.querySelector("#game-screen")?.getAttribute("data-map") === "24:3",
);
await page.keyboard.up("ArrowLeft");
await expect(screen).toHaveAttribute("data-activity", "idle");
await expect(page.locator("#location")).toHaveText("Route 29");
await expect(page.locator("#game-notice")).toHaveText("");
});
test("development server does not serve ROMs or the private reference checkout", async ({
request,
}) => {
+254 -13
View File
@@ -5,12 +5,13 @@ import { RomReader } from "../src/game/rom/reader";
import { decompress } from "../src/game/rom/lz";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { NATIVE_MAPS, SYMBOLS } from "../src/game/rom/profile";
import {
ScriptRun,
emptyScriptState,
runScript,
} from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import { readMovement } from "../src/game/scripts/movement";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { settle, walkTo } from "./world-driver";
@@ -213,7 +214,7 @@ describe.skipIf(!existsSync(romPath))(
const west = town.connections.find((c) => c.direction === "left")!;
const route = assets.map(west.targetMap);
expect(route.location).toBe("Route 29");
expect(assets.isPorted(route.id)).toBe(false);
expect(assets.isPorted(route.id)).toBe(true);
for (let y = 0; y < town.height; y++)
expect(assets.visibleBlock(town, -1, y)).toBe(
assets.block(route, west.x, y + west.y),
@@ -381,10 +382,6 @@ describe.skipIf(!existsSync(romPath))(
it("jumps over representative Route 29 ledges from the actual collision data", () => {
const assets = load();
const route = assets.map("24:3");
expect(assets.isPorted(route.id)).toBe(false);
// Enable only in this fixture: production still refuses Route 29's unported scripts.
const isPorted = assets.isPorted.bind(assets);
assets.isPorted = (id) => id === route.id || isPorted(id);
const world = new GameWorld(assets);
const checked = new Set<number>();
const directions: Record<number, Direction> = {
@@ -428,19 +425,88 @@ describe.skipIf(!existsSync(romPath))(
}
expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]);
});
it("gives the Route 29 item ball's Potion through FindItemInBallScript, hides the ball and commits atomically", () => {
const assets = load();
const route = assets.map("24:3");
const world = new GameWorld(assets);
const ball = route.objects.find((object) => object.kind === 1)!; // OBJECTTYPE_ITEMBALL
// The tile west of the ball is the only walkable approach.
world.restore({
...world.save(),
map: route.id,
x: ball.x - 1,
y: ball.y,
direction: "right",
objects: [],
});
expect(world.objects().some((object) => object.id === ball.id)).toBe(
true,
);
world.interact();
expect(settle(world)).toEqual([
"CHRIS found\nPOTION!",
"CHRIS put the\nPOTION in\nthe ITEM POCKET.",
]);
expect(world.diagnostic).toBe("");
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
expect(world.state.events.has(ball.eventFlag)).toBe(true);
// The ball disappears for the rest of the session...
expect(world.objects().some((object) => object.id === ball.id)).toBe(
false,
);
// ...and the pickup survives save()/restore().
const saved = world.save();
const restored = new GameWorld(assets);
restored.restore(saved);
expect(restored.save()).toEqual(saved);
expect(restored.objects().some((object) => object.id === ball.id)).toBe(
false,
);
});
it("shows the original no-room text and changes nothing when the item pocket is full", () => {
const assets = load();
const route = assets.map("24:3");
const world = new GameWorld(assets);
const ball = route.objects.find((object) => object.kind === 1)!;
const full = world.save();
// Twenty full POTION stacks: PutItemInPocket finds no room for another.
full.bag.items = Array.from({ length: 20 }, () => ({
item: 18,
quantity: 99,
}));
world.restore({
...full,
map: route.id,
x: ball.x - 1,
y: ball.y,
direction: "right",
objects: [],
});
world.interact();
expect(settle(world)).toEqual([
"CHRIS found\nPOTION!",
"But CHRIS can't\ncarry any more\nitems.",
]);
expect(world.diagnostic).toBe("");
expect(world.state.bag).toEqual(full.bag);
expect(world.state.events.has(ball.eventFlag)).toBe(false);
expect(world.objects().some((object) => object.id === ball.id)).toBe(
true,
);
});
it("refuses connections into unported maps and imports version 1 saves", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
const town = world.assets.map("24:4");
const edge = Array.from({ length: town.height }, (_, y) => y).find(
(y) =>
world.assets.permission(world.assets.collision(town, 0, y)) === 0,
const city = world.assets.map("26:3");
const edge = Array.from({ length: city.width }, (_, x) => x).find(
(x) =>
world.assets.permission(world.assets.collision(city, x, 0)) === 0,
)!;
world.restore({ ...world.save(), map: "24:4", x: 0, y: edge });
expect(world.move("left")).toBe(false);
expect(world.notice).toBe("Route 29 is not ported yet.");
world.restore({ ...world.save(), map: "26:3", x: edge, y: 0 });
expect(world.move("up")).toBe(false);
expect(world.notice).toBe("Route 30 is not ported yet.");
const {
scenes,
phone,
@@ -635,5 +701,180 @@ describe.skipIf(!existsSync(romPath))(
world.interact();
expect(settle(world)).toHaveLength(1);
});
it("heals a damaged, statused party through the Cherrygrove nurse at every time of day", () => {
const greetings = {
morning: ["Good morning!\nWelcome to our\nPOKéMON CENTER."],
day: ["Hello!\nWelcome to our\nPOKéMON CENTER."],
night: [
"Good evening!\nYou're out late.",
"Welcome to our\nPOKéMON CENTER.",
],
};
for (const [label, hour] of [
["morning", 8],
["day", 13],
["night", 21],
] as const) {
const assets = load();
const world = new GameWorld(
assets,
() => new Date(`2026-09-22T${String(hour).padStart(2, "0")}:00:00`),
);
const mon = generatePartyMon(assets.pokemon, {
species: 155,
level: 10,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
mon.moves = [45, 0, 0, 0]; // GROWL: base PP 40
mon.pp = [(3 << 6) | 2, 0, 0, 0]; // three PP Ups, nearly spent
mon.hp = 1;
mon.status = 0x08; // paralysis
world.state.party.push(mon);
world.restore({
...world.save(),
map: "26:5",
x: 3,
y: 3,
direction: "up",
});
// The counter at (3,2) lets interact() reach the nurse behind it.
world.interact();
expect(world.dialogue?.pages).toEqual(greetings[label]);
const pages = settle(world, [1]);
expect(pages).toContain("Your POKéMON are\nfully healed.");
const healed = world.save().party[0];
expect(healed.hp).toBe(healed.stats[0]);
expect(healed.status).toBe(0);
// ComputeMaxPP clamps each PP Up's bonus to 7, not 8: 40 + 3*7 = 61.
expect(healed.pp).toEqual([(3 << 6) | 61, 0, 0, 0]);
expect(world.diagnostic).toBe("");
}
});
it("leaves the party unchanged when healing is declined", () => {
const assets = load();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T13:00:00"),
);
const mon = generatePartyMon(assets.pokemon, {
species: 155,
level: 10,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
mon.hp = 1;
mon.status = 0x08;
world.state.party.push(mon);
world.restore({
...world.save(),
map: "26:5",
x: 3,
y: 3,
direction: "up",
});
world.interact();
const before = structuredClone(world.save().party);
const pages = settle(world, [0]);
expect(pages.at(-1)).toBe("We hope to see you\nagain.");
expect(pages).not.toContain("Your POKéMON are\nfully healed.");
expect(world.save().party).toEqual(before);
expect(world.diagnostic).toBe("");
});
it("records the whiteout spawn by walking into the Cherrygrove Pokécenter, and keeps it through save/restore", () => {
const assets = load();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T13:00:00"),
);
expect(world.save().lastSpawnMap).toBeNull();
world.restore({
...world.save(),
map: "26:3",
x: 29,
y: 4,
direction: "up",
});
// The Center's door is an automatic warp; EnterMapWarp's SetSpawn
// records the outdoor map (Cherrygrove City) as the new blackout spot.
expect(world.move("up")).toBe(true);
expect(world.mapId).toBe("26:5");
expect([world.x, world.y]).toEqual([3, 7]);
expect(world.save().lastSpawnMap).toBe("26:3");
const restored = new GameWorld(
world.assets,
() => new Date("2026-09-22T13:00:00"),
);
restored.restore(world.save());
expect(restored.save()).toEqual(world.save());
});
it("honors the recorded spawn for the whiteout warp, and defaults to the player's house", () => {
const assets = load();
const host = {
mapId: "1:1",
lastTalked: 0,
sceneOwner: () => undefined,
timeOfDay: () => 1,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0,
text: () => defaultTextContext("CHRIS"),
data: assets.pokemon,
player: () => ({ id: 0, gender: 0 as const }),
random: () => 0,
landmark: () => 0,
facing: () => 0,
position: () => ({ x: 0, y: 0 }),
};
// Runs the ROM's own battleWhiteoutScript end to end: HealParty,
// GetWhiteoutSpawn and WarpToSpawnPoint all through the interpreter.
const whiteoutWarp = (lastSpawnMap: string | null) => {
const state = emptyScriptState();
state.lastSpawnMap = lastSpawnMap;
const run = new ScriptRun(
assets.reader,
SYMBOLS.battleWhiteoutScript,
state,
host,
);
let effect = run.resume();
while (effect && effect.kind !== "warp") effect = run.resume();
return effect;
};
expect(whiteoutWarp("26:3")).toEqual({
kind: "warp",
map: "26:3",
x: 29,
y: 4,
});
// No recorded Center, or one the spawn table no longer lists: SPAWN_HOME.
expect(whiteoutWarp(null)).toEqual({
kind: "warp",
map: "24:7",
x: 3,
y: 3,
});
expect(whiteoutWarp("1:1")).toEqual({
kind: "warp",
map: "24:7",
x: 3,
y: 3,
});
});
it("migrates a save without the spawn field to the player's house default", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T13:00:00"),
);
const { lastSpawnMap, ...withoutSpawn } = world.save();
void lastSpawnMap;
expect(() => world.restore(withoutSpawn)).not.toThrow();
expect(world.save().lastSpawnMap).toBeNull();
});
},
);
+2 -1
View File
@@ -4,6 +4,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
import { BattleData } from "../src/game/battle/data";
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { resolve } from "node:path";
const romPath = resolve(
@@ -96,7 +97,7 @@ test("native decompression matches original GB assembly executing in the referen
reference.dispose();
return comparisons;
});
expect(result.length).toBe(6);
expect(result.length).toBe(Object.keys(NATIVE_MAPS).length);
for (const comparison of result) {
expect(
comparison.returned,
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { settle, findPath, walkTo } from "./world-driver";
import { generatePartyMon } from "../src/game/party/pokemon";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const BERRY = 0xad;
const ROUTE_29 = "24:3";
describe.skipIf(!existsSync(romPath))("Route 29 with the local ROM", () => {
/** `now` is mutable per test. */
const fixture = (date = "2026-09-22T12:00:00") => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const clock = { now: new Date(date) };
const world = new GameWorld(assets, () => clock.now);
// Facing FRUITTREE_ROUTE_29 at (12, 2).
world.restore({ ...world.save(), map: ROUTE_29, x: 12, y: 3, direction: "up" });
world.tick();
return { world, clock };
};
const berries = (world: GameWorld) =>
world.state.bag.items.find((slot) => slot.item === BERRY)?.quantity ?? 0;
const pick = (world: GameWorld) => {
world.interact();
const pages = settle(world);
expect(world.notice, world.diagnostic).toBe("");
return pages.join("\n");
};
it("picks a Berry once per day and regrows after the daily reset", () => {
const { world, clock } = fixture();
expect(world.state.dailyReset).not.toBeNull();
expect(pick(world)).toMatch(/BERRY/);
expect(berries(world)).toBe(1);
expect(world.state.fruitTrees).toEqual(new Set([1]));
expect(world.state.flags.has(ENGINE_FLAGS.allFruitTrees)).toBe(true);
// Same day, even across a save and reload: nothing to pick.
const saved = world.save();
expect(saved).toMatchObject({ fruitTrees: [1] });
world.restore(saved);
expect(world.save()).toEqual(saved);
pick(world);
expect(berries(world)).toBe(1);
// The first idle tick of the next day clears the daily flag.
clock.now = new Date("2026-09-23T00:01:00");
world.tick();
expect(world.state.flags.has(ENGINE_FLAGS.allFruitTrees)).toBe(false);
expect(pick(world)).toMatch(/BERRY/);
expect(berries(world)).toBe(2);
});
it("leaves the tree unpicked when the item pocket is full", () => {
const { world } = fixture();
world.state.bag.items = Array.from({ length: 20 }, (_, index) => ({
item: index + 1,
quantity: 1,
}));
const before = world.state.bag.items.map((slot) => ({ ...slot }));
pick(world);
expect(world.state.bag.items).toEqual(before);
expect(world.state.fruitTrees.size).toBe(0);
});
it("migrates version-3 saves without daily state and rejects invalid trees", () => {
const { world } = fixture();
const { fruitTrees: _, dailyReset: __, ...legacy } = world.save();
expect(migrateSave(legacy, () => 0)).toMatchObject({
fruitTrees: [],
dailyReset: null,
});
expect(() => world.restore({ ...world.save(), fruitTrees: [0] })).toThrow(
"Invalid native save",
);
expect(() => world.restore({ ...world.save(), fruitTrees: [1, 1] })).toThrow(
"Invalid native save",
);
});
it("walks from New Bark Town across Route 29 into Cherrygrove and a house", () => {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
// Random byte 255 never passes an encounter-rate check.
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 255);
world.state.party.push(
generatePartyMon(assets.pokemon, {
species: 155, level: 5, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
}),
);
// After the starter, Elm sets SCENE_NEWBARKTOWN_NOOP: the teacher lets you go.
world.restore({ ...world.save(), map: "24:4", x: 5, y: 8, scenes: { "24:4": 1 } });
const crossWest = (next: string) => {
const edge = Array.from({ length: world.map.height }, (_, y) => y).find((y) => {
try {
findPath(world, 0, y);
return true;
} catch {
return false;
}
})!;
walkTo(world, 0, edge);
expect(world.move("left"), world.notice).toBe(true);
settle(world);
expect(world.mapId).toBe(next);
expect(world.notice, world.diagnostic).toBe("");
};
crossWest(ROUTE_29);
crossWest("26:3");
walkTo(world, 17, 8);
expect(world.move("up")).toBe(true);
settle(world);
expect(world.mapId).toBe("26:6");
expect(world.notice, world.diagnostic).toBe("");
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
});