Files
Crystal/tests/movement.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

300 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
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";
import { MovementAssets, connectWarp, fixture } from "./movement-fixture";
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);
// Facing the ledge already, so no turn in place precedes the jump.
world.restore({ ...world.save(), direction });
world.tick(direction);
// StepFunction_PlayerJump: the map position reaches the tile jumped
// over first, and the landing tile halfway through.
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
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; tick++) world.tick();
expect([world.x, world.y]).toEqual([3 + dx, 3 + dy]);
world.tick();
expect([world.x, world.y]).toEqual([3 + dx * 2, 3 + dy * 2]);
expect(world.save()).toMatchObject({ x: 3, y: 3, tilesWalked: 0 });
for (let tick = WALK_TICKS + 1; tick < WALK_TICKS * 2 - 1; tick++) world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeDefined();
world.tick();
expect(world.poses().find((pose) => pose.id === 0)?.lift).toBeUndefined();
// The landing's tile events run on the next tick (PLAYERSTEP_STOP).
expect([world.busy, world.tilesWalked]).toEqual([true, 0]);
world.tick();
expect(world.busy).toBe(false);
expect(world.tilesWalked).toBe(1);
},
);
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 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);
assets.tile(3, 5, 7);
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",
]);
// WarpToNewMapScript's `end` takes the first tick in the new map.
world.tick("up");
expect([world.x, world.y, world.busy]).toEqual([2, 2, false]);
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, direction: "right" });
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,
});
});
});