Files
Crystal/tests/world-driver.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

132 lines
4.2 KiB
TypeScript

import type { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
/** Drive a world through scenes the way a player would: acknowledge pages,
* answer choices in order (default YES / first weekday) and wait out timed
* effects. Returns every page that was shown. */
export function settle(
world: GameWorld,
answers: (number | string)[] = [],
): string[] {
const shown: string[] = [];
let lastHeld = "";
for (let tick = 0; tick < 20_000; tick++) {
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index];
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) {
shown.push(page);
// The naming screen waits for the caller unless a name is queued.
if (dialogue.choice.kind === "name") {
if (typeof answers[0] !== "string") return shown;
world.choose(answers.shift()!);
continue;
}
const answer = answers.shift();
world.choose(
typeof answer === "number"
? answer
: dialogue.choice.kind === "yesno"
? 1
: 0,
);
} else if (last && dialogue.holdTicks !== undefined) {
if (page !== lastHeld) shown.push(page);
lastHeld = page;
world.tick();
} else {
shown.push(page);
world.advanceDialogue();
}
continue;
}
lastHeld = "";
if (!world.busy) {
// Scene scripts start on an idle tick.
world.tick();
if (!world.busy) return shown;
continue;
}
world.tick();
}
throw new Error("The world did not settle.");
}
const DIRECTIONS = [
["up", 0, -1],
["down", 0, 1],
["left", -1, 0],
["right", 1, 0],
] as const;
/** Shortest walkable path on the current map, avoiding other warps. */
export function findPath(
world: GameWorld,
targetX: number,
targetY: number,
): Direction[] {
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
const visited = new Set<string>();
for (let i = 0; i < queue.length; i++) {
const node = queue[i],
key = `${node.x},${node.y}`;
if (visited.has(key)) continue;
visited.add(key);
if (node.x === targetX && node.y === targetY) return node.path;
for (const [direction, dx, dy] of DIRECTIONS) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
if (
world.assets.permission(world.assets.collision(world.map, x, y)) !== 0
)
continue;
if (world.objects().some((object) => object.x === x && object.y === y))
continue;
if (
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== targetX || y !== targetY)
)
continue;
queue.push({ x, y, path: [...node.path, direction] });
}
}
throw new Error(`No route to ${targetX},${targetY}`);
}
/** Walk to a tile, settling any scene that a step triggers. A roaming NPC
* that steps into the way is waited out, then the route is planned again. */
export function walkTo(
world: GameWorld,
targetX: number,
targetY: number,
answers: (number | string)[] = [],
): string[] {
// Finish anything in progress first, such as the tick after a warp.
const shown: string[] = settle(world, answers);
for (let attempt = 0; attempt < 20; attempt++) {
let blocked = false;
for (const direction of findPath(world, targetX, targetY)) {
if (!world.move(direction)) {
const [, dx, dy] = DIRECTIONS.find(([name]) => name === direction)!;
const blocker = world
.objects()
.some((object) => object.x === world.x + dx && object.y === world.y + dy);
if (!blocker)
throw new Error(
`Blocked moving ${direction} at ${world.x},${world.y}: ${world.notice}`,
);
for (let tick = 0; tick < 64; tick++) world.tick();
blocked = true;
break;
}
shown.push(...settle(world, answers));
}
// A warp target leaves the map, so a completed path is the success test.
if (!blocked) return shown;
}
throw new Error(`Could not reach ${targetX},${targetY}`);
}