Implement native opening scenes, battle foundation and overworld movement
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
This commit is contained in:
1 parent
8b80d05ca2
commit
c08d250cf6
39 files changed
+10727
-1524
No files matched your search
+502
-88
@@ -6,9 +6,17 @@ 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 { runScript } from "../src/game/scripts/interpreter";
|
||||
import {
|
||||
ScriptRun,
|
||||
emptyScriptState,
|
||||
runScript,
|
||||
} from "../src/game/scripts/interpreter";
|
||||
import { readMovement } from "../src/game/scripts/movement";
|
||||
import { defaultTextContext, readText } from "../src/game/scripts/text";
|
||||
import { settle, walkTo } from "./world-driver";
|
||||
import { drawTile } from "../src/game/rendering/pixels";
|
||||
import { CRYSTAL_ROM } from "../src/rom/validate";
|
||||
import { VECTORS, type Direction } from "../src/game/world/types";
|
||||
|
||||
describe("native ROM formats", () => {
|
||||
it("resolves fixed and banked pointers and rejects out-of-bounds reads", () => {
|
||||
@@ -57,24 +65,73 @@ describe("native ROM formats", () => {
|
||||
});
|
||||
it("aborts unknown script commands without committing earlier event writes", () => {
|
||||
const reader = new RomReader(Uint8Array.from([0x33, 5, 0, 0x0e, 0, 0, 0]));
|
||||
const state = {
|
||||
events: new Set<number>(),
|
||||
flags: new Set<number>(),
|
||||
variables: {},
|
||||
};
|
||||
const state = emptyScriptState();
|
||||
expect(() => runScript(reader, 0, state, "TEST")).toThrow("not ported");
|
||||
expect(state.events.size).toBe(0);
|
||||
});
|
||||
it("bounds looping scripts", () => {
|
||||
const reader = new RomReader(Uint8Array.from([0x03, 0, 0]));
|
||||
expect(() => runScript(reader, 0, emptyScriptState(), "TEST")).toThrow(
|
||||
"budget",
|
||||
);
|
||||
});
|
||||
it("resumes scripts after choices and writes only to a private draft", () => {
|
||||
const bytes = new Uint8Array(0x100);
|
||||
// writetext $40; yesorno; iftrue $10; end; $10: setevent 7; end
|
||||
bytes.set([0x4c, 0x40, 0x00, 0x4e, 0x09, 0x10, 0x00, 0x91], 0);
|
||||
bytes.set([0x33, 7, 0, 0x91], 0x10);
|
||||
// "HI" then the player's name through text_ram, then done.
|
||||
bytes.set([0x00, 0x87, 0x88, 0x7f, 0x52, 0x57], 0x40);
|
||||
const state = emptyScriptState();
|
||||
const host = {
|
||||
mapId: "1:1",
|
||||
lastTalked: 0,
|
||||
sceneOwner: () => undefined,
|
||||
timeMask: () => 2,
|
||||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||||
deviceWeekday: () => 0,
|
||||
text: () => defaultTextContext("KAWA"),
|
||||
};
|
||||
const yes = new ScriptRun(new RomReader(bytes), 0, state, host);
|
||||
expect(yes.resume()).toEqual({ kind: "yesno", pages: ["HI KAWA"] });
|
||||
expect(yes.resume(1)).toBeUndefined();
|
||||
expect(yes.state.events.has(7)).toBe(true);
|
||||
expect(state.events.size).toBe(0);
|
||||
const no = new ScriptRun(new RomReader(bytes), 0, state, host);
|
||||
no.resume();
|
||||
expect(no.resume(0)).toBeUndefined();
|
||||
expect(no.state.events.size).toBe(0);
|
||||
});
|
||||
it("decodes movement data and refuses unported movement commands", () => {
|
||||
const reader = new RomReader(
|
||||
Uint8Array.from([
|
||||
0x02, 0x0f, 0x08, 0x31, 0x3b, 0x40, 0x46, 12, 0x47, 0x4c, 0x47,
|
||||
]),
|
||||
);
|
||||
expect(readMovement(reader, 0)).toEqual([
|
||||
{ kind: "turn", direction: "left", ticks: 1 },
|
||||
{ kind: "step", direction: "right", ticks: 8, animate: true },
|
||||
{ kind: "step", direction: "down", ticks: 16, animate: true },
|
||||
{ kind: "jump", direction: "up", ticks: 8 },
|
||||
{ kind: "fix-facing", fixed: true },
|
||||
{ kind: "sleep", ticks: 3 },
|
||||
{ kind: "sleep", ticks: 12 },
|
||||
]);
|
||||
expect(() => readMovement(reader, 9)).toThrow("not ported");
|
||||
});
|
||||
it("reads RAM-backed names and string buffers only from native values", () => {
|
||||
// text_ram wStringBuffer4, " ", text_buffer 0, done
|
||||
const bytes = Uint8Array.from([
|
||||
0x01, 0xac, 0xd0, 0x00, 0x7f, 0x50, 0x14, 0x00, 0x50, 0x50,
|
||||
]);
|
||||
const context = {
|
||||
...defaultTextContext("KAWA"),
|
||||
strings: { 0xd0ac: "POKéGEAR", 0xd099: "ELM" },
|
||||
};
|
||||
expect(readText(new RomReader(bytes), 0, context)).toBe("POKéGEAR ELM");
|
||||
expect(() =>
|
||||
runScript(
|
||||
reader,
|
||||
0,
|
||||
{ events: new Set(), flags: new Set(), variables: {} },
|
||||
"TEST",
|
||||
),
|
||||
).toThrow("budget");
|
||||
readText(new RomReader(bytes), 0, defaultTextContext("KAWA")),
|
||||
).toThrow("no native value");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,84 +186,438 @@ describe.skipIf(!existsSync(romPath))(
|
||||
);
|
||||
},
|
||||
);
|
||||
it("walks from the bedroom to downstairs and the town, with collision and return warps", () => {
|
||||
it("walks with the original timing: eight overworld ticks per tile", () => {
|
||||
const world = new GameWorld(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
expect(world.x).toBe(3);
|
||||
expect(world.y).toBe(3);
|
||||
expect(world.move("up")).toBe(true);
|
||||
const steps = world.tilesWalked;
|
||||
expect(world.move("up")).toBe(false); // Radio/furniture is solid.
|
||||
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 2, steps]);
|
||||
expect(world.move("down")).toBe(true);
|
||||
const routeTo = (targetX: number, targetY: number) => {
|
||||
const directions = [
|
||||
["up", 0, -1],
|
||||
["down", 0, 1],
|
||||
["left", -1, 0],
|
||||
["right", 1, 0],
|
||||
] as const;
|
||||
const queue = [
|
||||
{
|
||||
x: world.x,
|
||||
y: world.y,
|
||||
path: [] as ("up" | "down" | "left" | "right")[],
|
||||
},
|
||||
];
|
||||
const visited = new Set<string>();
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const node = queue[i],
|
||||
key = `${node.x},${node.y}`;
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
if (node.x === targetX && node.y === targetY) {
|
||||
for (const direction of node.path)
|
||||
expect(world.move(direction)).toBe(true);
|
||||
return;
|
||||
}
|
||||
for (const [direction, dx, dy] of directions) {
|
||||
const x = node.x + dx,
|
||||
y = node.y + dy;
|
||||
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
|
||||
continue;
|
||||
if (
|
||||
world.assets.permission(
|
||||
world.assets.collision(world.map, x, y),
|
||||
) !== 0
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
world.objects().some((object) => object.x === x && object.y === y)
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
|
||||
(x !== targetX || y !== targetY)
|
||||
)
|
||||
continue;
|
||||
queue.push({ x, y, path: [...node.path, direction] });
|
||||
}
|
||||
}
|
||||
throw new Error(`No route to ${targetX},${targetY}`);
|
||||
};
|
||||
routeTo(7, 0);
|
||||
expect(world.mapId).toBe("24:6");
|
||||
routeTo(6, 7);
|
||||
expect(world.mapId).toBe("24:4");
|
||||
expect(world.tilesWalked).toBeGreaterThan(10);
|
||||
const arrival = world.map.warps.find(
|
||||
(warp) => warp.targetMap === "24:6",
|
||||
)!;
|
||||
routeTo(arrival.x, arrival.y);
|
||||
world.restore({ ...world.save(), x: 3, y: 2, direction: "down" });
|
||||
const ticks: number[] = [];
|
||||
for (let tick = 1; tick <= 40 && world.x < 7; tick++) {
|
||||
const x = world.x;
|
||||
world.tick("right");
|
||||
if (world.x !== x) ticks.push(tick);
|
||||
}
|
||||
// Steps start on ticks 1, 9, 17... and continue without a gap.
|
||||
expect(ticks).toEqual([1, 9, 17, 25]);
|
||||
// Two pixels per tick: the step started on tick 25, now two ticks in.
|
||||
world.tick();
|
||||
expect(world.poses().find((pose) => pose.id === 0)?.x).toBe(6 * 16 + 4);
|
||||
});
|
||||
it("decodes ROM location names and draws connected maps beyond the edges", () => {
|
||||
const assets = load();
|
||||
const town = assets.map("24:4");
|
||||
expect(town.location).toBe("New Bark Town");
|
||||
expect(assets.map("24:5").name).toBe("Elm’s lab");
|
||||
expect(assets.map("24:5").location).toBe("New Bark Town");
|
||||
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);
|
||||
for (let y = 0; y < town.height; y++)
|
||||
expect(assets.visibleBlock(town, -1, y)).toBe(
|
||||
assets.block(route, west.x, y + west.y),
|
||||
);
|
||||
expect(assets.visibleBlock(town, -1, -1)).toBe(town.border);
|
||||
expect(() => assets.map("24:99")).toThrow("Unknown map");
|
||||
expect(() => assets.map("27:1")).toThrow("Unknown map");
|
||||
});
|
||||
it("plays Mom's opening scene with native choices, then walks outside", () => {
|
||||
const world = new GameWorld(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
// The bedroom's NEWMAP callback ran the original initialization script.
|
||||
expect(world.state.events.size).toBeGreaterThan(100);
|
||||
walkTo(world, 7, 0);
|
||||
expect(world.mapId).toBe("24:6");
|
||||
expect([world.x, world.y]).toEqual([9, 1]);
|
||||
const flags = world.state.flags.size;
|
||||
// Monday, confirm, DST yes, confirm time, "know how to use the phone".
|
||||
const pages = walkTo(world, 9, 4, [1, 1, 1, 1, 1]);
|
||||
expect(pages[0]).toBe("Oh, CHRIS…! Our\nneighbor, PROF.");
|
||||
expect(pages).toContain("CHRIS received\nPOKéGEAR.");
|
||||
expect(pages).toContain("What day is it?");
|
||||
expect(pages).toContain("MONDAY, is it?");
|
||||
expect(pages.some((page) => /^12:00 PM DST,/.test(page))).toBe(true);
|
||||
expect(pages).toContain("Don't you just\nturn the POKéGEAR");
|
||||
const save = world.save();
|
||||
expect(() => world.restore({ ...save, x: 127 })).toThrow("position");
|
||||
expect(world.save()).toEqual(save);
|
||||
const restored = new GameWorld(world.assets);
|
||||
restored.restore(save);
|
||||
expect(restored.save()).toEqual(save);
|
||||
expect(save.scenes).toEqual({ "24:6": 1 });
|
||||
expect(save.phone).toEqual([1]);
|
||||
// 2026-09-22 is a Tuesday; the chosen Monday is six days ahead.
|
||||
expect(save.clock).toEqual({ weekdayOffset: 6, dst: true });
|
||||
expect(world.clock().weekday).toBe(1);
|
||||
expect(world.state.flags.size).toBe(flags + 2);
|
||||
expect(world.state.events.has(1735)).toBe(true);
|
||||
// Stepping on the tile again does not replay the scene.
|
||||
expect(world.move("up")).toBe(true);
|
||||
expect(settle(world)).toEqual([]);
|
||||
// Objects spawn when the map loads: the scene's Mom stays until the
|
||||
// player leaves, then the daytime Mom replaces her.
|
||||
expect(world.objects().find((object) => object.id === 2)).toMatchObject({
|
||||
x: 7,
|
||||
y: 4,
|
||||
direction: "left",
|
||||
});
|
||||
walkTo(world, 6, 7);
|
||||
expect(world.mapId).toBe("24:6");
|
||||
expect(world.move("down")).toBe(true);
|
||||
settle(world);
|
||||
expect(world.mapId).toBe("24:4");
|
||||
const home = world.map.warps.find((warp) => warp.targetMap === "24:6")!;
|
||||
walkTo(world, home.x, home.y);
|
||||
expect(world.objects().map((object) => object.id)).toEqual([4, 6]);
|
||||
walkTo(world, 6, 7);
|
||||
const restored = new GameWorld(
|
||||
world.assets,
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
restored.restore(world.save());
|
||||
expect(restored.save()).toEqual(world.save());
|
||||
expect(() => world.restore({ ...world.save(), x: 127 })).toThrow(
|
||||
"position",
|
||||
);
|
||||
expect(() =>
|
||||
world.restore({ ...world.save(), scenes: { "24:4": 3 } }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
world.restore({ ...world.save(), scenes: { "1:1": 3 } }),
|
||||
).toThrow("Invalid");
|
||||
});
|
||||
it("saves the committed state during a scene and replays it after restore", () => {
|
||||
const world = new GameWorld(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
walkTo(world, 7, 0);
|
||||
walkTo(world, 9, 3);
|
||||
const before = world.save();
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(world.busy).toBe(true);
|
||||
// A mid-scene save keeps the position before the triggering step.
|
||||
expect(world.save()).toEqual(before);
|
||||
const restored = new GameWorld(
|
||||
world.assets,
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
restored.restore(world.save());
|
||||
expect(restored.move("down")).toBe(true);
|
||||
expect(settle(restored, [1, 1, 1, 1, 1]).length).toBeGreaterThan(10);
|
||||
expect(restored.save().scenes).toEqual({ "24:6": 1 });
|
||||
});
|
||||
it("runs New Bark Town's teacher and rival scenes with scripted movement", () => {
|
||||
const world = new GameWorld(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
world.restore({ ...world.save(), map: "24:4", x: 5, y: 8 });
|
||||
walkTo(world, 2, 8);
|
||||
const events = world.save().events;
|
||||
expect(world.move("left")).toBe(true);
|
||||
const pages = settle(world);
|
||||
expect(pages[0]).toBe("Wait, CHRIS!");
|
||||
expect(pages).toContain("It's dangerous to\ngo out without a\nPOKéMON!");
|
||||
// The teacher runs over and leads the player back east.
|
||||
expect([world.x, world.y, world.direction]).toEqual([5, 8, "right"]);
|
||||
const teacher = world.objects().find((object) => object.id === 2)!;
|
||||
expect([teacher.x, teacher.y, teacher.direction]).toEqual([6, 8, "left"]);
|
||||
expect(world.save().events).toEqual(events);
|
||||
walkTo(world, 2, 2);
|
||||
expect(world.move("right")).toBe(false);
|
||||
world.interact();
|
||||
const rival = settle(world);
|
||||
expect(rival).toContain("…What are you\nstaring at?");
|
||||
// Pushed one step and shoved two more with a jump.
|
||||
expect([world.x, world.y, world.direction]).toEqual([2, 5, "up"]);
|
||||
const silver = world.objects().find((object) => object.id === 4)!;
|
||||
expect([silver.x, silver.y, silver.direction]).toEqual([3, 2, "right"]);
|
||||
});
|
||||
it("runs Elm's introduction, the lab's exit guard and a declined Poké Ball", () => {
|
||||
const world = new GameWorld(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: "24:4",
|
||||
x: 6,
|
||||
y: 4,
|
||||
scenes: { "24:6": 1 },
|
||||
});
|
||||
const door = world.map.warps.find((warp) => warp.targetMap === "24:5")!;
|
||||
walkTo(world, door.x, door.y + 1);
|
||||
expect(world.move("up")).toBe(true);
|
||||
expect(world.mapId).toBe("24:5");
|
||||
// The OBJECTS callback moved Elm for the introduction.
|
||||
expect(world.objects().find((object) => object.id === 2)).toMatchObject({
|
||||
x: 3,
|
||||
y: 4,
|
||||
});
|
||||
const pages = settle(world, [0, 1]);
|
||||
expect(pages[0]).toBe("ELM: CHRIS!\nThere you are!");
|
||||
expect(pages).toContain("But… Please, I\nneed your help!");
|
||||
expect(pages.at(-1)).toBe("Go on. Pick one!");
|
||||
expect(world.save().scenes).toMatchObject({ "24:5": 1 });
|
||||
// The warp places the player on the mat at y=11; the ROM walks up seven tiles.
|
||||
expect([world.x, world.y, world.direction]).toEqual([4, 4, "right"]);
|
||||
// Elm stops the player from leaving without a Pokémon.
|
||||
walkTo(world, 4, 5);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(settle(world).length).toBeGreaterThan(0);
|
||||
expect([world.x, world.y]).toEqual([4, 5]);
|
||||
walkTo(world, 6, 4);
|
||||
world.move("up");
|
||||
const before = world.save();
|
||||
world.interact();
|
||||
// Declining a Poké Ball commits nothing.
|
||||
expect(settle(world, [0])).toEqual([
|
||||
"",
|
||||
"ELM: You'll take\nCYNDAQUIL, the\nfire POKéMON?",
|
||||
"ELM: Think it over\ncarefully.",
|
||||
"Your partner is\nimportant.",
|
||||
]);
|
||||
expect(world.save()).toEqual(before);
|
||||
});
|
||||
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> = {
|
||||
0xa0: "right",
|
||||
0xa1: "left",
|
||||
0xa3: "down",
|
||||
0xa4: "down",
|
||||
0xa5: "down",
|
||||
};
|
||||
for (let y = 1; y < route.height - 2; y++) {
|
||||
for (let x = 2; x < route.width - 2; x++) {
|
||||
const collision = assets.collision(route, x, y),
|
||||
direction = directions[collision];
|
||||
if (!direction || checked.has(collision)) continue;
|
||||
const [dx, dy] = VECTORS[direction];
|
||||
if (
|
||||
assets.permission(assets.collision(route, x + dx, y + dy)) === 0 ||
|
||||
assets.permission(
|
||||
assets.collision(route, x + dx * 2, y + dy * 2),
|
||||
) !== 0 ||
|
||||
route.objects.some((o) => o.x === x + dx * 2 && o.y === y + dy * 2)
|
||||
)
|
||||
continue;
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: route.id,
|
||||
x,
|
||||
y,
|
||||
direction,
|
||||
objects: [],
|
||||
});
|
||||
world.tick(direction);
|
||||
expect([world.x, world.y]).toEqual([x + dx * 2, y + dy * 2]);
|
||||
expect(
|
||||
world.poses().find((pose) => pose.id === 0)?.lift,
|
||||
).toBeLessThan(0);
|
||||
settle(world);
|
||||
expect(world.notice).toBe("");
|
||||
checked.add(collision);
|
||||
}
|
||||
}
|
||||
expect([...checked].sort()).toEqual([0xa0, 0xa1, 0xa3, 0xa4, 0xa5]);
|
||||
});
|
||||
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,
|
||||
)!;
|
||||
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.");
|
||||
const {
|
||||
scenes,
|
||||
phone,
|
||||
clock,
|
||||
objects,
|
||||
playerId,
|
||||
party,
|
||||
bag,
|
||||
pokedex,
|
||||
...legacy
|
||||
} = world.save();
|
||||
void [scenes, phone, objects, playerId, party, bag, pokedex];
|
||||
world.restore({ ...legacy, version: 1 });
|
||||
expect(world.save()).toMatchObject({
|
||||
version: 3,
|
||||
scenes: {},
|
||||
phone: [],
|
||||
objects: [],
|
||||
party: [],
|
||||
pokedex: { seen: [], caught: [] },
|
||||
});
|
||||
world.restore({
|
||||
...legacy,
|
||||
version: 2,
|
||||
scenes: { "24:6": 1 },
|
||||
phone: [1],
|
||||
clock,
|
||||
objects: [],
|
||||
});
|
||||
expect(world.save()).toMatchObject({
|
||||
version: 3,
|
||||
scenes: { "24:6": 1 },
|
||||
phone: [1],
|
||||
});
|
||||
expect(world.save().playerId).toBeLessThan(0x10000);
|
||||
expect(() => world.restore({ ...legacy, version: 4 })).toThrow("Invalid");
|
||||
});
|
||||
it("decodes species, moves, items and growth from the ROM", () => {
|
||||
const data = load().pokemon;
|
||||
expect([152, 155, 158].map((id) => data.speciesName(id))).toEqual([
|
||||
"CHIKORITA",
|
||||
"CYNDAQUIL",
|
||||
"TOTODILE",
|
||||
]);
|
||||
expect(data.speciesName(122)).toBe("MR.MIME");
|
||||
expect(data.baseData(155)).toMatchObject({
|
||||
species: 155,
|
||||
stats: [39, 52, 43, 65, 60, 50],
|
||||
growthRate: 3,
|
||||
picSize: 5,
|
||||
});
|
||||
// Medium Slow: 135 at level 5, and the original level-1 underflow.
|
||||
expect(data.expAtLevel(3, 5)).toBe(135);
|
||||
expect(data.expAtLevel(3, 1)).toBe(16777162);
|
||||
expect(data.expAtLevel(0, 100)).toBe(1_000_000);
|
||||
expect(data.learnset(155).slice(0, 2)).toEqual([
|
||||
{ level: 1, move: 33 },
|
||||
{ level: 1, move: 43 },
|
||||
]);
|
||||
expect(data.moveName(33)).toBe("TACKLE");
|
||||
expect(data.move(33)).toMatchObject({ power: 35, accuracy: 242, pp: 35 });
|
||||
expect(data.itemName(18)).toBe("POTION");
|
||||
expect(data.itemPocket(18)).toBe("item");
|
||||
expect(data.pocketName("item")).toBe("ITEM POCKET");
|
||||
expect(data.frontPic(155)).toHaveLength(49 * 16);
|
||||
expect(() => data.speciesName(252)).toThrow("Unknown");
|
||||
});
|
||||
it.skipIf(
|
||||
!existsSync(
|
||||
".reference/pokecrystal/gfx/pokemon/cyndaquil/front.animated.2bpp",
|
||||
),
|
||||
)(
|
||||
"matches front pictures against the locally rebuilt PRET reference",
|
||||
() => {
|
||||
const data = load().pokemon;
|
||||
for (const [species, name] of [
|
||||
[152, "chikorita"],
|
||||
[155, "cyndaquil"],
|
||||
[158, "totodile"],
|
||||
] as const)
|
||||
expect(data.frontPicData(species)).toEqual(
|
||||
new Uint8Array(
|
||||
readFileSync(
|
||||
`.reference/pokecrystal/gfx/pokemon/${name}/front.animated.2bpp`,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
it("gives the chosen starter and the aide's Potion, then saves them", () => {
|
||||
let seed = 7;
|
||||
const random = () => (seed = (seed * 73 + 41) & 255);
|
||||
const world = new GameWorld(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
random,
|
||||
);
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: "24:5",
|
||||
x: 4,
|
||||
y: 3,
|
||||
scenes: { "24:6": 1, "24:5": 1 },
|
||||
});
|
||||
walkTo(world, 6, 4);
|
||||
expect(world.move("up")).toBe(false);
|
||||
world.interact();
|
||||
// pokepic shows the ROM's front picture and waits for a button.
|
||||
expect(world.picture).toBe(155);
|
||||
expect(world.dialogue?.pages).toEqual([""]);
|
||||
world.advanceDialogue();
|
||||
expect(world.picture).toBeUndefined();
|
||||
expect(world.dialogue?.choice?.kind).toBe("yesno");
|
||||
// Take it, then give it a nickname through the naming screen.
|
||||
const pages = settle(world, [1, 1]);
|
||||
expect(pages).toContain("CHRIS received\nCYNDAQUIL!");
|
||||
expect(world.dialogue?.choice).toMatchObject({
|
||||
kind: "name",
|
||||
current: "CYNDAQUIL",
|
||||
});
|
||||
expect(world.dialogue?.pages).toEqual(["CYNDAQUIL'S\nNICKNAME?"]);
|
||||
// Characters the naming screen cannot enter keep the prompt open.
|
||||
world.choose("BAD~NAME");
|
||||
expect(world.dialogue?.choice?.kind).toBe("name");
|
||||
world.choose("BLAZE");
|
||||
expect(settle(world)).toContain("CHRIS got ELM's\nphone number.");
|
||||
const save = world.save();
|
||||
expect(save.party).toHaveLength(1);
|
||||
expect(save.party[0]).toMatchObject({
|
||||
species: 155,
|
||||
nickname: "BLAZE",
|
||||
ot: "CHRIS",
|
||||
otId: world.playerId,
|
||||
level: 5,
|
||||
exp: 135,
|
||||
item: 173,
|
||||
moves: [33, 43, 0, 0],
|
||||
pp: [35, 30, 0, 0],
|
||||
happiness: 70,
|
||||
// Day, level 5; male player in New Bark Town.
|
||||
caughtData: 0x8501,
|
||||
});
|
||||
expect(save.party[0].hp).toBe(save.party[0].stats[0]);
|
||||
expect(save.pokedex).toEqual({ seen: [155], caught: [155] });
|
||||
expect(save.scenes).toEqual({ "24:6": 1, "24:5": 5, "24:4": 1 });
|
||||
expect(save.events).toContain(26); // EVENT_GOT_A_POKEMON_FROM_ELM
|
||||
// The other balls now only describe themselves.
|
||||
walkTo(world, 7, 4);
|
||||
world.move("up");
|
||||
world.interact();
|
||||
expect(settle(world)).toHaveLength(1);
|
||||
expect(world.save().party).toHaveLength(1);
|
||||
walkTo(world, 4, 7);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(settle(world)).toEqual([
|
||||
"CHRIS, I want\nyou to have this\nfor your errand.",
|
||||
"CHRIS received\nPOTION.",
|
||||
"CHRIS put the\nPOTION in\nthe ITEM POCKET.",
|
||||
"There are only two\nof us, so we're\nalways busy.",
|
||||
]);
|
||||
const final = world.save();
|
||||
expect(final.bag.items).toEqual([{ item: 18, quantity: 1 }]);
|
||||
expect(final.scenes["24:5"]).toBe(2);
|
||||
const restored = new GameWorld(
|
||||
world.assets,
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
restored.restore(final);
|
||||
expect(restored.save()).toEqual(final);
|
||||
// Stored stats must match the ROM's formula for the stored DVs.
|
||||
const tampered = structuredClone(final);
|
||||
tampered.party[0].stats[1] += 1;
|
||||
expect(() => restored.restore(tampered)).toThrow("Invalid");
|
||||
const potion = structuredClone(final);
|
||||
potion.bag.keyItems = [18];
|
||||
expect(() => restored.restore(potion)).toThrow("Invalid");
|
||||
expect(restored.save()).toEqual(final);
|
||||
});
|
||||
it("reads the bedroom radio through native event and text interpreters", () => {
|
||||
const world = new GameWorld(load());
|
||||
@@ -214,12 +625,15 @@ describe.skipIf(!existsSync(romPath))(
|
||||
const previousEvents = world.state.events.size;
|
||||
world.interact();
|
||||
expect(world.diagnostic).toBe("");
|
||||
expect(world.dialogue?.pages.length).toBe(4);
|
||||
// Each radio line holds for 45 ticks (pause 45), ignoring buttons.
|
||||
expect(world.dialogue?.holdTicks).toBe(45);
|
||||
world.advanceDialogue();
|
||||
expect(world.dialogue?.holdTicks).toBe(45);
|
||||
expect(world.state.events.size).toBe(previousEvents);
|
||||
while (world.dialogue) world.advanceDialogue();
|
||||
expect(settle(world)).toHaveLength(4);
|
||||
expect(world.state.events.size).toBe(previousEvents + 1);
|
||||
world.interact();
|
||||
expect(world.dialogue?.pages.length).toBe(1);
|
||||
expect(settle(world)).toHaveLength(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user