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

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