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

401 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { WALK_TICKS } from "../src/game/world/world";
import { OW } from "../src/game/world/constants";
import { SYMBOLS } from "../src/game/rom/profile";
import type { Direction } from "../src/game/world/types";
import { fixture } from "./movement-fixture";
import { settle } from "./world-driver";
const pose = (world: ReturnType<typeof fixture>["world"]) =>
world.poses().find((candidate) => candidate.id === 0)!;
/** Hold a direction for `ticks` ticks and record the tick numbers on which
* the player's tile changed. */
function hold(
world: ReturnType<typeof fixture>["world"],
direction: Direction | undefined,
ticks: number,
) {
const moved: number[] = [];
for (let tick = 1; tick <= ticks; tick++) {
const at = `${world.x},${world.y}`;
world.tick(direction);
if (`${world.x},${world.y}` !== at) moved.push(tick);
}
return moved;
}
describe("turning in place (DoPlayerMovement .CheckTurning)", () => {
it("turns on the next tick and walks on the fourth tick after a turn", () => {
const { world } = fixture();
expect(world.direction).toBe("down");
world.tick("right");
// StepFunction_Turn shows the new facing a tick later.
expect([world.x, world.direction, world.busy]).toEqual([3, "down", true]);
world.tick("right");
expect(world.direction).toBe("right");
expect(pose(world).frame).toBe(1);
// ChangeDirectionScript's deactivatefacing 3 holds input until then.
expect(hold(world, "right", 2)).toEqual([]);
expect(hold(world, "right", 1)).toEqual([1]);
// Continuous walking needs no further turns.
expect(hold(world, "right", WALK_TICKS * 2)).toEqual([WALK_TICKS, WALK_TICKS * 2]);
});
it("turns without walking when the direction is only tapped", () => {
const { world } = fixture();
world.tick("left");
expect(hold(world, undefined, 8)).toEqual([]);
expect([world.x, world.y, world.direction, world.busy]).toEqual([3, 3, "left", false]);
// Facing that way now, the next press walks at once.
expect(hold(world, "left", 1)).toEqual([1]);
});
it("changes direction without turning while walking, and turns again after stopping", () => {
const { world } = fixture();
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
// Still walking: the new direction steps on the next decision.
expect(hold(world, "right", 1)).toEqual([1]);
expect(world.direction).toBe("right");
hold(world, undefined, WALK_TICKS + 1);
expect(hold(world, "up", 4)).toEqual([]);
expect(world.direction).toBe("up");
});
it("bumps into walls in place at half animation speed and must turn after a bump", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 7);
const frames: number[] = [];
for (let tick = 0; tick < 16; tick++) {
world.tick("down");
frames.push(pose(world).frame);
}
expect([world.x, world.y]).toEqual([3, 3]);
// SetFacingBumpAction: OBJECT_STEP_FRAME >> 3.
expect(frames).toEqual([0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2]);
// The bump cleared wPlayerTurningDirection, so a new direction turns first.
expect(hold(world, "left", 4)).toEqual([]);
expect(hold(world, "left", 1)).toEqual([1]);
});
it("walking into a wall in a new direction bumps without facing it, then turns", () => {
const { assets, world } = fixture();
assets.tile(4, 4, 7);
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
// Mid-walk turning is not needed, but the blocked step does not turn.
world.tick("right");
expect([world.x, world.y, world.direction]).toEqual([3, 4, "down"]);
world.tick("right");
world.tick("right");
expect(world.direction).toBe("right");
});
it("does not turn toward the direction the player already faces", () => {
const { world } = fixture();
expect(hold(world, "down", 1)).toEqual([1]);
});
});
describe("forced terrain (DoPlayerMovement .CheckTile)", () => {
it.each<[number, Direction | undefined]>([
[0x40, undefined],
[0x41, "right"],
[0x42, "left"],
[0x43, "up"],
[0x44, "down"],
[0x45, undefined],
[0x50, "right"],
[0x51, "left"],
[0x52, "up"],
[0x53, "down"],
[0x54, undefined],
])("walk tile %i moves the player %s without input", (tile, direction) => {
const { assets, world } = fixture();
assets.tile(3, 3, tile);
world.tick();
if (!direction) {
expect([world.x, world.y, world.busy]).toEqual([3, 3, false]);
return;
}
const vectors = { right: [4, 3], left: [2, 3], up: [3, 2], down: [3, 4] };
expect([world.x, world.y, world.direction]).toEqual([...vectors[direction], direction]);
expect(pose(world).frame).toBe(0);
});
it("forced steps ignore held input and walls, like .continue_walk", () => {
const { assets, world } = fixture();
assets.tile(3, 3, 0x41);
assets.tile(4, 3, 7);
world.tick("left");
expect([world.x, world.y]).toEqual([4, 3]);
});
it.each<[number, Direction]>([
[0x30, "right"],
[0x31, "left"],
[0x32, "up"],
[0x33, "down"],
[0x38, "right"],
[0x3b, "down"],
])("currents and waterfalls (%i) carry a surfer %s", (tile, direction) => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
assets.tile(3, 3, tile);
world.tick();
expect(world.direction).toBe(direction);
expect(world.activity).toBe("walking");
});
});
describe("ice (STEP_ICE and .CheckForced)", () => {
it("slides at four ticks per tile until something blocks the slide", () => {
const { assets, world } = fixture();
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
assets.tile(7, 3, 7);
world.restore({ ...world.save(), direction: "right" });
// Stepping from floor onto ice is still a walk; here we start on ice.
const moved = hold(world, "right", 1);
expect(moved).toEqual([1]);
expect(pose(world).frame).toBe(0);
// Input no longer matters while sliding.
expect(hold(world, "up", 16)).toEqual([4, 8]);
expect([world.x, world.y]).toEqual([6, 3]);
// The wall stopped the slide with a bump, so the player turned up during
// the last hold; still on ice, the step off it is another four-tick slide.
expect(world.direction).toBe("up");
expect(hold(world, "up", 5)).toEqual([1, 5]);
});
it("does not allow button actions while sliding", () => {
const { assets, world } = fixture();
for (let x = 3; x <= 6; x++) assets.tile(x, 3, OW.COLL_ICE);
world.restore({ ...world.save(), direction: "right" });
hold(world, "right", 4);
world.interact();
expect(world.notice).toBe("");
});
it("walks normally from floor onto ice", () => {
const { assets, world } = fixture();
assets.tile(3, 4, OW.COLL_ICE);
const moved = hold(world, "down", WALK_TICKS);
expect(moved).toEqual([1]);
expect(pose(world).frame).not.toBe(undefined);
});
});
describe("bikes, surfing and side buoys", () => {
it("rides a bike at four ticks per tile", () => {
const { world } = fixture();
world.state.playerState = OW.PLAYER_BIKE;
expect(hold(world, "down", 8)).toEqual([1, 5]);
});
it("rides downhill without input and slowly uphill", () => {
const { world } = fixture();
world.state.playerState = OW.PLAYER_BIKE;
world.state.flags.add(OW.ENGINE_DOWNHILL);
world.restore({ ...world.save(), y: 1 });
world.state.playerState = OW.PLAYER_BIKE;
world.state.flags.add(OW.ENGINE_DOWNHILL);
expect(hold(world, undefined, 8)).toEqual([1, 5]);
// Still moving, so no turn: uphill against the slope is a walk.
expect(hold(world, "up", 16)).toEqual([1, 9]);
});
it("surfs over water, refuses walls and gets out onto land", () => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
assets.tile(3, 5, 0x27);
assets.tile(4, 4, 0);
expect(hold(world, "down", WALK_TICKS)).toEqual([1]);
expect(world.state.playerState).toBe(OW.PLAYER_SURF);
expect(hold(world, "down", 4)).toEqual([]);
world.tick("right");
world.tick("right");
world.tick("right");
world.tick("right");
expect(hold(world, "right", 1)).toEqual([1]);
expect(world.state.playerState).toBe(OW.PLAYER_NORMAL);
});
it("buoys block surfing like side walls", () => {
const { assets, world } = fixture();
world.state.playerState = OW.PLAYER_SURF;
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) assets.tile(x, y, 0x29);
// COLL_UP_BUOY below the player blocks a downward move into it.
assets.tile(3, 4, 0xc2);
expect(hold(world, "down", 4)).toEqual([]);
assets.tile(3, 4, 0xc3);
expect(hold(world, "down", 1)).toEqual([1]);
});
it("walking cannot enter water", () => {
const { assets, world } = fixture();
assets.tile(3, 4, 0x29);
expect(hold(world, "down", 4)).toEqual([]);
});
});
describe("dynamic warps (-1)", () => {
it("remembers the entrance on arrival and returns through a -1 warp", () => {
const { assets, world } = fixture();
// 24:7's warp 1 at (3,4) leads to 24:6 warp 1, a -1 warp back.
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 1 });
assets.map("24:7").warps.push({ x: 5, y: 4, targetMap: "24:6", targetWarp: 1 });
assets.map("24:6").warps.push({ x: 2, y: 2, targetMap: "24:6", targetWarp: 0xff });
assets.tile(3, 4, 0x72);
assets.tile(5, 4, 0x72);
assets.tile(2, 3, 0x72, "24:6");
assets.map("24:6").warps.push({ x: 2, y: 3, targetMap: "24:6", targetWarp: 0xff });
world.restore({ ...world.save(), x: 5 });
expect(world.move("down")).toBe(true);
expect([world.mapId, world.x, world.y]).toEqual(["24:6", 2, 2]);
expect(world.state.backupWarp).toEqual({ map: "24:7", warp: 2 });
const saved = world.save();
expect(saved.backupWarp).toEqual({ map: "24:7", warp: 2 });
world.restore(saved);
expect(world.move("down")).toBe(true);
expect([world.mapId, world.x, world.y]).toEqual(["24:7", 5, 4]);
});
it("refuses a -1 warp without a recorded destination", () => {
const { assets, world } = fixture();
assets.map("24:7").warps.push({ x: 3, y: 4, targetMap: "24:6", targetWarp: 0xff });
assets.tile(3, 4, 0x72);
expect(world.move("down")).toBe(false);
expect(world.notice).toMatch(/not ported/);
});
it("rejects malformed backup warps in saves", () => {
const { world } = fixture();
for (const backupWarp of [{ map: "x", warp: 1 }, { map: "24:6", warp: 0 }, 5])
expect(() => world.restore({ ...world.save(), backupWarp })).toThrow("Invalid native save");
expect(() => world.restore({ ...world.save(), playerState: 3 })).toThrow("Invalid native save");
});
});
describe("Strength boulders", () => {
it("pushes a boulder one tile over sixteen ticks while the player bumps", () => {
const { assets, world } = fixture();
const type = 19; // SPRITEMOVEDATA_STRENGTH_BOULDER
const entry = SYMBOLS.spriteMovementData + type * 6;
// MovementFunction_Strength; WONT_DELETE | FIXED_FACING | SLIDING |
// MOVE_ANYWHERE; STRENGTH_BOULDER.
assets.bytes.set([0x11, 0, 1, 0x2e, 0, 0x40], entry);
assets.map("24:7").objects.push({
id: 2, x: 3, y: 4, sprite: 1, movement: type, radius: 0,
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
});
world.restore(world.save());
// Without Strength the boulder does not move.
hold(world, "down", 20);
expect(world.objects()[0]).toMatchObject({ x: 3, y: 4 });
world.state.flags.add(OW.ENGINE_STRENGTH_ACTIVE);
world.tick("down");
expect(world.objects()[0]).toMatchObject({ x: 3, y: 5 });
expect([world.x, world.y]).toEqual([3, 3]);
hold(world, undefined, 15);
expect(world.save().objects).toEqual([{ id: 2, x: 3, y: 5, direction: "down" }]);
// It stops at walls.
assets.tile(3, 7, 7);
hold(world, "down", 60);
expect(world.objects()[0]).toMatchObject({ x: 3, y: 6 });
});
});
describe("changeblock", () => {
const trigger = (script: number[]) => {
const { assets, world } = fixture();
assets.bytes.set(script, 0x100);
assets.map("24:7").coordinateEvents.push({ x: 3, y: 4, scene: 0xff, script: 0x100 });
return { assets, world };
};
it("changes the working block data until the map loads again", () => {
// changeblock 2, 2, $05; end
const { world } = trigger([0x7a, 2, 2, 5, 0x91]);
expect(world.move("down")).toBe(true);
settle(world);
expect(world.notice).toBe("");
expect(world.map.blocks[1 * 4 + 1]).toBe(5);
expect(world.blockRevision).toBe(1);
world.warpTo("24:7", 3, 3);
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
});
it("restores the blocks when a script that changed them is refused", () => {
// changeblock 2, 2, $05; an unported command
const { world } = trigger([0x7a, 2, 2, 5, 0xff]);
world.move("down");
settle(world);
expect(world.notice).toMatch(/not been ported/);
expect(world.map.blocks[1 * 4 + 1]).toBe(0);
});
it("refuses blocks outside the map or beyond the tileset", () => {
for (const script of [[0x7a, 8, 2, 5, 0x91], [0x7a, 2, 2, 0x80, 0x91]]) {
const { world } = trigger(script);
world.move("down");
settle(world);
expect(world.notice).toMatch(/not been ported/);
}
});
});
describe("NPC movement functions", () => {
const withObject = (fn: number, action = 1, flags = 0, sprite = 1) => {
const { assets, world } = fixture();
const type = 30;
assets.bytes.set([fn, 0, action, flags, 0, 0], SYMBOLS.spriteMovementData + type * 6);
assets.map("24:7").objects.push({
id: 2, x: 5, y: 3, sprite, movement: type, radius: 0,
palette: 0, kind: 0, script: 0, eventFlag: 0xffff, hour1: 0xff, hour2: 0xff,
});
world.restore(world.save());
return { assets, world };
};
const facingOf = (world: ReturnType<typeof fixture>["world"]) =>
world.objects().find((object) => object.id === 2)!.direction;
it.each<[number, Direction[]]>([
[0x18, ["down", "left", "up", "right", "down"]],
[0x19, ["down", "right", "up", "left", "down"]],
])("SPRITEMOVEFN %i turns every sixteen ticks", (fn, expected) => {
const { world } = withObject(fn);
const seen: Direction[] = [];
for (let tick = 0; tick < 16 * 5; tick++) {
world.tick();
if (tick % 16 === 0) seen.push(facingOf(world));
}
expect(seen).toEqual(expected);
});
it("bounces between the down and up standing frames every eight ticks", () => {
const { world } = withObject(0x16, 0x0a, 0x2e);
const facings: (number | undefined)[] = [];
for (let tick = 0; tick < 17; tick++) {
world.tick();
facings.push(world.poses().find((pose) => pose.id === 2)?.facing);
}
expect(facings.slice(0, 7).every((facing) => facing === 0)).toBe(true);
expect(facings.slice(7, 15).every((facing) => facing === 4)).toBe(true);
expect(facings[15]).toBe(0);
});
it("draws big dolls with their own facing layouts", () => {
const { world } = withObject(0x15, 0x09);
world.tick();
expect(world.poses().find((pose) => pose.id === 2)?.facing).toBe(0x17);
});
it("resolves variable sprites from wVariableSprites and hides unset ones", () => {
const { world } = withObject(0x06, 1, 0, 0xf3);
expect(world.objects()).toHaveLength(0);
world.state.variables[3] = 0x2a;
world.restore(world.save());
expect(world.poses().find((pose) => pose.id === 2)?.sprite).toBe(0x2a);
});
});