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["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["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["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); }); });