Exact source used by this build. SHA-256: 10dccde820fa279c88b86b463efa3dd836eceb0dc8b628711980a5c7280a84ea.
1 import { z } from "zod";
2 import type { IntentResult, Player, SimRules, Warp, WorldState } from "../../types";
3 import { withPlayer } from "../../state";
4 import { defineIntent, defineSystem, type SystemContext } from "../registry";
5 import { FacingSchema } from "../schema";
6 import { isBlocked, movementPath } from "./geometry";
7 export * from "./geometry";
8 import { getMineEquipmentEffects } from "../mines/model";
9 export function movementSpeed(player: Player, rules: SimRules): number { return 4 + getMineEquipmentEffects(player, rules).speed; }
10 function contains(warp: Warp, x: number, y: number): boolean {
11 return x >= warp.x && y >= warp.y && x < warp.x + warp.width && y < warp.y + warp.height;
12 }
13 function transition(state: WorldState, player: Player, warp: Warp, context: Pick<SystemContext, "isWarpUnlocked" | "positionError">): IntentResult {
14 const destination = state.areas[warp.targetAreaId];
15 if ((warp.locked || destination?.locked) && !context.isWarpUnlocked?.(warp)) return { state, events: [{ type: "repairsNeeded", playerId: player.id, areaId: player.areaId, message: "Repairs needed" }] };
16 if (!destination || isBlocked(destination, warp.targetX, warp.targetY)) return { state, events: [], error: "Destination is unavailable" };
17 const error = context.positionError?.({ ...player, areaId: destination.id }, warp.targetX, warp.targetY);
18 if (error) return { state, events: [], error };
19 return { state: withPlayer(state, { ...player, areaId: destination.id, x: warp.targetX, y: warp.targetY }), events: [{ type: "areaChanged", playerId: player.id, areaId: destination.id, snapshot: true }] };
20 }
21 export const movementSystem = defineSystem({
22 id: "movement", worldDefaults: () => ({}), playerDefaults: () => ({}), events: ["areaChanged", "repairsNeeded"],
23 movementSpeedMultiplier: (_state, player, rules) => movementSpeed(player, rules) / 4,
24 initializePlayer: (state, player) => {
25 const spawn = player.areaId === state.starterPlayer.areaId ? state.starterPlayer : state.areas[player.areaId]!.spawn;
26 return { x: spawn.x, y: spawn.y, facing: player.areaId === state.starterPlayer.areaId ? state.starterPlayer.facing : "down" };
27 },
28 intents: [
29 defineIntent("move", z.object({ type: z.literal("move"), x: z.number(), y: z.number(), facing: FacingSchema }), ({ state, player, elapsedMs, rules, movementSpeed: registeredSpeed, positionError, isWarpUnlocked }, intent) => {
30 const area = state.areas[player.areaId];
31 if (!area || !Number.isFinite(intent.x) || !Number.isFinite(intent.y)) return { state, events: [], error: "Invalid move" };
32 if (intent.x === player.x && intent.y === player.y && intent.facing === player.facing) return { state, events: [], timingCostMs: 0 };
33 const elapsed = Number.isFinite(elapsedMs) ? Math.min(1000, Math.max(0, elapsedMs ?? 0)) : 0;
34 const distance = Math.hypot(intent.x - player.x, intent.y - player.y);
35 const speed = registeredSpeed ?? movementSpeed(player, rules);
36 if (distance > speed * (elapsed / 1000) + 0.001) return { state, events: [], error: "Move exceeds speed limit" };
37 if (intent.x < 0 || intent.y < 0 || intent.x >= area.width || intent.y >= area.height) return { state, events: [], error: "Move is out of bounds" };
38 for (const { x, y, progress, collisionOnly } of movementPath(area, player, intent)) {
39 if (isBlocked(area, x, y)) return { state, events: [], error: "Move is blocked" };
40 const error = positionError?.(player, x, y);
41 if (error) return { state, events: [], error };
42 if (collisionOnly) continue;
43 const warp = area.warps.find((entry) => contains(entry, x, y) && !contains(entry, player.x, player.y));
44 if (warp) {
45 const result = transition(state, { ...player, facing: intent.facing }, warp, { positionError, isWarpUnlocked });
46 return { ...result, timingCostMs: result.error || result.state === state ? 0 : distance * progress / speed * 1000 };
47 }
48 }
49 return { state: withPlayer(state, { ...player, x: intent.x, y: intent.y, facing: intent.facing }), events: [], timingCostMs: distance / speed * 1000 };
50 }, { timing: "movement", timingBufferMs: 100, persistence: "batched" }),
51 defineIntent("interact", z.object({ type: z.literal("interact"), target: z.string().min(1) }), ({ state, player, positionError, isWarpUnlocked }, intent) => {
52 const warp = state.areas[player.areaId]?.warps.find((entry) => entry.id === intent.target);
53 if (!warp) return { state, events: [], error: "Unknown interaction" };
54 const nearestX = Math.max(warp.x, Math.min(player.x, warp.x + warp.width));
55 const nearestY = Math.max(warp.y, Math.min(player.y, warp.y + warp.height));
56 if (Math.hypot(player.x - nearestX, player.y - nearestY) > 2) return { state, events: [], error: "Interaction is out of reach" };
57 return transition(state, player, warp, { positionError, isWarpUnlocked });
58 }),
59 ],
60 });
61