Files
Crystal/tests/decorations.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 451464f7c6
Checks / web (push) Canceled after 0s
Complete the overworld foundation
Port forced movement (turn delays, bumps, ice, currents, pits), dynamic
return warps, elevators, Surf, Cut, Strength and cycling rules, working
map blocks with changeblock/changemapblocks, and the remaining NPC
movement types and movement commands.

Add tileset animation from the ROM's command tables, grass sprite
priority and special map palettes; room decorations with ownership, the
bedroom PC and save migration; and Key Items access to the Bicycle.

Fourteen original-engine traces compare positions, facing, actors, event
flags and scripted poses. Tile animation matches original VRAM for four
tilesets, and LoadMapPals matches 192 palette cases. skyfall_top now
keeps the $60 sprite offset its step function leaves behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:47:29 +02:00

167 lines
5.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import {
DecorationData,
initialDecorations,
} from "../src/game/world/decorations";
import { EVENTS } from "../src/game/rom/profile";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
function fixture() {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
return {
assets,
world,
data: new DecorationData(assets.reader, assets.pokemon),
};
}
function pc(world: GameWorld) {
const save = world.save();
world.restore({
...save,
map: "24:7",
x: 2,
y: 2,
direction: "up",
objects: [],
});
world.interact();
expect(world.notice, world.diagnostic).toBe("");
expect(world.dialogue?.choice?.kind).toBe("menu");
}
function choose(world: GameWorld, name: string) {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error("Expected decoration menu");
const index = choice.items.indexOf(name);
expect(index, name).toBeGreaterThanOrEqual(0);
world.choose(index);
}
function acknowledge(world: GameWorld) {
for (let i = 0; world.dialogue && !world.dialogue.choice && i < 20; i++)
world.advanceDialogue();
}
describe.skipIf(!existsSync(romPath))("room decorations", () => {
it("starts with the original bed and Town Map and migrates old saves", () => {
const { world, assets } = fixture();
expect(world.state.decorations).toEqual(initialDecorations());
expect(assets.block(world.map, 0, 4)).toBe(0x1b);
expect(assets.block(world.map, 6, 0)).toBe(0x1f);
expect(world.state.events.has(EVENTS.playersRoomPoster)).toBe(true);
const old = world.save() as Partial<ReturnType<GameWorld["save"]>>;
delete old.decorations;
world.restore(old);
expect(world.state.decorations).toEqual(initialDecorations());
});
it("PC puts away and restores a bed; mid-menu saves retain committed choices", () => {
const { world, assets } = fixture();
pc(world);
choose(world, "BED");
choose(world, "PUT IT AWAY");
expect(world.liveState.decorations.bed).toBe(0);
expect(world.save().decorations.bed).toBe(2);
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect(world.state.decorations.bed).toBe(0);
expect(assets.block(world.map, 0, 4)).toBe(
assets.block(assets.map("24:7"), 0, 4),
);
world.restore(world.save());
pc(world);
choose(world, "BED");
choose(world, "FEATHERY BED");
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect(assets.block(world.map, 0, 4)).toBe(0x1b);
});
it("owned ornaments move between sides without duplication and reload as sprites", () => {
const { world, data } = fixture();
world.state.events.add(data.attribute(30).event);
pc(world);
choose(world, "ORNAMENT");
choose(world, "PIKACHU DOLL");
choose(world, "RIGHT SIDE");
acknowledge(world);
choose(world, "ORNAMENT");
choose(world, "PIKACHU DOLL");
choose(world, "LEFT SIDE");
acknowledge(world);
choose(world, "EXIT");
settle(world);
expect([
world.state.decorations.left,
world.state.decorations.right,
]).toEqual([30, 0]);
expect(world.objects().some((object) => object.id === 3)).toBe(true);
expect(world.objects().some((object) => object.id === 4)).toBe(false);
world.restore(world.save());
expect(world.state.variables[1]).toBe(data.attribute(30).sprite);
});
it("validates ownership and categories before changing the current game", () => {
const { world } = fixture();
const before = world.save();
for (const decorations of [
null,
{ ...before.decorations, bed: 30 },
{ ...before.decorations, bed: 3 },
{ ...before.decorations, poster: 999 },
]) {
expect(() => world.restore({ ...before, decorations })).toThrow(
/Invalid native save/,
);
expect(world.save()).toEqual(before);
}
});
it("sets every tile category and sprite category from the owned ROM attributes", () => {
const { world, assets, data } = fixture();
for (let id = 1; id <= 52; id++) {
const a = data.attribute(id);
if (a.action & 1) world.state.events.add(a.event);
}
world.state.decorations = {
bed: 5,
carpet: 10,
plant: 14,
poster: 19,
console: 24,
bigDoll: 28,
left: 51,
right: 52,
};
world.warpTo("24:7", 2, 2);
expect(
[
[0, 4],
[7, 4],
[6, 0],
[0, 0],
[0, 2],
[2, 2],
[4, 2],
].map(([x, y]) => assets.block(world.map, x, y)),
).toEqual([0x1e, 0x22, 0x25, 0x11, 0x12, 0x13, 0x12]);
expect([0, 1, 2, 3].map((i) => world.state.variables[i])).toEqual(
[24, 51, 52, 28].map((id) => data.attribute(id).sprite),
);
expect(world.objects()).toHaveLength(4);
world.restore(world.save());
expect(world.objects()).toHaveLength(4);
});
});