Files
Crystal/tests/movement.test.ts
T
kawaandClaude Opus 5.5 2640fa2725 Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)
Enable Route 35, Route 36, Route 37, the National Park and its gates,
Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's
room and both floors of the Burned Tower. The story runs from Floria,
the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the
rival in the Burned Tower and the beasts' release to Ecruteak Gym,
Morty, the FOGBADGE and TM30.

Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons,
CheckEncounterRoamMon) with their state in the save; only Continue moves
them on restore, so a failed import's rollback keeps them in place.
Port the Bug-Catching Contest: registration on contest days, Park Balls,
contestants, the timer, QUIT, the catch-and-swap prompt, judging from
the ROM's contestant table, prizes and the saved contest state.

Port the Game Corner's SlotMachine and CardFlip on a new realtime tile
screen that advances two frames per tick with buffered buttons: reel
strips and scrolling, bias tables, the seven streak, Golem and Chansey,
payouts, the Card Flip board, cursor and navigation. Their tilemaps are
checked against the ROM's own data.

Also port getnum, a plain warp that continues its script on the new map,
and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the
Burned Tower rival) is no longer sighted once its own flag is set, as
the original's read of bit 1 of wNumKeyItems gives with the usual key
items.

The playthrough walks through the National Park and continues to Morty;
its driver chooses moves by type matchup, buys at Marts and uses Super
Potions and Awakenings in trainer battles. New tests cover roaming, the
contest, the Game Corner and Ecruteak's side content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:10:17 +02:00

301 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 { afterRestore, 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");
const reloaded = restored.save();
expect(reloaded).toEqual(afterRestore(beforeLanding, reloaded));
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,
});
});
});