Checks / web (push) Canceled after 0s
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>
300 lines
10 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|