TURNCRAFT/src/machines/worlds/common.ts
type-two 33232fdeba SIDE B: the crate worlds — three dive-in pockets, stamps, golden slipmat
Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.

- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
  pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
  flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
  tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
  beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
  (default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
  survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
  vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
  feedback-echo + spring, disco pentatonic tiles) over a boothGain
  duck-and-handback that can never restore stale state; portal/stamp/golden
  SFX; bonus stem.

Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.

Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:22:11 +10:00

96 lines
3.7 KiB
TypeScript

// 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_<i>`, `acid_ped<i>`) 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;
}