clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

Marble ring (user): revolving seeded peer marbles around the player

Dami committed 34bc421
Six slots at signed arc offsets; spawn-time is seed material by design
(peer/<slot>/<n>/<stamp> rng labels). Kinematic centerline riders with
per-peer speed character (drift revolves the ring), seeded visual
identity (mesh variant, glass tint, scale, lateral+wobble). Pure core
peersCore.ts with 7 rule tests; render layer peers.ts; CUA exposes ring
state. Browser soak 4 sim-min clean.
commit
34bc421ab35b4c4bfe784d673b8691af6adf4b42
parent
fd6d0fca15e30265bfc14c2641bee7f9b1937294
author
Dami <dami.etoile@gmail.com>
5 files changed, +670 -0
Mprogress.md21++
Msrc/client/endless.ts35++++
Asrc/client/peers.ts193++++++++++++++++++++++
Asrc/client/peersCore.ts264++++++++++++++++++++++++++++++
Atests/peers_test.ts157++++++++++++++++++
M progress.md +21 -0
@@ -812,6 +812,27 @@ zones (icy needs a visible surface treatment first — zones must READ).
812812 813813 **DONE this round (post-M12, user-directed):**814814 815+- **MARBLE RING** (user: a revolving set of peer marbles so the run is816+ constantly alive ahead and behind): src/client/peersCore.ts (pure817+ slot-lifecycle core, headless-tested) + peers.ts (render layer), wired818+ into the endless Session. Six SLOTS at signed arc offsets from the819+ player; a slot spawns when open (session start / after its peer820+ despawned + cooldown) and the peer despawns when it drifts out of the821+ live band ([−18, +38] rel or outside the built window). SPAWN TIME is822+ part of the seed material by design (rngFamily label823+ `peer/<slot>/<n>/<quarter-second stamp>`) — presentation-only824+ nondeterminism; generation stays a pure function of the seed. Each draw825+ fixes identity (mesh variant incl. rare butterfly, HSL glass tint or826+ stock, scale 0.8–1.3, seeded lateral offset + wobble so passes read827+ side-by-side) and behaviour (base/min/max speed, gravity response,828+ drag riffing on the kinematic law) — the character SPREAD is what829+ revolves the ring: fast peers pull away, slow ones fall off the back,830+ slots refill. Peers are kinematic centerline riders (wasm stays831+ single-marble); rolling spin + easeOutBack scale-in / ease-out.832+ CUA exposes peers + peerRing rel/speed. 7 rule tests833+ (tests/peers_test.ts); browser soak 4 sim-min: 6 live with turnover,834+ 0 deaths, nodes bounded, only the favicon 404 in console.835+815836 - **PIPE COLOR IS PER ISLAND** (user refinement of the round below): in816837 endless, every Placement carries a `chapter` (the most recent island's817838 world-unique id at its cells — world.ts stamps it via the window→
M src/client/endless.ts +35 -0
@@ -41,6 +41,7 @@ import { Atmosphere, drawZone } from "./atmosphere.ts";
4141 import type { DecorCatalog } from "../gen/decor.ts";4242 import { genVersion } from "../gen/version.ts";4343 import { drawPipeColor } from "./pipeColor.ts";44+import { PeerRing } from "./peers.ts";4445 import { installCua } from "./cua.ts";4546 4647 // Per-tile physics modifiers + planner zones live in physics/rail.ts now
@@ -180,6 +181,9 @@ export function bootEndless(
180181 weather: session.atmosphere.weatherKind,181182 physics: session.physicsActive,182183 deaths: session.deaths,184+ // Peer ring lifecycle (marble ring): live count + relative arcs.185+ peers: session.peerRing.liveCount,186+ peerRing: session.peerRing.stats(session.marbleS),183187 // Instancing gate: island pieces share draw calls now.184188 drawCalls: ctx.renderer.info.render.calls,185189 }
@@ -215,6 +219,8 @@ class Session {
215219 private statsClock = 0;216220 217221 readonly atmosphere: Atmosphere;222+ /** The revolving ring of seeded NPC marbles around the player. */223+ readonly peerRing: PeerRing;218224 /** Box3D driver (null → kinematic rail rider, ?phys=0). */219225 private engine: PhysicsEngine | null = null;220226 private readonly rails = new Map<number, Rail>();
@@ -295,6 +301,7 @@ class Session {
295301 this.pivot.add(mesh);296302 }297303 ctx.scene.add(this.marbleObj);304+ this.peerRing = new PeerRing(seed, assets, ctx.scene);298305 299306 this.ensure();300307 this.initialBuild = false;
@@ -354,6 +361,27 @@ class Session {
354361 return true;355362 }356363 364+ /** Point + tangent at global arc length s (peer riders). */365+ sampleFrame(s: number, pos: THREE.Vector3, tan: THREE.Vector3): boolean {366+ const seg = this.segmentAt(s);367+ if (!seg) return false;368+ const u = Math.min(1, Math.max(0, (s - seg.startS) / seg.length));369+ seg.curve.getPointAt(u, pos);370+ seg.curve.getTangentAt(u, tan);371+ return true;372+ }373+374+ /** Arc-length range currently sampleable (the built window). */375+ private builtBand(): [number, number] {376+ let lo = Infinity;377+ let hi = -Infinity;378+ for (const seg of this.built.values()) {379+ lo = Math.min(lo, seg.startS);380+ hi = Math.max(hi, seg.startS + seg.length);381+ }382+ return lo <= hi ? [lo, hi] : [0, 0];383+ }384+357385 private segmentAt(s: number): BuiltSegment | null {358386 let best: BuiltSegment | null = null;359387 for (const seg of this.built.values()) {
@@ -529,6 +557,12 @@ class Session {
529557 }530558 this.ensure();531559 this.atmosphere.update(dt, this.focus);560+ this.peerRing.update(561+ dt,562+ this.marbleS,563+ this.builtBand(),564+ (s, pos, tan) => this.sampleFrame(s, pos, tan),565+ );532566 this.finishFrame(dt);533567 }534568
@@ -754,6 +788,7 @@ class Session {
754788 }755789 this.exiting = [];756790 this.atmosphere.dispose();791+ this.peerRing.dispose();757792 this.ctx.scene.remove(this.root);758793 this.ctx.scene.remove(this.marbleObj);759794 this.cams.retargetPath(null, null);
A src/client/peers.ts +193 -0
@@ -0,0 +1,193 @@
1+/**2+ * Peer marble ring — render layer. Owns the Three.js meshes for the3+ * PeerRingCore's peers: tinted marble-kit variants riding the session's4+ * centerline with a small seeded lateral offset + wobble (so passes read5+ * as side-by-side overtakes, not superimpositions), rolling spin, and6+ * scale-in/out on spawn/despawn.7+ */8+import * as THREE from "three";9+import type { Assets } from "./assets.ts";10+import { MARBLE_SCALE } from "./physics/engine.ts";11+import {12+ IN_TIME,13+ OUT_TIME,14+ type Peer,15+ PeerRingCore,16+} from "./peersCore.ts";17+18+/** Samples the session's global centerline at arc length s. */19+export type FrameSampler = (20+ s: number,21+ pos: THREE.Vector3,22+ tan: THREE.Vector3,23+) => boolean;24+25+const FALLBACK_MESH = "marble-center-high";26+27+interface PeerVisual {28+ peer: Peer;29+ obj: THREE.Group;30+ pivot: THREE.Group;31+ radius: number;32+ /** Cloned (tinted) materials to dispose with the peer. */33+ materials: THREE.Material[];34+ baseScale: number;35+}36+37+export class PeerRing {38+ private readonly core: PeerRingCore;39+ private readonly visuals = new Map<string, PeerVisual>();40+ private readonly point = new THREE.Vector3();41+ private readonly tangent = new THREE.Vector3();42+ private readonly axis = new THREE.Vector3();43+ private readonly spinQ = new THREE.Quaternion();44+ private readonly up = new THREE.Vector3(0, 1, 0);45+46+ constructor(47+ seed: string,48+ private readonly assets: Assets,49+ private readonly parent: THREE.Object3D,50+ ) {51+ this.core = new PeerRingCore(seed);52+ }53+54+ get liveCount(): number {55+ return this.core.liveCount;56+ }57+58+ /** Peer arc positions relative to the player (CUA/debug). */59+ stats(playerS: number): { slot: number; rel: number; speed: number }[] {60+ return this.core.peers.flatMap((p) =>61+ p62+ ? [{63+ slot: p.slot,64+ rel: Number((p.s - playerS).toFixed(1)),65+ speed: Number(p.speed.toFixed(2)),66+ }]67+ : []68+ );69+ }70+71+ update(72+ dt: number,73+ playerS: number,74+ band: readonly [number, number],75+ sample: FrameSampler,76+ ): void {77+ const events = this.core.update(78+ dt,79+ playerS,80+ band,81+ performance.now(),82+ (s) => sample(s, this.point, this.tangent) ? this.tangent.y : null,83+ );84+ for (const peer of events.spawned) this.buildVisual(peer);85+ for (const peer of events.removed) this.destroyVisual(peer);86+87+ for (const vis of this.visuals.values()) {88+ const { peer } = vis;89+ if (!sample(peer.s, this.point, this.tangent)) continue;90+91+ // Lateral offset + wobble: perpendicular to travel in the horizontal92+ // plane, faded out where the tangent goes vertical (helix dives).93+ const nl = Math.hypot(this.tangent.z, this.tangent.x);94+ if (nl > 1e-4) {95+ const off = peer.identity.lateral +96+ Math.sin(97+ peer.age * peer.identity.wobbleHz * Math.PI * 2 +98+ peer.identity.wobblePhase,99+ ) * peer.identity.wobbleAmp;100+ const f = Math.min(1, nl / 0.3);101+ this.point.x += (this.tangent.z / nl) * off * f;102+ this.point.z += (-this.tangent.x / nl) * off * f;103+ }104+ vis.obj.position.copy(this.point);105+106+ // Rolling spin, same law as the player marble.107+ if (peer.phase !== "out") {108+ this.axis.copy(this.up).cross(this.tangent);109+ if (this.axis.lengthSq() > 1e-8) {110+ this.axis.normalize();111+ this.spinQ.setFromAxisAngle(112+ this.axis,113+ (peer.speed * dt) / Math.max(vis.radius, 1e-3),114+ );115+ vis.pivot.quaternion.premultiply(this.spinQ);116+ }117+ }118+119+ // Spawn/despawn scale envelope (easeOutBack in, ease-in out).120+ let k = 1;121+ if (peer.phase === "in") {122+ const t = Math.min(1, peer.phaseT / IN_TIME);123+ const c = 1.70158;124+ const u = t - 1;125+ k = 1 + (c + 1) * u * u * u + c * u * u;126+ } else if (peer.phase === "out") {127+ const t = Math.min(1, peer.phaseT / OUT_TIME);128+ k = Math.max(1e-3, 1 - t * t);129+ }130+ vis.obj.scale.setScalar(k * vis.baseScale);131+ }132+ }133+134+ dispose(): void {135+ for (const peer of this.core.clear()) this.destroyVisual(peer);136+ }137+138+ private buildVisual(peer: Peer): void {139+ const obj = new THREE.Group();140+ obj.name = `peer:${peer.label}`;141+ const pivot = new THREE.Group();142+ obj.add(pivot);143+ const mesh = this.assets.getPiece(peer.identity.meshId) ??144+ this.assets.getPiece(FALLBACK_MESH);145+ let radius = 0.1;146+ const materials: THREE.Material[] = [];147+ if (mesh) {148+ mesh.scale.setScalar(MARBLE_SCALE * peer.identity.scale);149+ if (peer.identity.tint) {150+ const [r, g, b] = peer.identity.tint;151+ mesh.traverse((child: THREE.Object3D) => {152+ const m = child as THREE.Mesh;153+ if (!m.isMesh) return;154+ const tintOne = (mat: THREE.Material): THREE.Material => {155+ const clone = (mat as THREE.MeshStandardMaterial).clone();156+ clone.color.setRGB(r / 255, g / 255, b / 255);157+ materials.push(clone);158+ return clone;159+ };160+ m.material = Array.isArray(m.material)161+ ? m.material.map(tintOne)162+ : tintOne(m.material);163+ });164+ }165+ const box = new THREE.Box3().setFromObject(mesh);166+ const center = box.getCenter(new THREE.Vector3());167+ const size = box.getSize(new THREE.Vector3());168+ radius = Math.max(size.x, size.y, size.z) / 2 || 0.1;169+ pivot.position.copy(center);170+ mesh.position.sub(center);171+ pivot.add(mesh);172+ }173+ obj.scale.setScalar(1e-3); // grows in via the scale envelope174+ this.parent.add(obj);175+ this.visuals.set(peer.label, {176+ peer,177+ obj,178+ pivot,179+ radius,180+ materials,181+ baseScale: 1,182+ });183+ }184+185+ private destroyVisual(peer: Peer): void {186+ const vis = this.visuals.get(peer.label);187+ if (!vis) return;188+ this.visuals.delete(peer.label);189+ this.parent.remove(vis.obj);190+ vis.obj.clear();191+ for (const m of vis.materials) m.dispose();192+ }193+}
A src/client/peersCore.ts +264 -0
@@ -0,0 +1,264 @@
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+ */19+import { rngFamily } from "../gen/rng.ts";20+21+/** Signed arc-length offsets (world units) the ring keeps populated. */22+export const SLOT_OFFSETS: readonly number[] = [-13, -7, 6, 12, 19, 27];23+24+/** Live band relative to the player: outside this, a peer despawns. */25+export const BEHIND_LIMIT = 18;26+export const AHEAD_LIMIT = 38;27+28+/** Seconds of spawn scale-in / despawn scale-out. */29+export const IN_TIME = 0.45;30+export const OUT_TIME = 0.35;31+32+/** Peer marble mesh variants (weights): mostly the classic ball, with the33+ * kit's other marbles as rarer identities. */34+const MESH_CHOICES: readonly (readonly [string, number])[] = [35+ ["marble-center-high", 4],36+ ["marble-center-low", 2],37+ ["marble-high", 1.5],38+ ["marble-low", 1],39+ ["marble-center-butterfly-high", 0.4],40+];41+42+export interface PeerIdentity {43+ meshId: string;44+ /** Material tint 0–255, or null for the stock kit marble. */45+ tint: readonly [number, number, number] | null;46+ /** Mesh scale relative to the player marble's scale. */47+ scale: number;48+ /** Signed lateral offset from the centerline (world units). */49+ lateral: number;50+ wobbleHz: number;51+ wobbleAmp: number;52+ wobblePhase: number;53+}54+55+export interface PeerBehaviour {56+ base: number;57+ min: number;58+ max: number;59+ gravity: number;60+ drag: number;61+}62+63+export interface Peer {64+ slot: number;65+ /** RNG label path — doubles as a stable debug id. */66+ label: string;67+ s: number;68+ speed: number;69+ age: number;70+ phase: "in" | "live" | "out";71+ phaseT: number;72+ identity: PeerIdentity;73+ behaviour: PeerBehaviour;74+}75+76+/** Player-feel constants the peers riff on (endless kinematic law). */77+const BASE_SPEED = 2.2;78+const MIN_SPEED = 1.2;79+const MAX_SPEED = 7;80+const GRAVITY = 6;81+const DRAG = 0.9;82+83+export interface RingEvents {84+ spawned: Peer[];85+ removed: Peer[];86+}87+88+export class PeerRingCore {89+ /** One entry per slot; null = open. */90+ readonly peers: (Peer | null)[];91+ private readonly cooldowns: number[];92+ private readonly counters: number[];93+94+ constructor(private readonly seed: string) {95+ this.peers = SLOT_OFFSETS.map(() => null);96+ this.counters = SLOT_OFFSETS.map(() => 0);97+ // Staggered opening: slots come alive over the first moments instead98+ // of six marbles popping in on one frame.99+ this.cooldowns = SLOT_OFFSETS.map((_, i) =>100+ rngFamily(seed, `peer-open/${i}`).float() * 2101+ );102+ }103+104+ get liveCount(): number {105+ return this.peers.filter((p) => p !== null).length;106+ }107+108+ /**109+ * Advance the ring one frame.110+ * `band` is the arc-length range currently built (sampleable); `nowMs`111+ * seeds new spawns (wall clock — presentation-only nondeterminism, by112+ * design); `tanYAt` samples the centerline tangent's y at an arc length113+ * (null outside the built window).114+ */115+ update(116+ dt: number,117+ playerS: number,118+ band: readonly [number, number],119+ nowMs: number,120+ tanYAt: (s: number) => number | null,121+ ): RingEvents {122+ const events: RingEvents = { spawned: [], removed: [] };123+124+ for (let slot = 0; slot < SLOT_OFFSETS.length; slot++) {125+ const peer = this.peers[slot];126+ if (!peer) {127+ this.cooldowns[slot] -= dt;128+ if (this.cooldowns[slot] <= 0) {129+ const spawned = this.spawn(slot, playerS, band, nowMs);130+ if (spawned) {131+ this.peers[slot] = spawned;132+ events.spawned.push(spawned);133+ } else {134+ this.cooldowns[slot] = 0.5; // band too tight — retry shortly135+ }136+ }137+ continue;138+ }139+140+ peer.age += dt;141+ peer.phaseT += dt;142+ if (peer.phase === "in" && peer.phaseT >= IN_TIME) {143+ peer.phase = "live";144+ peer.phaseT = 0;145+ }146+ if (peer.phase === "out") {147+ if (peer.phaseT >= OUT_TIME) {148+ this.peers[slot] = null;149+ this.cooldowns[slot] = 1 +150+ rngFamily(this.seed, `${peer.label}/cooldown`).float() * 2.5;151+ events.removed.push(peer);152+ }153+ continue; // frozen in place while scaling out154+ }155+156+ // Kinematic speed law with the peer's own character.157+ const b = peer.behaviour;158+ const ty = tanYAt(peer.s) ?? 0;159+ peer.speed += (-ty * b.gravity - b.drag * (peer.speed - b.base)) * dt;160+ peer.speed = Math.min(b.max, Math.max(b.min, peer.speed));161+ peer.s += peer.speed * dt;162+163+ // Out of the live band → begin the scale-out.164+ const rel = peer.s - playerS;165+ if (166+ rel < -BEHIND_LIMIT || rel > AHEAD_LIMIT ||167+ peer.s < band[0] + 0.5 || peer.s > band[1] - 0.5168+ ) {169+ peer.phase = "out";170+ peer.phaseT = 0;171+ }172+ }173+ return events;174+ }175+176+ /** Immediately release every peer (session teardown). */177+ clear(): Peer[] {178+ const out: Peer[] = [];179+ for (let i = 0; i < this.peers.length; i++) {180+ const p = this.peers[i];181+ if (p) out.push(p);182+ this.peers[i] = null;183+ }184+ return out;185+ }186+187+ private spawn(188+ slot: number,189+ playerS: number,190+ band: readonly [number, number],191+ nowMs: number,192+ ): Peer | null {193+ const lo = band[0] + 1;194+ const hi = band[1] - 1;195+ if (hi - lo < 4) return null;196+ let s = playerS + SLOT_OFFSETS[slot];197+ s = Math.min(hi, Math.max(lo, s));198+ // Never materialize on top of the player.199+ if (Math.abs(s - playerS) < 3) {200+ s = playerS + (SLOT_OFFSETS[slot] >= 0 ? 3 : -3);201+ if (s < lo || s > hi) return null;202+ }203+204+ const n = this.counters[slot]++;205+ // Wall-clock stamp (quarter-second buckets) is deliberately part of206+ // the seed material: WHEN a peer spawns shapes WHO it is.207+ const label = `peer/${slot}/${n}/${Math.floor(nowMs / 250)}`;208+ const rng = rngFamily(this.seed, label);209+210+ const identity: PeerIdentity = {211+ meshId: rng.weighted(MESH_CHOICES),212+ tint: rng.chance(0.25) ? null : drawTint(rng.float(), rng.float(), rng.float()),213+ scale: 0.8 + rng.float() * 0.5,214+ lateral: (rng.chance(0.5) ? 1 : -1) * (0.06 + rng.float() * 0.18),215+ wobbleHz: 0.15 + rng.float() * 0.35,216+ wobbleAmp: 0.03 + rng.float() * 0.07,217+ wobblePhase: rng.float() * Math.PI * 2,218+ };219+ const behaviour: PeerBehaviour = {220+ base: BASE_SPEED * (0.82 + rng.float() * 0.46),221+ min: MIN_SPEED * (0.8 + rng.float() * 0.3),222+ max: MAX_SPEED * (0.85 + rng.float() * 0.35),223+ gravity: GRAVITY * (0.8 + rng.float() * 0.55),224+ drag: DRAG * (0.7 + rng.float() * 0.45),225+ };226+ return {227+ slot,228+ label,229+ s,230+ speed: behaviour.base,231+ age: 0,232+ phase: "in",233+ phaseT: 0,234+ identity,235+ behaviour,236+ };237+ }238+}239+240+/** Rich marble-glass tints (bounded like the pipe colors so peers read241+ * against every atmosphere). */242+function drawTint(243+ h: number,244+ sIn: number,245+ lIn: number,246+): readonly [number, number, number] {247+ const s = 0.45 + sIn * 0.4;248+ const l = 0.42 + lIn * 0.24;249+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;250+ const p = 2 * l - q;251+ const channel = (t: number): number => {252+ if (t < 0) t += 1;253+ if (t > 1) t -= 1;254+ if (t < 1 / 6) return p + (q - p) * 6 * t;255+ if (t < 1 / 2) return q;256+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;257+ return p;258+ };259+ return [260+ Math.round(channel(h + 1 / 3) * 255),261+ Math.round(channel(h) * 255),262+ Math.round(channel(h - 1 / 3) * 255),263+ ];264+}
A tests/peers_test.ts +157 -0
@@ -0,0 +1,157 @@
1+/**2+ * Peer marble ring (marble ring feature) — rule-based lifecycle tests over3+ * the pure core (peersCore.ts). No Three.js, no wall clock: time is passed4+ * in explicitly, so every rule is deterministic here even though the live5+ * client deliberately seeds spawns from the real clock.6+ *7+ * Rules pinned:8+ * - the ring fills every slot once cooldowns lapse (the run is ALIVE);9+ * - identity/behaviour are a pure function of (seed, slot, count, stamp);10+ * - different spawn stamps → different identities (time shapes who spawns);11+ * - peers that drift out of the live band despawn, and the slot REOPENS12+ * and respawns (the revolving property);13+ * - spawns never materialize on top of the player, never outside the band;14+ * - behaviour constants stay inside their designed envelopes.15+ */16+import { assert, assertEquals, assertNotEquals } from "@std/assert";17+import {18+ AHEAD_LIMIT,19+ BEHIND_LIMIT,20+ OUT_TIME,21+ type Peer,22+ PeerRingCore,23+ SLOT_OFFSETS,24+} from "../src/client/peersCore.ts";25+26+const FLAT = (_s: number) => 0;27+const BAND: readonly [number, number] = [0, 400];28+const DT = 1 / 60;29+30+function run(31+ ring: PeerRingCore,32+ seconds: number,33+ playerS: () => number,34+ nowMs: () => number,35+ band: readonly [number, number] = BAND,36+): { spawned: Peer[]; removed: Peer[] } {37+ const all = { spawned: [] as Peer[], removed: [] as Peer[] };38+ for (let t = 0; t < seconds; t += DT) {39+ const ev = ring.update(DT, playerS(), band, nowMs(), FLAT);40+ all.spawned.push(...ev.spawned);41+ all.removed.push(...ev.removed);42+ }43+ return all;44+}45+46+Deno.test("peers: ring fills every slot after opening stagger", () => {47+ const ring = new PeerRingCore("det-seed");48+ run(ring, 3, () => 100, () => 0);49+ assertEquals(ring.liveCount, SLOT_OFFSETS.length);50+});51+52+Deno.test("peers: identity is deterministic for (seed, slot, n, stamp)", () => {53+ const a = new PeerRingCore("det-seed");54+ const b = new PeerRingCore("det-seed");55+ const evA = run(a, 3, () => 100, () => 12345);56+ const evB = run(b, 3, () => 100, () => 12345);57+ assertEquals(evA.spawned.length, evB.spawned.length);58+ for (let i = 0; i < evA.spawned.length; i++) {59+ assertEquals(evA.spawned[i].identity, evB.spawned[i].identity);60+ assertEquals(evA.spawned[i].behaviour, evB.spawned[i].behaviour);61+ }62+});63+64+Deno.test("peers: spawn time shapes identity", () => {65+ const a = new PeerRingCore("det-seed");66+ const b = new PeerRingCore("det-seed");67+ const evA = run(a, 3, () => 100, () => 0);68+ const evB = run(b, 3, () => 100, () => 60_000);69+ // Same slots and counts, different stamps: identities must diverge70+ // somewhere (all-equal would mean time is not in the seed material).71+ const same = evA.spawned.every((p, i) =>72+ JSON.stringify(p.identity) === JSON.stringify(evB.spawned[i]?.identity)73+ );74+ assert(!same, "identities identical across different spawn stamps");75+});76+77+Deno.test("peers: no spawn on top of the player, all inside the band", () => {78+ const ring = new PeerRingCore("abs-1");79+ // Snapshot s at the spawn frame — Peer objects are live and keep moving.80+ const spawnS: number[] = [];81+ for (let t = 0; t < 3; t += DT) {82+ const ev = ring.update(DT, 200, BAND, 777, FLAT);83+ spawnS.push(...ev.spawned.map((p) => p.s));84+ }85+ assertEquals(spawnS.length, SLOT_OFFSETS.length);86+ for (const s of spawnS) {87+ assert(Math.abs(s - 200) >= 3, `spawned ${s} on the player at 200`);88+ assert(s >= BAND[0] + 1 && s <= BAND[1] - 1, `outside band: ${s}`);89+ }90+});91+92+Deno.test("peers: drifting out of the live band despawns and reopens", () => {93+ const ring = new PeerRingCore("det-seed");94+ run(ring, 3, () => 100, () => 0);95+ assertEquals(ring.liveCount, SLOT_OFFSETS.length);96+ // Teleport the player far forward: every peer is now beyond BEHIND_LIMIT.97+ const ev = run(ring, OUT_TIME + 0.1, () => 100 + BEHIND_LIMIT + 200, () => 0);98+ assert(ev.removed.length >= SLOT_OFFSETS.length, "peers did not despawn");99+ // Slots must refill (cooldown ≤ 3.5s + scale-in).100+ run(ring, 5, () => 100 + BEHIND_LIMIT + 200, () => 0);101+ assertEquals(ring.liveCount, SLOT_OFFSETS.length, "slots did not refill");102+});103+104+Deno.test("peers: behaviour envelopes hold across many draws", () => {105+ const ring = new PeerRingCore("envelope-seed");106+ const seen: Peer[] = [];107+ let clock = 0;108+ // Cycle many generations by teleporting the player each round.109+ for (let round = 0; round < 12; round++) {110+ const at = 50 + round * 100;111+ const ev = run(ring, 6, () => at, () => (clock += 1000), [0, 5000]);112+ seen.push(...ev.spawned);113+ }114+ assert(seen.length >= 24, `too few spawns to judge: ${seen.length}`);115+ const labels = new Set(seen.map((p) => p.label));116+ assertEquals(labels.size, seen.length, "spawn labels must be unique");117+ for (const p of seen) {118+ const b = p.behaviour;119+ assert(b.base >= 2.2 * 0.82 - 1e-9 && b.base <= 2.2 * 1.28 + 1e-9);120+ assert(b.min < b.base && b.base < b.max, "min < base < max");121+ assert(b.gravity > 0 && b.drag > 0);122+ const id = p.identity;123+ assert(id.scale >= 0.8 && id.scale <= 1.3 + 1e-9);124+ assert(Math.abs(id.lateral) >= 0.06 - 1e-9 && Math.abs(id.lateral) <= 0.24 + 1e-9);125+ if (id.tint) {126+ for (const c of id.tint) assert(c >= 0 && c <= 255);127+ }128+ }129+ // Identity variety: the ring should not be a monoculture.130+ assert(new Set(seen.map((p) => p.identity.meshId)).size >= 2, "one mesh only");131+ assert(seen.some((p) => p.identity.tint !== null), "no tinted peers");132+});133+134+Deno.test("peers: relative drift revolves the ring (fast peers pull ahead)", () => {135+ const ring = new PeerRingCore("drift-seed");136+ run(ring, 3, () => 100, () => 5000, [0, 10_000]);137+ const speeds = ring.peers.flatMap((p) => (p ? [p.behaviour.base] : []));138+ assert(speeds.length === SLOT_OFFSETS.length);139+ // Character spread exists → drift is guaranteed over time.140+ assert(141+ Math.max(...speeds) - Math.min(...speeds) > 0.05,142+ "no speed character spread — the ring would never revolve",143+ );144+ // Drive 60 s with the player pinned: at least one peer must leave the145+ // AHEAD or BEHIND limit and hand its slot to a fresh spawn.146+ const ev = run(ring, 60, () => 100, () => 9000, [0, 10_000]);147+ assert(ev.removed.length >= 1, "no peer ever left the band");148+ assert(ev.spawned.length >= 1, "no replacement ever spawned");149+ for (const p of ring.peers) {150+ if (!p) continue;151+ const rel = p.s - 100;152+ assert(153+ rel >= -BEHIND_LIMIT - 1 && rel <= AHEAD_LIMIT + 1,154+ `live peer outside band: ${rel}`,155+ );156+ }157+});