Files
Crystal/tests/movement.test.ts
T

367 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
import { settle } from "./world-driver";
/** Synthetic maps and script bytes: these tests run without a private ROM. */
class MovementAssets extends CrystalAssets {
readonly fixtures = new Map<string, GameMap>();
readonly tiles = new Map<string, number>();
readonly bytes: Uint8Array;
constructor() {
const bytes = new Uint8Array(0xa0000);
bytes[0] = 0x50; // Empty weekday name for synthetic script hosts.
super(bytes);
this.bytes = bytes;
for (const id of ["24:7", "24:6"]) {
this.fixtures.set(id, {
id,
group: 24,
number: Number(id.split(":")[1]),
name: id,
location: id,
landmark: 1,
width: 8,
height: 8,
border: 0,
tileset: 1,
environment: 1,
paletteMode: 1,
blocks: new Uint8Array(16),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
});
}
}
override map(id: string) {
return this.fixtures.get(id)!;
}
override isPorted(id: string) {
return this.fixtures.has(id);
}
override sceneOwner() {
return undefined;
}
override collision(map: GameMap, x: number, y: number) {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
return this.tiles.get(`${map.id}:${x},${y}`) ?? 0;
}
override permission(collision: number) {
return collision === 7 ? 15 : 0;
}
override grassCollisions() {
return [];
}
override sprite() {
return { graphics: new Uint8Array(0), palette: 0, walking: false };
}
tile(x: number, y: number, collision: number, map = "24:7") {
this.tiles.set(`${map}:${x},${y}`, collision);
}
}
function fixture() {
const assets = new MovementAssets();
const world = new GameWorld(
assets,
() => new Date("2026-09-22T12:00:00"),
() => 0,
);
return { assets, world };
}
function connectWarp(assets: MovementAssets, x = 3, y = 4) {
assets.map("24:7").warps.push({ x, y, targetMap: "24:6", targetWarp: 1 });
assets
.map("24:6")
.warps.push({ x: 2, y: 2, targetMap: "24:7", targetWarp: 1 });
assets.tile(2, 2, 0x70, "24:6");
}
describe("native ledge movement", () => {
it.each<[number, Direction]>([
[0xa0, "right"],
[0xa1, "left"],
[0xa2, "up"],
[0xa3, "down"],
[0xa4, "right"],
[0xa4, "down"],
[0xa5, "down"],
[0xa5, "left"],
[0xa6, "up"],
[0xa6, "right"],
[0xa7, "up"],
[0xa7, "left"],
])(
"jumps from ledge %i toward %s over two tiles in sixteen ticks",
(tile, direction) => {
const { assets, world } = fixture();
const [dx, dy] = VECTORS[direction];
assets.tile(3, 3, tile);
assets.tile(3 + dx, 3 + dy, 7);
world.tick(direction);
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({
x: 48 + dx * 2,
y: 48 + dy * 2,
lift: -6,
shadow: dx ? 12 : 14,
});
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.busy).toBe(true);
world.tick();
expect(world.busy).toBe(false);
expect(world.tilesWalked).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
},
);
it("walks onto ledges and prefers a normal step when the next tile is clear", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 0xa3);
expect(world.move("down")).toBe(true);
world.tick("down");
expect([world.x, world.y]).toEqual([3, 5]);
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
settle(world);
expect(world.tilesWalked).toBe(2);
});
it("refuses the wrong direction, blocked or unsupported landings and map-edge jumps", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 2, 7);
expect(world.move("up")).toBe(false);
assets.tile(3, 4, 7);
for (const landing of [7, 0x30, 0x40, 0x60]) {
assets.tile(3, 5, landing);
expect(world.move("down")).toBe(false);
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 3, 0]);
}
assets.tile(3, 6, 0xa3);
assets.tile(3, 7, 7);
world.restore({ ...world.save(), y: 6 });
expect(world.move("down")).toBe(false);
});
it("does not land on an occupied tile", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 4, 7);
assets.map("24:7").objects.push({
id: 2,
x: 3,
y: 5,
sprite: 1,
movement: 6,
palette: 0,
kind: 0,
script: 0,
eventFlag: 0xffff,
hour1: 0xff,
hour2: 0xff,
});
world.restore(world.save());
expect(world.move("down")).toBe(false);
expect(world.y).toBe(3);
});
it("replays an interrupted jump and its landing scene without skipping event writes", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0xa3);
assets.tile(3, 4, 7);
// pause 4; setevent 8; end
assets.bytes.set([0x8b, 4, 0x33, 8, 0, 0x91], 0x100);
assets
.map("24:7")
.coordinateEvents.push({ x: 3, y: 5, scene: 0xff, script: 0x100 });
world.tick("down");
const beforeLanding = world.save();
const restored = new GameWorld(assets);
restored.restore(beforeLanding);
expect(restored.move("down")).toBe(true);
expect(restored.activity).toBe("scene");
expect(restored.save()).toEqual(beforeLanding);
expect(restored.state.events.has(8)).toBe(false);
settle(restored);
expect(restored.state.events.has(8)).toBe(true);
expect(restored.save()).toMatchObject({ x: 3, y: 5, tilesWalked: 1 });
});
});
describe("native warp movement", () => {
it.each<[number, Direction]>([
[0x70, "down"],
[0x76, "left"],
[0x78, "up"],
[0x7e, "right"],
])("carpet %i waits for a blocked step toward %s", (tile, direction) => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, tile);
expect(world.move("down")).toBe(true);
expect(world.mapId).toBe("24:7");
settle(world);
expect(world.mapId).toBe("24:7");
const [dx, dy] = VECTORS[direction];
assets.tile(3 + dx, 4 + dy, 7);
expect(world.move(direction)).toBe(true);
expect(world.mapId).toBe("24:6");
expect([world.x, world.y, world.direction, world.tilesWalked]).toEqual([
2,
2,
direction,
1,
]);
settle(world);
// Arrival on a mat neither teleports inward nor warps back.
expect([world.x, world.y]).toEqual([2, 2]);
});
it("lets the player walk away from a mat and refuses the wrong exit direction", () => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, 0x70);
world.move("down");
assets.tile(2, 4, 7);
expect(world.move("left")).toBe(false);
expect(world.move("up")).toBe(true);
expect(world.mapId).toBe("24:7");
expect(world.y).toBe(3);
});
it("uses carpet warps on the boundary without moving outside the map", () => {
const { assets, world } = fixture();
connectWarp(assets, 3, 7);
assets.tile(3, 7, 0x70);
world.restore({ ...world.save(), y: 7 });
world.tick("down");
expect(world.mapId).toBe("24:6");
expect(world.tilesWalked).toBe(0);
expect(() => world.restore(world.save())).not.toThrow();
});
it.each([0x71, 0x79, 0x7a, 0x7b])(
"walks down off arrival tile %i over eight ticks without input",
(tile) => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, 0x72);
assets.tile(2, 2, tile, "24:6");
world.move("down");
expect([world.mapId, world.x, world.y, world.direction]).toEqual([
"24:6",
2,
2,
"down",
]);
world.tick("up"); // Forced exit overrides held input.
expect(world.poses().find((pose) => pose.id === 0)?.y).toBe(34);
const save = world.save();
expect(save).toMatchObject({ x: 2, y: 2, tilesWalked: 1 });
world.restore(save);
settle(world);
expect(world.save()).toMatchObject({ x: 2, y: 3, tilesWalked: 2 });
},
);
it("saves before a doorway until the walking animation and warp complete", () => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, 0x71);
world.tick("down");
const save = world.save();
expect(save).toMatchObject({ map: "24:7", x: 3, y: 3, tilesWalked: 0 });
world.restore(save);
expect(world.move("down")).toBe(true);
expect(world.save()).toMatchObject({
map: "24:6",
x: 2,
y: 2,
tilesWalked: 1,
});
});
it("saves on the source map during a connection crossing", () => {
const { assets, world } = fixture();
assets
.map("24:7")
.connections.push({ direction: "right", targetMap: "24:6", x: 0, y: 0 });
world.restore({ ...world.save(), x: 7 });
world.tick("right");
const save = world.save();
expect(save).toMatchObject({ map: "24:7", x: 7, y: 3, tilesWalked: 0 });
expect(() => world.restore(save)).not.toThrow();
expect(world.move("right")).toBe(true);
expect(world.save()).toMatchObject({
map: "24:6",
x: 0,
y: 3,
tilesWalked: 1,
});
});
it("restores early version-3 backups without money but rejects corrupt balances", () => {
const { world } = fixture();
const { money, ...earlySave } = world.save();
world.state.money = 42;
world.restore(earlySave);
expect(world.state.money).toBe(money);
for (const invalid of [null, -1, 1.5, 1_000_000, "3000"]) {
expect(() => world.restore({ ...earlySave, money: invalid })).toThrow(
"Invalid native save",
);
expect(world.state.money).toBe(money);
}
});
it("refuses unavailable destinations and bad callbacks before entering a doorway", () => {
const { assets, world } = fixture();
connectWarp(assets);
assets.tile(3, 4, 0x71);
const warp = world.map.warps[0];
for (const targetWarp of [0, 255, 2]) {
warp.targetWarp = targetWarp;
expect(world.move("down")).toBe(false);
expect(world.save()).toMatchObject({
map: "24:7",
x: 3,
y: 3,
tilesWalked: 0,
});
}
warp.targetWarp = 1;
warp.targetMap = "1:1";
expect(world.move("down")).toBe(false);
warp.targetMap = "24:6";
assets.bytes[0x100] = 0xff;
assets.map("24:6").callbacks.push({ kind: 5, script: 0x100 });
expect(world.move("down")).toBe(false);
expect(world.notice).toContain("progress is unchanged");
expect(world.save()).toMatchObject({
map: "24:7",
x: 3,
y: 3,
tilesWalked: 0,
});
// A directional carpet can be entered, but its failed exit is also atomic.
assets.tile(3, 4, 0x70);
assets.tile(3, 5, 7);
expect(world.move("down")).toBe(true);
expect(world.move("down")).toBe(false);
expect(world.save()).toMatchObject({
map: "24:7",
x: 3,
y: 4,
tilesWalked: 1,
});
});
});