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(); readonly tiles = new Map(); 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, }); }); });