// LANE M — SIDE B shared helpers for the crate-world machines. // Positions come from the contract (src/core/worlds.ts); this file only adds // the small conversions every world machine needs. No game state lives here. import { WORLD_DEFS, WORLD_KEYS, type WorldKey } from '../../core/worlds'; import { bus, type GameEvents } from '../../core/events'; import type { IPlayerView, Vec3 } from '../../core/types'; /** Walkable floor level inside every pocket: shell 142 + dressing 143 → feet 144. */ export const POCKET_WALK_Y = 144; /** Out-of-pocket safety altitude (brief M1): above this and outside any pocket * bounds, the player is teleported home. The booth wall top is 140. */ export const POCKET_SAFETY_Y = 141; /** * Emit `machine:interact`, optionally carrying the SIDEB briefs' `index` field * (disco cues, acid locks). The contract payload is `{machineId, action}`; * `index` rides along as a structural extra so Lanes S/E2 — whose briefs quote * `{action:'disco_cue', index}` — can read it, while the contract file stays * untouched. The tile/pedestal id is ALSO encoded in machineId * (`disco_tile_`, `acid_ped`) so consumers can use either. */ export function interactAt(machineId: string, action: string, index?: number): void { const payload: GameEvents['machine:interact'] & { index?: number } = { machineId, action }; if (index !== undefined) payload.index = index; bus.emit('machine:interact', payload); } /** Is a world-space point inside a pocket's INCLUSIVE voxel bounds (+margin)? */ export function insidePocket(k: WorldKey, x: number, y: number, z: number, margin = 0.5): boolean { const d = WORLD_DEFS[k]; return ( x >= d.pocketMin[0] - margin && x <= d.pocketMax[0] + 1 + margin && y >= d.pocketMin[1] - margin && y <= d.pocketMax[1] + 1 + margin && z >= d.pocketMin[2] - margin && z <= d.pocketMax[2] + 1 + margin ); } /** The pocket the point is inside, or null. */ export function pocketAt(x: number, y: number, z: number): WorldKey | null { for (const k of WORLD_KEYS) if (insidePocket(k, x, y, z)) return k; return null; } /** Nearest world by xz distance to the pocket centre (safety-net homing). */ export function nearestWorld(x: number, z: number): WorldKey { let best: WorldKey = WORLD_KEYS[0]; let bestD = Infinity; for (const k of WORLD_KEYS) { const d = WORLD_DEFS[k]; const cx = (d.pocketMin[0] + d.pocketMax[0]) / 2; const cz = (d.pocketMin[2] + d.pocketMax[2]) / 2; const dd = (x - cx) * (x - cx) + (z - cz) * (z - cz); if (dd < bestD) { bestD = dd; best = k; } } return best; } /** * Lane B's PlayerController exposes `teleport` and `isFlying` beyond the * IPlayerView contract. We reach them structurally (optional), never by * importing Lane B — a mock player without them simply doesn't move. */ export interface PlayerControl { teleport?(v: Vec3): void; isFlying?: boolean; } export function teleportPlayer(p: IPlayerView | null, v: Vec3): void { (p as (IPlayerView & PlayerControl) | null)?.teleport?.(v); } export function playerFlying(p: IPlayerView | null): boolean { return (p as (IPlayerView & PlayerControl) | null)?.isFlying === true; } /** Tiny deterministic PRNG (mulberry32) for per-entry quest seeds. */ export function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** A fresh per-entry seed (wall clock + a counter so re-entries differ). */ let seedCounter = 0; export function entrySeed(): number { seedCounter = (seedCounter + 0x9e3779b9) >>> 0; return (Date.now() ^ seedCounter) >>> 0; }