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 */16import { assert, assertEquals } from "@std/assert";17import {18 AHEAD_LIMIT,19 BEHIND_LIMIT,20 DEFAULT_PEER_COUNT,21 OUT_TIME,22 type Peer,23 PeerRingCore,24 SLOT_OFFSETS,25 slotOffsets,26} from "../src/client/peersCore.ts";2728const FLAT = (_s: number) => 0;29const BAND: readonly [number, number] = [0, 400];30const DT = 1 / 60;3132function run(33 ring: PeerRingCore,34 seconds: number,35 playerS: (t: number) => number,36 nowMs: () => number,37 band: readonly [number, number] = BAND,38): { spawned: Peer[]; removed: Peer[] } {39 const all = { spawned: [] as Peer[], removed: [] as Peer[] };40 for (let t = 0; t < seconds; t += DT) {41 const ev = ring.update(DT, playerS(t), band, nowMs(), FLAT);42 all.spawned.push(...ev.spawned);43 all.removed.push(...ev.removed);44 }45 return all;46}4748/** A player travelling at the peers' mean pace — fill assertions must not49 * race the outermost slot against the despawn edge. */50const cruise = (from: number) => (t: number) => from + 2.2 * t;5152Deno.test("peers: slotOffsets sizes the ring from config (?peers=N)", () => {53 assertEquals(slotOffsets(0), []);54 assertEquals(slotOffsets(-3), []);55 assertEquals(SLOT_OFFSETS, slotOffsets(DEFAULT_PEER_COUNT));56 for (const n of [1, 2, 3, 6, 10, 16, 24, 99]) {57 const offs = slotOffsets(n);58 assertEquals(offs.length, Math.min(24, n), `count for n=${n}`);59 for (const o of offs) {60 assert(o !== 0, "no slot sits on the player");61 assert(62 o > -BEHIND_LIMIT + 2 && o < AHEAD_LIMIT - 2,63 `offset ${o} too close to the despawn edge (n=${n})`,64 );65 }66 assert(offs.some((o) => o > 0), `no leading slot for n=${n}`);67 if (n >= 3) assert(offs.some((o) => o < 0), `no trailing slot for n=${n}`);68 // A sized ring actually fills to its size.69 const ring = new PeerRingCore("size-seed", offs);70 let t2 = 0;71 for (let t = 0; t < 3; t += DT) {72 ring.update(DT, 100 + 2.2 * (t2 += DT), BAND, 0, FLAT);73 }74 assertEquals(ring.liveCount, offs.length, `fill for n=${n}`);75 }76});7778Deno.test("peers: ring fills every slot after opening stagger", () => {79 const ring = new PeerRingCore("det-seed");80 run(ring, 3, cruise(100), () => 0);81 assertEquals(ring.liveCount, SLOT_OFFSETS.length);82});8384Deno.test("peers: identity is deterministic for (seed, slot, n, stamp)", () => {85 const a = new PeerRingCore("det-seed");86 const b = new PeerRingCore("det-seed");87 const evA = run(a, 3, cruise(100), () => 12345);88 const evB = run(b, 3, cruise(100), () => 12345);89 assertEquals(evA.spawned.length, evB.spawned.length);90 for (let i = 0; i < evA.spawned.length; i++) {91 assertEquals(evA.spawned[i].identity, evB.spawned[i].identity);92 assertEquals(evA.spawned[i].behaviour, evB.spawned[i].behaviour);93 }94});9596Deno.test("peers: spawn time shapes identity", () => {97 const a = new PeerRingCore("det-seed");98 const b = new PeerRingCore("det-seed");99 const evA = run(a, 3, cruise(100), () => 0);100 const evB = run(b, 3, cruise(100), () => 60_000);101 // Same slots and counts, different stamps: identities must diverge102 // somewhere (all-equal would mean time is not in the seed material).103 const same = evA.spawned.every((p, i) =>104 JSON.stringify(p.identity) === JSON.stringify(evB.spawned[i]?.identity)105 );106 assert(!same, "identities identical across different spawn stamps");107});108109Deno.test("peers: no spawn on top of the player, all inside the band", () => {110 const ring = new PeerRingCore("abs-1");111 // Snapshot s at the spawn frame — Peer objects are live and keep moving.112 const spawnS: number[] = [];113 for (let t = 0; t < 3; t += DT) {114 const ev = ring.update(DT, 200, BAND, 777, FLAT);115 spawnS.push(...ev.spawned.map((p) => p.s));116 }117 assertEquals(spawnS.length, SLOT_OFFSETS.length);118 for (const s of spawnS) {119 assert(Math.abs(s - 200) >= 3, `spawned ${s} on the player at 200`);120 assert(s >= BAND[0] + 1 && s <= BAND[1] - 1, `outside band: ${s}`);121 }122});123124Deno.test("peers: drifting out of the live band despawns and reopens", () => {125 const ring = new PeerRingCore("det-seed");126 run(ring, 3, cruise(100), () => 0);127 assertEquals(ring.liveCount, SLOT_OFFSETS.length);128 // Teleport the player far forward: every peer is now beyond BEHIND_LIMIT.129 const jump = 100 + 2.2 * 3 + BEHIND_LIMIT + 200;130 const ev = run(ring, OUT_TIME + 0.1, cruise(jump), () => 0);131 assert(ev.removed.length >= SLOT_OFFSETS.length, "peers did not despawn");132 // Slots must refill (cooldown ≤ 3.5s + scale-in).133 run(ring, 5, cruise(jump), () => 0);134 assertEquals(ring.liveCount, SLOT_OFFSETS.length, "slots did not refill");135});136137Deno.test("peers: behaviour envelopes hold across many draws", () => {138 const ring = new PeerRingCore("envelope-seed");139 const seen: Peer[] = [];140 let clock = 0;141 // Cycle many generations by teleporting the player each round.142 for (let round = 0; round < 12; round++) {143 const at = 50 + round * 100;144 const ev = run(ring, 6, cruise(at), () => (clock += 1000), [0, 5000]);145 seen.push(...ev.spawned);146 }147 assert(seen.length >= 24, `too few spawns to judge: ${seen.length}`);148 const labels = new Set(seen.map((p) => p.label));149 assertEquals(labels.size, seen.length, "spawn labels must be unique");150 for (const p of seen) {151 const b = p.behaviour;152 assert(b.base >= 2.2 * 0.82 - 1e-9 && b.base <= 2.2 * 1.28 + 1e-9);153 assert(b.min < b.base && b.base < b.max, "min < base < max");154 assert(b.gravity > 0 && b.drag > 0);155 const id = p.identity;156 assert(id.scale >= 0.8 && id.scale <= 1.3 + 1e-9);157 assert(158 Math.abs(id.lateral) >= 0.06 - 1e-9 &&159 Math.abs(id.lateral) <= 0.24 + 1e-9,160 );161 if (id.tint) {162 for (const c of id.tint) assert(c >= 0 && c <= 255);163 }164 }165 // Identity variety: the ring should not be a monoculture.166 assert(167 new Set(seen.map((p) => p.identity.meshId)).size >= 2,168 "one mesh only",169 );170 assert(seen.some((p) => p.identity.tint !== null), "no tinted peers");171});172173Deno.test("peers: relative drift revolves the ring (fast peers pull ahead)", () => {174 const ring = new PeerRingCore("drift-seed");175 run(ring, 3, cruise(100), () => 5000, [0, 10_000]);176 const speeds = ring.peers.flatMap((p) => (p ? [p.behaviour.base] : []));177 assert(speeds.length === SLOT_OFFSETS.length);178 // Character spread exists → drift is guaranteed over time.179 assert(180 Math.max(...speeds) - Math.min(...speeds) > 0.05,181 "no speed character spread — the ring would never revolve",182 );183 // Drive 60 s with the player pinned: at least one peer must leave the184 // AHEAD or BEHIND limit and hand its slot to a fresh spawn.185 const ev = run(ring, 60, () => 100, () => 9000, [0, 10_000]);186 assert(ev.removed.length >= 1, "no peer ever left the band");187 assert(ev.spawned.length >= 1, "no replacement ever spawned");188 for (const p of ring.peers) {189 if (!p) continue;190 const rel = p.s - 100;191 assert(192 rel >= -BEHIND_LIMIT - 1 && rel <= AHEAD_LIMIT + 1,193 `live peer outside band: ${rel}`,194 );195 }196});