- 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:
1 parent
11d1153802
commit
796fe8f6d5
24 files changed
+1549
-59
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user