1/**2 * Peer marble ring — the pure logic core (no Three.js, headless-testable).3 *4 * A revolving set of NPC marbles rides the same centerline as the player so5 * the run always has company ahead and behind. The ring is a fixed set of6 * SLOTS keyed to signed arc-length offsets from the player; a slot spawns a7 * peer when it is open (session start, or after its previous peer despawned8 * and the slot's cooldown lapsed) and the peer despawns when it drifts out9 * of the live band (too far behind/ahead, or outside the built window).10 *11 * Each spawn draws a fresh identity + behaviour from12 * `rngFamily(seed, "peer/<slot>/<n>/<timeStamp>")` — the SPAWN TIME is part13 * of the seed material (user rule), so the ring is different every session14 * while generation itself stays a pure function of the seed. Peers are15 * kinematic centerline riders with per-peer speed character (base speed,16 * gravity response, drag) — the character drift is what revolves the ring:17 * fast peers pull away and despawn ahead, slow ones fall off the back.18 */19import { rngFamily } from "../gen/rng.ts";2021/** Live band relative to the player: outside this, a peer despawns. */22export const BEHIND_LIMIT = 18;23export const AHEAD_LIMIT = 38;2425/** Default ring size (overridable per session via ?peers=N). */26export const DEFAULT_PEER_COUNT = 6;2728/**29 * Signed arc-length slot offsets for a ring of `n` peers: roughly a third30 * ride behind, the rest lead, spread evenly inside the live band with31 * margin so fresh spawns aren't born on the despawn edge.32 */33export function slotOffsets(n: number): number[] {34 const count = Math.max(0, Math.min(24, Math.floor(n)));35 if (count === 0) return [];36 const nBehind = count >= 3 ? Math.floor(count / 3) : count === 2 ? 1 : 0;37 const nAhead = count - nBehind;38 const out: number[] = [];39 const bLo = -BEHIND_LIMIT + 4;40 const bHi = -7;41 for (let k = 0; k < nBehind; k++) {42 out.push(43 Math.round(nBehind === 1 ? bHi : bLo + (bHi - bLo) * (k / (nBehind - 1))),44 );45 }46 const aLo = 6;47 const aHi = AHEAD_LIMIT - 6;48 for (let k = 0; k < nAhead; k++) {49 out.push(50 Math.round(nAhead === 1 ? aLo : aLo + (aHi - aLo) * (k / (nAhead - 1))),51 );52 }53 return out;54}5556/** The default ring's offsets (kept as a const for tests/back-compat). */57export const SLOT_OFFSETS: readonly number[] = slotOffsets(DEFAULT_PEER_COUNT);5859/** Seconds of spawn scale-in / despawn scale-out. */60export const IN_TIME = 0.45;61export const OUT_TIME = 0.35;6263/** Peer marble mesh variants (weights): mostly the classic ball, with the64 * kit's other marbles as rarer identities. */65const MESH_CHOICES: readonly (readonly [string, number])[] = [66 ["marble-center-high", 4],67 ["marble-center-low", 2],68 ["marble-high", 1.5],69 ["marble-low", 1],70 ["marble-center-butterfly-high", 0.4],71];7273export interface PeerIdentity {74 meshId: string;75 /** Material tint 0–255, or null for the stock kit marble. */76 tint: readonly [number, number, number] | null;77 /** Mesh scale relative to the player marble's scale. */78 scale: number;79 /** Signed lateral offset from the centerline (world units). */80 lateral: number;81 wobbleHz: number;82 wobbleAmp: number;83 wobblePhase: number;84}8586export interface PeerBehaviour {87 base: number;88 min: number;89 max: number;90 gravity: number;91 drag: number;92}9394export interface Peer {95 slot: number;96 /** RNG label path — doubles as a stable debug id. */97 label: string;98 s: number;99 speed: number;100 age: number;101 phase: "in" | "live" | "out";102 phaseT: number;103 identity: PeerIdentity;104 behaviour: PeerBehaviour;105}106107/** Player-feel constants the peers riff on (endless kinematic law). */108const BASE_SPEED = 2.2;109const MIN_SPEED = 1.2;110const MAX_SPEED = 7;111const GRAVITY = 6;112const DRAG = 0.9;113114export interface RingEvents {115 spawned: Peer[];116 removed: Peer[];117}118119export class PeerRingCore {120 /** One entry per slot; null = open. */121 readonly peers: (Peer | null)[];122 readonly offsets: readonly number[];123 private readonly cooldowns: number[];124 private readonly counters: number[];125126 constructor(127 private readonly seed: string,128 offsets: readonly number[] = SLOT_OFFSETS,129 ) {130 this.offsets = offsets;131 this.peers = offsets.map(() => null);132 this.counters = offsets.map(() => 0);133 // Staggered opening: slots come alive over the first moments instead134 // of the whole ring popping in on one frame.135 this.cooldowns = offsets.map((_, i) =>136 rngFamily(seed, `peer-open/${i}`).float() * 2137 );138 }139140 get liveCount(): number {141 return this.peers.filter((p) => p !== null).length;142 }143144 /**145 * Advance the ring one frame.146 * `band` is the arc-length range currently built (sampleable); `nowMs`147 * seeds new spawns (wall clock — presentation-only nondeterminism, by148 * design); `tanYAt` samples the centerline tangent's y at an arc length149 * (null outside the built window).150 */151 update(152 dt: number,153 playerS: number,154 band: readonly [number, number],155 nowMs: number,156 tanYAt: (s: number) => number | null,157 ): RingEvents {158 const events: RingEvents = { spawned: [], removed: [] };159160 for (let slot = 0; slot < this.offsets.length; slot++) {161 const peer = this.peers[slot];162 if (!peer) {163 this.cooldowns[slot] -= dt;164 if (this.cooldowns[slot] <= 0) {165 const spawned = this.spawn(slot, playerS, band, nowMs);166 if (spawned) {167 this.peers[slot] = spawned;168 events.spawned.push(spawned);169 } else {170 this.cooldowns[slot] = 0.5; // band too tight — retry shortly171 }172 }173 continue;174 }175176 peer.age += dt;177 peer.phaseT += dt;178 if (peer.phase === "in" && peer.phaseT >= IN_TIME) {179 peer.phase = "live";180 peer.phaseT = 0;181 }182 if (peer.phase === "out") {183 if (peer.phaseT >= OUT_TIME) {184 this.peers[slot] = null;185 this.cooldowns[slot] = 1 +186 rngFamily(this.seed, `${peer.label}/cooldown`).float() * 2.5;187 events.removed.push(peer);188 }189 continue; // frozen in place while scaling out190 }191192 // Kinematic speed law with the peer's own character.193 const b = peer.behaviour;194 const ty = tanYAt(peer.s) ?? 0;195 peer.speed += (-ty * b.gravity - b.drag * (peer.speed - b.base)) * dt;196 peer.speed = Math.min(b.max, Math.max(b.min, peer.speed));197 peer.s += peer.speed * dt;198199 // Out of the live band → begin the scale-out.200 const rel = peer.s - playerS;201 if (202 rel < -BEHIND_LIMIT || rel > AHEAD_LIMIT ||203 peer.s < band[0] + 0.5 || peer.s > band[1] - 0.5204 ) {205 peer.phase = "out";206 peer.phaseT = 0;207 }208 }209 return events;210 }211212 /** Immediately release every peer (session teardown). */213 clear(): Peer[] {214 const out: Peer[] = [];215 for (let i = 0; i < this.peers.length; i++) {216 const p = this.peers[i];217 if (p) out.push(p);218 this.peers[i] = null;219 }220 return out;221 }222223 private spawn(224 slot: number,225 playerS: number,226 band: readonly [number, number],227 nowMs: number,228 ): Peer | null {229 const lo = band[0] + 1;230 const hi = band[1] - 1;231 if (hi - lo < 4) return null;232 let s = playerS + this.offsets[slot];233 s = Math.min(hi, Math.max(lo, s));234 // Never materialize on top of the player.235 if (Math.abs(s - playerS) < 3) {236 s = playerS + (this.offsets[slot] >= 0 ? 3 : -3);237 if (s < lo || s > hi) return null;238 }239240 const n = this.counters[slot]++;241 // Wall-clock stamp (quarter-second buckets) is deliberately part of242 // the seed material: WHEN a peer spawns shapes WHO it is.243 const label = `peer/${slot}/${n}/${Math.floor(nowMs / 250)}`;244 const rng = rngFamily(this.seed, label);245246 const identity: PeerIdentity = {247 meshId: rng.weighted(MESH_CHOICES),248 tint: rng.chance(0.25)249 ? null250 : drawTint(rng.float(), rng.float(), rng.float()),251 scale: 0.8 + rng.float() * 0.5,252 lateral: (rng.chance(0.5) ? 1 : -1) * (0.06 + rng.float() * 0.18),253 wobbleHz: 0.15 + rng.float() * 0.35,254 wobbleAmp: 0.03 + rng.float() * 0.07,255 wobblePhase: rng.float() * Math.PI * 2,256 };257 const behaviour: PeerBehaviour = {258 base: BASE_SPEED * (0.82 + rng.float() * 0.46),259 min: MIN_SPEED * (0.8 + rng.float() * 0.3),260 max: MAX_SPEED * (0.85 + rng.float() * 0.35),261 gravity: GRAVITY * (0.8 + rng.float() * 0.55),262 drag: DRAG * (0.7 + rng.float() * 0.45),263 };264 return {265 slot,266 label,267 s,268 speed: behaviour.base,269 age: 0,270 phase: "in",271 phaseT: 0,272 identity,273 behaviour,274 };275 }276}277278/** Marble phenotype (user round): the outer SHELL tint and an EFFECT are279 * two additional MULTIPLICATIVE expressions layered over the base look —280 * shell multiplies the color, the effect multiplies material character281 * (gloss/pearl/ember/ghost at a drawn intensity). */282export interface RacerLook {283 meshId: string;284 tint: readonly [number, number, number] | null;285 /** Per-channel color multiplier (1,1,1 = no shell). */286 shell: readonly [number, number, number];287 effect: {288 kind: "none" | "gloss" | "pearl" | "ember" | "ghost";289 intensity: number;290 };291}292293/** Gentle multiplicative shell color: identity at strength 0; a cosine294 * palette keeps every channel in (1−s, 1]. */295function shellOf(296 h: number,297 strength: number,298): readonly [number, number, number] {299 const ch = (o: number): number =>300 1 - strength * (0.5 + 0.5 * Math.cos(2 * Math.PI * (h + o)));301 return [ch(0), ch(1 / 3), ch(2 / 3)];302}303304/** Seed-stable looks for a REAL racer marble (M15 — physics peers). Uses305 * the same mesh pool + tint law as ring peers; label-derived, so adding306 * racers never perturbs anything else. Phenotype draws are APPENDED so307 * older identities stay byte-identical. */308export function racerIdentity(seed: string, index: number): RacerLook {309 const rng = rngFamily(seed, `racer/${index}`);310 const meshId = rng.weighted(MESH_CHOICES);311 const tint = rng.chance(0.2)312 ? null313 : drawTint(rng.float(), rng.float(), rng.float());314 const shell = shellOf(rng.float(), rng.float() * 0.35);315 const kind = rng.weighted(316 [317 ["none", 0.35],318 ["gloss", 0.2],319 ["pearl", 0.15],320 ["ember", 0.15],321 ["ghost", 0.15],322 ] as const,323 ) as RacerLook["effect"]["kind"];324 const intensity = 0.4 + rng.float() * 0.6;325 return { meshId, tint, shell, effect: { kind, intensity } };326}327328// --- rivalries (M20b) -------------------------------------------------------329330export interface Rival {331 /** The racer this one is fixated on. */332 target: number;333 /** Grudge = hunt them; ally = spare them (and spread on braids). */334 valence: "grudge" | "ally";335 /** 0.5-1: scales every expression lever. */336 intensity: number;337}338339/**340 * Seeded pairwise relationship for racer `i` in a pack of `n` — DIRECTED341 * (i fixates on target; the target is oblivious). Symmetry would need a342 * canonical pair hash, and shared hashes are banned by the rng law —343 * asymmetric grudges are both compliant and better drama. Own label344 * `rival/${i}`: a fresh per-racer stream, so racer i's relationship is345 * invariant under everything about racer j. Draw order is FIXED — any346 * future rival trait appends after intensity.347 */348export function rivalFor(seed: string, i: number, n: number): Rival | null {349 if (n < 2) return null;350 const rng = rngFamily(seed, `rival/${i}`);351 const has = rng.chance(0.5);352 const d = 1 + rng.int(0, 16); // drawn even when unused: fixed order353 const valence = rng.chance(0.7) ? "grudge" as const : "ally" as const;354 const intensity = 0.5 + rng.float() * 0.5;355 if (!has) return null;356 const target = (i + d) % n;357 if (target === i) return null;358 return { target, valence, intensity };359}360361/** Rich marble-glass tints (bounded like the pipe colors so peers read362 * against every atmosphere). */363function drawTint(364 h: number,365 sIn: number,366 lIn: number,367): readonly [number, number, number] {368 const s = 0.45 + sIn * 0.4;369 const l = 0.42 + lIn * 0.24;370 const q = l < 0.5 ? l * (1 + s) : l + s - l * s;371 const p = 2 * l - q;372 const channel = (t: number): number => {373 if (t < 0) t += 1;374 if (t > 1) t -= 1;375 if (t < 1 / 6) return p + (q - p) * 6 * t;376 if (t < 1 / 2) return q;377 if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;378 return p;379 };380 return [381 Math.round(channel(h + 1 / 3) * 255),382 Math.round(channel(h) * 255),383 Math.round(channel(h - 1 / 3) * 255),384 ];385}