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

+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);
});
});