slotOffsets(n) spreads ~1/3 trailing / rest leading inside the live band; SLOT_OFFSETS stays as slotOffsets(6) default. Verified in browser at peers=12 and peers=0.
- commit
- e76a6b4af1f27ae5fd8ebecde0146f310d4d2301
- parent
- 34bc421ab35b4c4bfe784d673b8691af6adf4b42
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 8 | ++++- |
| M | src/ | 6 | +++- |
| M | src/ | 58 | ++++++++++++++++++++++++------ |
| M | tests/ | 57 | +++++++++++++++++++++++------ |
@@ -301,7 +301,13 @@ class Session {
301301 this.pivot.add(mesh);302302 }303303 ctx.scene.add(this.marbleObj);304- this.peerRing = new PeerRing(seed, assets, ctx.scene);304+ // Ring size is session config, not seed material: ?peers=N (0 turns305+ // the ring off, capped at 24; default DEFAULT_PEER_COUNT).306+ const peersParam = new URLSearchParams(location.search).get("peers");307+ const peerCount = peersParam === null308+ ? undefined309+ : Math.max(0, Math.min(24, Number.parseInt(peersParam, 10) || 0));310+ this.peerRing = new PeerRing(seed, assets, ctx.scene, peerCount);305311 306312 this.ensure();307313 this.initialBuild = false;@@ -13,6 +13,7 @@ import {
1313 OUT_TIME,1414 type Peer,1515 PeerRingCore,16+ slotOffsets,1617 } from "./peersCore.ts";1718 1819 /** Samples the session's global centerline at arc length s. */@@ -47,8 +48,11 @@ export class PeerRing {
4748 seed: string,4849 private readonly assets: Assets,4950 private readonly parent: THREE.Object3D,51+ count?: number,5052 ) {51- this.core = new PeerRingCore(seed);53+ this.core = count === undefined54+ ? new PeerRingCore(seed)55+ : new PeerRingCore(seed, slotOffsets(count));5256 }5357 5458 get liveCount(): number {@@ -18,13 +18,44 @@
1818 */1919 import { rngFamily } from "../gen/rng.ts";2020 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-2421 /** Live band relative to the player: outside this, a peer despawns. */2522 export const BEHIND_LIMIT = 18;2623 export const AHEAD_LIMIT = 38;2724 25+/** Default ring size (overridable per session via ?peers=N). */26+export const DEFAULT_PEER_COUNT = 6;27+28+/**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+ */33+export 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+}55+56+/** The default ring's offsets (kept as a const for tests/back-compat). */57+export const SLOT_OFFSETS: readonly number[] = slotOffsets(DEFAULT_PEER_COUNT);58+2859 /** Seconds of spawn scale-in / despawn scale-out. */2960 export const IN_TIME = 0.45;3061 export const OUT_TIME = 0.35;@@ -88,15 +119,20 @@ export interface RingEvents {
88119 export class PeerRingCore {89120 /** One entry per slot; null = open. */90121 readonly peers: (Peer | null)[];122+ readonly offsets: readonly number[];91123 private readonly cooldowns: number[];92124 private readonly counters: number[];93125 94- constructor(private readonly seed: string) {95- this.peers = SLOT_OFFSETS.map(() => null);96- this.counters = SLOT_OFFSETS.map(() => 0);126+ 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);97133 // 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) =>134+ // of the whole ring popping in on one frame.135+ this.cooldowns = offsets.map((_, i) =>100136 rngFamily(seed, `peer-open/${i}`).float() * 2101137 );102138 }@@ -121,7 +157,7 @@ export class PeerRingCore {
121157 ): RingEvents {122158 const events: RingEvents = { spawned: [], removed: [] };123159 124- for (let slot = 0; slot < SLOT_OFFSETS.length; slot++) {160+ for (let slot = 0; slot < this.offsets.length; slot++) {125161 const peer = this.peers[slot];126162 if (!peer) {127163 this.cooldowns[slot] -= dt;@@ -193,11 +229,11 @@ export class PeerRingCore {
193229 const lo = band[0] + 1;194230 const hi = band[1] - 1;195231 if (hi - lo < 4) return null;196- let s = playerS + SLOT_OFFSETS[slot];232+ let s = playerS + this.offsets[slot];197233 s = Math.min(hi, Math.max(lo, s));198234 // Never materialize on top of the player.199235 if (Math.abs(s - playerS) < 3) {200- s = playerS + (SLOT_OFFSETS[slot] >= 0 ? 3 : -3);236+ s = playerS + (this.offsets[slot] >= 0 ? 3 : -3);201237 if (s < lo || s > hi) return null;202238 }203239 @@ -17,10 +17,12 @@ import { assert, assertEquals, assertNotEquals } from "@std/assert";
1717 import {1818 AHEAD_LIMIT,1919 BEHIND_LIMIT,20+ DEFAULT_PEER_COUNT,2021 OUT_TIME,2122 type Peer,2223 PeerRingCore,2324 SLOT_OFFSETS,25+ slotOffsets,2426 } from "../src/client/peersCore.ts";2527 2628 const FLAT = (_s: number) => 0;@@ -30,30 +32,60 @@ const DT = 1 / 60;
3032 function run(3133 ring: PeerRingCore,3234 seconds: number,33- playerS: () => number,35+ playerS: (t: number) => number,3436 nowMs: () => number,3537 band: readonly [number, number] = BAND,3638 ): { spawned: Peer[]; removed: Peer[] } {3739 const all = { spawned: [] as Peer[], removed: [] as Peer[] };3840 for (let t = 0; t < seconds; t += DT) {39- const ev = ring.update(DT, playerS(), band, nowMs(), FLAT);41+ const ev = ring.update(DT, playerS(t), band, nowMs(), FLAT);4042 all.spawned.push(...ev.spawned);4143 all.removed.push(...ev.removed);4244 }4345 return all;4446 }4547 48+/** A player travelling at the peers' mean pace — fill assertions must not49+ * race the outermost slot against the despawn edge. */50+const cruise = (from: number) => (t: number) => from + 2.2 * t;51+52+Deno.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+});77+4678 Deno.test("peers: ring fills every slot after opening stagger", () => {4779 const ring = new PeerRingCore("det-seed");48- run(ring, 3, () => 100, () => 0);80+ run(ring, 3, cruise(100), () => 0);4981 assertEquals(ring.liveCount, SLOT_OFFSETS.length);5082 });5183 5284 Deno.test("peers: identity is deterministic for (seed, slot, n, stamp)", () => {5385 const a = new PeerRingCore("det-seed");5486 const b = new PeerRingCore("det-seed");55- const evA = run(a, 3, () => 100, () => 12345);56- const evB = run(b, 3, () => 100, () => 12345);87+ const evA = run(a, 3, cruise(100), () => 12345);88+ const evB = run(b, 3, cruise(100), () => 12345);5789 assertEquals(evA.spawned.length, evB.spawned.length);5890 for (let i = 0; i < evA.spawned.length; i++) {5991 assertEquals(evA.spawned[i].identity, evB.spawned[i].identity);@@ -64,8 +96,8 @@ Deno.test("peers: identity is deterministic for (seed, slot, n, stamp)", () => {
6496 Deno.test("peers: spawn time shapes identity", () => {6597 const a = new PeerRingCore("det-seed");6698 const b = new PeerRingCore("det-seed");67- const evA = run(a, 3, () => 100, () => 0);68- const evB = run(b, 3, () => 100, () => 60_000);99+ const evA = run(a, 3, cruise(100), () => 0);100+ const evB = run(b, 3, cruise(100), () => 60_000);69101 // Same slots and counts, different stamps: identities must diverge70102 // somewhere (all-equal would mean time is not in the seed material).71103 const same = evA.spawned.every((p, i) =>@@ -91,13 +123,14 @@ Deno.test("peers: no spawn on top of the player, all inside the band", () => {
91123 92124 Deno.test("peers: drifting out of the live band despawns and reopens", () => {93125 const ring = new PeerRingCore("det-seed");94- run(ring, 3, () => 100, () => 0);126+ run(ring, 3, cruise(100), () => 0);95127 assertEquals(ring.liveCount, SLOT_OFFSETS.length);96128 // 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);129+ const jump = 100 + 2.2 * 3 + BEHIND_LIMIT + 200;130+ const ev = run(ring, OUT_TIME + 0.1, cruise(jump), () => 0);98131 assert(ev.removed.length >= SLOT_OFFSETS.length, "peers did not despawn");99132 // Slots must refill (cooldown ≤ 3.5s + scale-in).100- run(ring, 5, () => 100 + BEHIND_LIMIT + 200, () => 0);133+ run(ring, 5, cruise(jump), () => 0);101134 assertEquals(ring.liveCount, SLOT_OFFSETS.length, "slots did not refill");102135 });103136 @@ -108,7 +141,7 @@ Deno.test("peers: behaviour envelopes hold across many draws", () => {
108141 // Cycle many generations by teleporting the player each round.109142 for (let round = 0; round < 12; round++) {110143 const at = 50 + round * 100;111- const ev = run(ring, 6, () => at, () => (clock += 1000), [0, 5000]);144+ const ev = run(ring, 6, cruise(at), () => (clock += 1000), [0, 5000]);112145 seen.push(...ev.spawned);113146 }114147 assert(seen.length >= 24, `too few spawns to judge: ${seen.length}`);@@ -133,7 +166,7 @@ Deno.test("peers: behaviour envelopes hold across many draws", () => {
133166 134167 Deno.test("peers: relative drift revolves the ring (fast peers pull ahead)", () => {135168 const ring = new PeerRingCore("drift-seed");136- run(ring, 3, () => 100, () => 5000, [0, 10_000]);169+ run(ring, 3, cruise(100), () => 5000, [0, 10_000]);137170 const speeds = ring.peers.flatMap((p) => (p ? [p.behaviour.base] : []));138171 assert(speeds.length === SLOT_OFFSETS.length);139172 // Character spread exists → drift is guaranteed over time.