367 lines
11 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|