- 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
+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();
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user