clone

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

PACK DYNAMICS (M20a): drafting, lane personality, overtake commitment

Dami committed 5c4fd0d
The pack interacts through the air and the line, not just contact/fuel —
all inside the existing envelope law, zero gen-side movement (both golden
files byte-identical; scenario pins untouched).

- A0 FIRST: tests/personality_snapshot.json + the appended-draw lock —
  pilotFor/racerIdentity/braidLaneAlt fixture captured PRE-change; the
  test walks only fixture keys, so appends stay green and any in-stream
  insert goes red. The fixture regenerates only from a known-good commit.
- Traits appended after efficiency: wake 0.6-1.4 (draft appetite),
  lanePref 0.35-0.65 (braid threshold), chir ±1 (passing side).
- draftFactor(gap, wake): wake window 1.2-5.0u behind the same-lane
  leader, ≤ +10%, threaded into autopilotForce as targetMult exactly like
  boostMult — wishspeed stays min(target, vSafe): drafting into a corner
  brakes identically (envelope test proves the cap).
- decide(): captures the gap-ahead racer; same-lane gate on braids;
  r.draft at decision cadence (1-step-lag contract); overtake commitment
  holds pilotEff.offset = chir·0.27 (< CORRIDOR_DEAD 0.3 — the corridor
  spring never fights it) while a burn is an attack, engage <3.5u /
  release >5u hysteresis + 0.4s slew on the single offset actuator.
  State resets on demote/promote/respawn.
- Riders: rank stamped in the sort pass; riderUpdate applies the same
  draft law CAPPED at +8% (chained riders must never outrun the
  hero-anchored stream) and draws the same per-racer braid lane at
  segment entry, so the fork split ratio holds at LOD distance.
- braidLaneAlt(seed,idx,seg,pref): SAME label, SAME single draw, moving
  threshold only — historical coin == pref 0.5 (test-pinned).
- Telemetry: race JSON gains respawnBurst (2s window), spreadP10P90 (the
  liveliness tripwire), per-racer draft.
- Tests: draft window/falloff/cap tables, envelope safety, lane
  monotonicity + per-racer independence, trait bands, fuzz harness runs
  the draft law with client parity + asserts a wake window opened;
  determinism run-twice still byte-equal. 193 green, check clean.
commit
5c4fd0d74aee4e3302f11d16fd601f1e4cfda09d
parent
74731b242648c8051e0cd701703bffcc99804114
author
Dami <dami.etoile@gmail.com>
5 files changed, +505 -7
Msrc/client/endless.ts110+++++++++++++++++++-
Msrc/client/physics/autopilot.ts54+++++++++-
Mtests/autopilot_test.ts164+++++++++++++++++++++++++++++
Mtests/multimarble_test.ts15++-
Atests/personality_snapshot.json169++++++++++++++++++++++++++++++
M src/client/endless.ts +106 -4
@@ -48,6 +48,7 @@ import {
4848 type BoostState,4949 boostStep,5050 braidLaneAlt,51+ draftFactor,5152 FULL_TANK,5253 type Neighbor,5354 type Pilot,
@@ -328,6 +329,17 @@ const PLACE_REFRESH = 0.25;
328329 * tight pack; skill modulation follows the smoothed position. */329330 const RUBBER_TAU = 2;330331 332+// Overtake commitment (M20a). OVERTAKE_OFF stays strictly below the333+// autopilot's CORRIDOR_DEAD (0.3) so the corridor spring never fights334+// the held line and the channel wall never enters play.335+const OVERTAKE_OFF = 0.27;336+const OTK_ENGAGE = 3.5;337+const OTK_DISENGAGE = 5;338+const OTK_SLEW_TAU = 0.4;339+/** Rider draft cap (M20a): riders chained nose-to-tail must never340+ * compound into a glide that outruns the hero-anchored stream. */341+const RIDER_DRAFT_CAP = 1.08;342+331343 // --- physics LOD (pack-scale realtime) -------------------------------------332344 //333345 // N=9999 ran at ~0.27x realtime: the 10k-body Box3D step was the wall.
@@ -418,6 +430,16 @@ interface Racer {
418430 cachedF: [number, number, number] | null;419431 /** Ember phenotype baseline — boost-glow flameout restores it. */420432 baseEmissive: THREE.Color | null;433+ /** Draft multiplier this decision (M20a) — 1 outside a wake. Reset on434+ * LOD transitions and respawns (state-clean replays). */435+ draft: number;436+ /** Overtake-commitment blend 0..1 (M20a): eases the offset between the437+ * wander line and the chirality passing line (slew kills offset438+ * flapping against separation steering). */439+ otkBlend: number;440+ /** Progress rank this step (index into the s-sorted pack) — written in441+ * the sort pass; riders read their leader through it. */442+ rank: number;421443 }422444 423445 /** Reusable neighbor slots (separation steering) — mutated in place. */
@@ -513,6 +535,8 @@ class Session {
513535 private readonly fanField = new FanField();514536 /** Accumulated sim time for pulsed wind (M19a gust walls). */515537 private windT = 0;538+ /** Respawn timestamps inside the trailing 2s (M20a pile-up telemetry). */539+ private readonly respawnTimes: number[] = [];516540 /** Scratch for the per-decision wind force (allocation-free). */517541 private readonly windScratch: [number, number, number] = [0, 0, 0];518542 /** M16 conjugation scratch (reoriented segments): marble state maps
@@ -737,6 +761,9 @@ class Session {
737761 quat: [0, 0, 0, 1],738762 cachedF: null,739763 baseEmissive: null,764+ draft: 1,765+ otkBlend: 0,766+ rank: 0,740767 });741768 // Racer peers (M15): real bodies on a wrapped grid start —742769 // stations × 3 lanes, then stacked tiers above (a huge pack rains
@@ -895,6 +922,9 @@ class Session {
895922 quat: [0, 0, 0, 1],896923 cachedF: null,897924 baseEmissive,925+ draft: 1,926+ otkBlend: 0,927+ rank: 0,898928 });899929 }900930 if (this.peerMesh?.instanceColor) {
@@ -925,28 +955,39 @@ class Session {
925955 n: number;926956 leader: number;927957 leaderCam: boolean;958+ respawnBurst: number;959+ spreadP10P90: number;928960 racers: {929961 idx: number;930962 s: number;931963 speed: number;932964 fuel: number;933965 boosting: boolean;966+ draft: number;934967 }[];935968 } | null {936969 if (this.racers.length === 0) return null;937970 let leader = this.racers[0];938971 for (const r of this.racers) if (r.s > leader.s) leader = r;939972 const top = [...this.racers].sort((a, b) => b.s - a.s).slice(0, 12);973+ // Pack-spread percentile gap (M20a telemetry): the liveliness-law974+ // tripwire — a draft compression wave would collapse it.975+ const byS = [...this.racers].sort((a, b) => a.s - b.s);976+ const p10 = byS[Math.floor((byS.length - 1) * 0.1)].s;977+ const p90 = byS[Math.floor((byS.length - 1) * 0.9)].s;940978 return {941979 n: this.racers.length,942980 leader: leader.idx,943981 leaderCam: this.leaderCam,982+ respawnBurst: this.respawnTimes.filter((t) => t > this.windT - 2).length,983+ spreadP10P90: Number((p90 - p10).toFixed(1)),944984 racers: top.map((r) => ({945985 idx: r.idx,946986 s: Number(r.s.toFixed(1)),947987 speed: Number(r.speed.toFixed(2)),948988 fuel: Number(r.boost.fuel.toFixed(2)),949989 boosting: r.boost.boosting,990+ draft: Number(r.draft.toFixed(3)),950991 })),951992 };952993 }
@@ -1603,6 +1644,8 @@ class Session {
16031644 r.cachedF = null;16041645 r.ghostT = 0;16051646 r.stallClock = 0;1647+ r.draft = 1;1648+ r.otkBlend = 0;16061649 if (r.boost.boosting) r.boost = { fuel: r.boost.fuel, boosting: false };16071650 if (r.wasBoosting) {16081651 r.wasBoosting = false;
@@ -1652,6 +1695,8 @@ class Session {
16521695 r.lastProgressS = r.s;16531696 r.stallClock = 0;16541697 r.stallHopped = false;1698+ r.draft = 1;1699+ r.otkBlend = 0;16551700 }16561701 16571702 /** Kinematic rider glide: advance progress at a personality cruise and
@@ -1659,11 +1704,28 @@ class Session {
16591704 * this is what lets N=9999 run realtime. */16601705 private riderUpdate(r: Racer, dt: number): void {16611706 const h = (((r.idx * 2654435761) >>> 0) % 1024) / 1024;1662- const target = 3.0 + h * 1.2;1707+ let target = 3.0 + h * 1.2;1708+ // Rider draft fake (M20a): the far pack expresses the same wake law1709+ // kinematically, CAPPED — chained riders must never compound into a1710+ // runaway glide (the streaming anchor is the hero alone).1711+ const ahead = this.sorted[r.rank + 1];1712+ if (ahead && ahead !== r) {1713+ target *= Math.min(1714+ draftFactor(ahead.s - r.s, r.pilot.wake),1715+ RIDER_DRAFT_CAP,1716+ );1717+ }16631718 r.speed += (target - r.speed) * Math.min(1, dt * 0.5);16641719 r.s += r.speed * dt;16651720 const seg = this.segmentAt(r.s);16661721 if (!seg) return;1722+ // Braid entry (M20a): riders draw the SAME per-racer lane law as1723+ // live racers, so the fork split ratio holds at LOD distance.1724+ if (this.braidSegs.has(seg.index) && r.laneBraid !== seg.index) {1725+ r.laneBraid = seg.index;1726+ r.laneAlt = braidLaneAlt(this.seed, r.idx, seg.index, r.pilot.lanePref);1727+ r.nearest = { seg: seg.index, idx: 0 };1728+ }16671729 const rail = this.railFor(r, seg.index);16681730 if (!rail) return;16691731 const local = r.s - seg.startS;
@@ -1727,9 +1789,9 @@ class Session {
17271789 r.rubber += (behind - r.rubber) *17281790 Math.min(1, (FIXED_DT * K) / RUBBER_TAU);17291791 rubberMod(r.pilot, r.rubber, r.pilotEff);1730- r.pilotEff.offset = pilotOffset(r.pilot, r.simT);17311792 let gapAhead: number | null = null;17321793 let gapBehind: number | null = null;1794+ let aheadRacer: Racer | null = null;17331795 this.othersScratch.length = 0;17341796 const addNeighbor = (o: Racer) => {17351797 const slot = this.neighborPool[this.othersScratch.length];
@@ -1746,7 +1808,10 @@ class Session {
17461808 for (let k = k0 + 1; k < this.sorted.length && k <= k0 + 3; k++) {17471809 const o = this.sorted[k];17481810 const d = o.s - r.s;1749- if (gapAhead === null && d > 0.05) gapAhead = d;1811+ if (gapAhead === null && d > 0.05) {1812+ gapAhead = d;1813+ aheadRacer = o;1814+ }17501815 if (d < 2 && this.othersScratch.length < 4) addNeighbor(o);17511816 }17521817 for (let k = k0 - 1; k >= 0 && k >= k0 - 3; k--) {
@@ -1767,6 +1832,28 @@ class Session {
17671832 gapAhead,17681833 gapBehind,17691834 );1835+ // Drafting (M20a): wake bonus behind the SAME-LANE leader — on a1836+ // braid, a wake across lanes is fiction; elsewhere lanes coincide.1837+ // Held at decision cadence (the boost-state 1-step-lag contract).1838+ const sameLane = aheadRacer === null ||1839+ !this.braidSegs.has(r.nearest.seg) ||1840+ (aheadRacer.laneAlt === r.laneAlt &&1841+ aheadRacer.laneBraid === r.laneBraid);1842+ r.draft = sameLane ? draftFactor(gapAhead, r.pilot.wake) : 1;1843+ // Overtake commitment (M20a): while the burn is an ATTACK (rival1844+ // ahead in range), hold an off-center passing line on the pilot's1845+ // chirality side. Hysteresis (engage < 3.5u, release > 5u) plus a1846+ // ~0.4s slew keep the single offset actuator from flapping against1847+ // separation steering; the offset stays inside CORRIDOR_DEAD so1848+ // the corridor spring never fights it. Scratch pilot only.1849+ const otkEngaged = r.boost.boosting && gapAhead !== null &&1850+ (r.otkBlend > 0 ? gapAhead < OTK_DISENGAGE : gapAhead < OTK_ENGAGE);1851+ r.otkBlend += ((otkEngaged ? 1 : 0) - r.otkBlend) *1852+ Math.min(1, (FIXED_DT * K) / OTK_SLEW_TAU);1853+ if (r.otkBlend < 0.02 && !otkEngaged) r.otkBlend = 0;1854+ const wander = pilotOffset(r.pilot, r.simT);1855+ r.pilotEff.offset = wander * (1 - r.otkBlend) +1856+ r.pilot.chir * OVERTAKE_OFF * r.otkBlend;17701857 f = autopilotForce(17711858 rail,17721859 r.nearest.idx,
@@ -1778,6 +1865,7 @@ class Session {
17781865 r.dbg,17791866 this.othersScratch,17801867 r.boost.boosting,1868+ r.draft,17811869 );17821870 const j = wantJump(17831871 rail,
@@ -1860,6 +1948,11 @@ class Session {
18601948 this.sorted.length = 0;18611949 for (const r of this.racers) this.sorted.push(r);18621950 this.sorted.sort((a, b2) => a.s - b2.s);1951+ // Rank stamp (M20a): riders read their leader through it — one int1952+ // store per racer, no extra alloc.1953+ for (let ri = 0; ri < this.sorted.length; ri++) {1954+ this.sorted[ri].rank = ri;1955+ }18631956 this.stepCounter++;18641957 // Physics LOD: re-elect the real-body band around the subject.18651958 if (++this.lodClock >= LOD_PERIOD) {
@@ -2223,6 +2316,13 @@ class Session {
22232316 // inside a braid, the next decide re-draws the (deterministic) lane.22242317 r.laneAlt = false;22252318 r.laneBraid = -1;2319+ r.draft = 1;2320+ r.otkBlend = 0;2321+ // Pile-up telemetry (M20a): trailing-window respawn counter.2322+ this.respawnTimes.push(this.windT);2323+ while (this.respawnTimes.length > 0 && this.respawnTimes[0] < this.windT - 4) {2324+ this.respawnTimes.shift();2325+ }22262326 const seg = this.segmentAt(at);22272327 const rail = seg ? this.rails.get(seg.index) : undefined;22282328 if (!seg || !rail) return;
@@ -2381,7 +2481,9 @@ class Session {
23812481 if (!this.braidSegs.has(segIdx)) return;23822482 if (r.laneBraid !== segIdx) {23832483 r.laneBraid = segIdx;2384- const want = braidLaneAlt(this.seed, r.idx, segIdx);2484+ // Lane identity is CHARACTER (M20a): base pilot's threshold, never2485+ // the rubber-banded scratch. Same label, same single draw.2486+ const want = braidLaneAlt(this.seed, r.idx, segIdx, r.pilot.lanePref);23852487 if (want !== r.laneAlt) {23862488 r.laneAlt = want;23872489 // Re-anchor on the lane rail (pre-fork stations coincide, so the
M src/client/physics/autopilot.ts +52 -2
@@ -64,6 +64,14 @@ export interface Pilot {
6464 wanderAmp: number;6565 phase1: number;6666 phase2: number;67+ /** Wake-rider appetite (M20a): scales the draft bonus. */68+ wake: number;69+ /** Braid alt-lane threshold (M20a): braidLaneAlt compares its single70+ * draw against this — lane identity is CHARACTER, so it reads the71+ * base pilot, never the rubber-banded scratch. */72+ lanePref: number;73+ /** Overtake chirality (M20a): pass-left (−1) or pass-right (+1). */74+ chir: number;6775 }6876 6977 export const DEFAULT_PILOT: Pilot = {
@@ -78,6 +86,9 @@ export const DEFAULT_PILOT: Pilot = {
7886 wanderAmp: 0,7987 phase1: 0,8088 phase2: 0,89+ wake: 1,90+ lanePref: 0.5,91+ chir: 1,8192 };8293 8394 /** Seed-stable personality for marble `index` (label-derived stream —
@@ -95,6 +106,9 @@ export function pilotFor(seed: string, index = 0): Pilot {
95106 const regen = 0.75 + rng.float() * 0.5; // appended draw (boost round)96107 const boostMult = 1.35 + rng.float() * 0.4; // appended draw97108 const efficiency = 0.8 + rng.float() * 0.45; // appended draw109+ const wake = 0.6 + rng.float() * 0.8; // appended draw (M20a)110+ const lanePref = 0.35 + rng.float() * 0.3; // appended draw (M20a)111+ const chir = rng.float() < 0.5 ? -1 : 1; // appended draw (M20a)98112 return {99113 bravery,100114 lookGain,
@@ -107,6 +121,9 @@ export function pilotFor(seed: string, index = 0): Pilot {
107121 wanderAmp,108122 phase1,109123 phase2,124+ wake,125+ lanePref,126+ chir,110127 };111128 }112129
@@ -121,8 +138,37 @@ export function braidLaneAlt(
121138 seed: string,122139 idx: number,123140 segIndex: number,141+ /** Personality threshold (M20a): SAME label, SAME single draw — only142+ * the comparison moves, so racer streams stay untouched and lane143+ * splits lean with character instead of a fixed coin. */144+ pref = 0.5,124145 ): boolean {125- return rngFamily(seed, `lane/${idx}/${segIndex}`).float() < 0.5;146+ return rngFamily(seed, `lane/${idx}/${segIndex}`).float() < pref;147+}148+149+// --- drafting (M20a) --------------------------------------------------------150+//151+// A trailing marble rides the leader's wake: a wishspeed multiplier in a152+// window behind the same-lane racer ahead, applied through the SAME153+// envelope law as boost (wishspeed = min(target·mult, vSafe)) — drafting154+// into a corner brakes identically, so the slingshot can only happen155+// where the track permits it. Riders fake the same law kinematically.156+157+/** Wake window: closer than this, contact/separation owns the pair. */158+export const DRAFT_MIN = 1.2;159+/** Wake dissipates by this progress gap (world units). */160+export const DRAFT_MAX = 5.0;161+/** Peak bonus at DRAFT_MIN for wake = 1 (a ~10% slingshot). */162+export const DRAFT_GAIN = 0.1;163+164+/** Pure draft law: target-speed multiplier for a racer `gapAhead` behind165+ * its same-lane leader. 1 outside the window; linear falloff inside. */166+export function draftFactor(gapAhead: number | null, wake: number): number {167+ if (gapAhead === null || gapAhead < DRAFT_MIN || gapAhead >= DRAFT_MAX) {168+ return 1;169+ }170+ const falloff = (DRAFT_MAX - gapAhead) / (DRAFT_MAX - DRAFT_MIN);171+ return 1 + DRAFT_GAIN * wake * falloff;126172 }127173 128174 // --- the boost economy (competition round) ---------------------------------
@@ -379,6 +425,10 @@ export function autopilotForce(
379425 others?: readonly Neighbor[],380426 /** Fuel burn (boostStep): raises the target inside the same envelope. */381427 boosting = false,428+ /** Draft multiplier (M20a, draftFactor): raises the target inside the429+ * same envelope — stacks multiplicatively with boost; vSafe still430+ * caps, so a drafting marble brakes for every corner unchanged. */431+ targetMult = 1,382432 ): [number, number, number] | null {383433 const t = rail.tans[idx];384434 if (!t) return null;
@@ -412,7 +462,7 @@ export function autopilotForce(
412462 // --- speed planning: online backward brake sweep over the lookahead ---413463 const sHere = rail.s[idx];414464 const targetHere = CRUISE * (mod?.speed ?? 1) *415- (boosting ? pilot.boostMult : 1);465+ (boosting ? pilot.boostMult : 1) * targetMult;416466 let vSafe = VMAX;417467 let climbTy = t[1];418468 let prev: readonly [number, number, number] = t;
M tests/autopilot_test.ts +164 -0
@@ -5,11 +5,17 @@
55 */66 import { assert, assertEquals } from "@std/assert";77 import type { Rail } from "../src/client/physics/rail.ts";8+import { racerIdentity } from "../src/client/peersCore.ts";89 import {910 autopilotForce,1011 BOOST_MULT,1112 boostStep,13+ braidLaneAlt,1214 DEFAULT_PILOT,15+ DRAFT_GAIN,16+ DRAFT_MAX,17+ DRAFT_MIN,18+ draftFactor,1319 FULL_TANK,1420 type Pilot,1521 type PilotDebug,
@@ -327,3 +333,161 @@ Deno.test("wantJump: fires only at the lip, only on a rescuable undershoot", ()
327333 // the catch pan or the respawn net owns it.328334 assertEquals(wantJump(rail, lip, lipPos, [1.2, 0, 0], null), null);329335 });336+337+// ---------------------------------------------------------------------------338+// M20a A0 — the appended-draw regression lock.339+//340+// tests/personality_snapshot.json was captured BEFORE the M20 trait341+// appends. The comparison walks ONLY the fixture's keys: appending new342+// draws after the old ones keeps every fixture field byte-identical343+// (test green); any in-stream insert or reorder moves them (test red).344+// Update the fixture ONLY by regenerating it from a commit where the345+// old fields are known-good — never by hand.346+// ---------------------------------------------------------------------------347+348+Deno.test("appended-draw lock: pilots, identities and lane draws are frozen", async () => {349+ const snapshot = JSON.parse(350+ await Deno.readTextFile(351+ new URL("./personality_snapshot.json", import.meta.url),352+ ),353+ ) as Record<string, unknown>;354+ const cases: [string, number][] = [355+ ["snap-a", 0],356+ ["snap-a", 7],357+ ["marbles", 3],358+ ["wide-scan-13", 41],359+ ["det-seed", 128],360+ ];361+ for (const [seed, i] of cases) {362+ const pilot = pilotFor(seed, i) as unknown as Record<string, unknown>;363+ const want = snapshot[`pilot/${seed}/${i}`] as Record<string, unknown>;364+ for (const key of Object.keys(want)) {365+ assertEquals(366+ pilot[key],367+ want[key],368+ `pilotFor(${seed}, ${i}).${key} moved — in-stream draw change`,369+ );370+ }371+ assertEquals(372+ [0, 5, 12].map((s) => braidLaneAlt(seed, i, s)),373+ snapshot[`lane/${seed}/${i}`],374+ `braidLaneAlt(${seed}, ${i}) raw draws moved`,375+ );376+ const identity = racerIdentity(seed, i) as unknown as Record<377+ string,378+ unknown379+ >;380+ const wantId = snapshot[`identity/${seed}/${i}`] as Record<381+ string,382+ unknown383+ >;384+ for (const key of Object.keys(wantId)) {385+ assertEquals(386+ identity[key],387+ wantId[key],388+ `racerIdentity(${seed}, ${i}).${key} moved`,389+ );390+ }391+ }392+});393+394+// ---------------------------------------------------------------------------395+// M20a — drafting, lane personality, trait bands.396+// ---------------------------------------------------------------------------397+398+Deno.test("draftFactor: window, falloff, wake scaling", () => {399+ // Outside the window: no bonus.400+ assertEquals(draftFactor(null, 1), 1);401+ assertEquals(draftFactor(0.8, 1), 1); // contact/separation owns < DRAFT_MIN402+ assertEquals(draftFactor(5.0, 1), 1);403+ assertEquals(draftFactor(9, 1.4), 1);404+ // At the window's near edge: peak bonus, scaled by wake.405+ const peak = draftFactor(DRAFT_MIN, 1);406+ assert(Math.abs(peak - (1 + DRAFT_GAIN)) < 1e-9, `peak ${peak}`);407+ assert(408+ Math.abs(draftFactor(DRAFT_MIN, 0.6) - (1 + DRAFT_GAIN * 0.6)) < 1e-9,409+ );410+ // Monotone falloff across the window.411+ let prev = peak;412+ for (let g = DRAFT_MIN + 0.2; g < DRAFT_MAX; g += 0.2) {413+ const f = draftFactor(g, 1);414+ assert(f <= prev + 1e-12 && f >= 1, `non-monotone at ${g}`);415+ prev = f;416+ }417+});418+419+Deno.test("draft raises the target inside the same envelope (never past vSafe)", () => {420+ const rail = railOf(flat(40));421+ const dbgOff: PilotDebug = {};422+ const dbgOn: PilotDebug = {};423+ autopilotForce(rail, 10, [5, 0.1, 0], [1, 0, 0], 1, null, undefined, dbgOff);424+ autopilotForce(425+ rail,426+ 10,427+ [5, 0.1, 0],428+ [1, 0, 0],429+ 1,430+ null,431+ undefined,432+ dbgOn,433+ undefined,434+ false,435+ 1.1,436+ );437+ assert(438+ Math.abs(dbgOn.wishspeed! - dbgOff.wishspeed! * 1.1) < 1e-9,439+ `drafted wishspeed ${dbgOn.wishspeed} vs base ${dbgOff.wishspeed}`,440+ );441+ assertEquals(dbgOn.vSafe, dbgOff.vSafe); // the envelope is untouched442+ // An absurd multiplier is still capped by the envelope.443+ const dbgCap: PilotDebug = {};444+ autopilotForce(445+ rail,446+ 10,447+ [5, 0.1, 0],448+ [1, 0, 0],449+ 1,450+ null,451+ undefined,452+ dbgCap,453+ undefined,454+ false,455+ 10,456+ );457+ assert(458+ dbgCap.wishspeed! <= dbgCap.vSafe! + 1e-9,459+ `wishspeed ${dbgCap.wishspeed} exceeded vSafe ${dbgCap.vSafe}`,460+ );461+});462+463+Deno.test("braid lane threshold: same draw, monotone in pref, per-racer independent", () => {464+ for (let i = 0; i < 30; i++) {465+ // pref 0 never takes the alt lane; pref 1 always does; monotone: a466+ // racer alt at some pref stays alt at any higher pref (single draw467+ // compared against a moving threshold).468+ assert(!braidLaneAlt("lane-seed", i, 4, 0));469+ assert(braidLaneAlt("lane-seed", i, 4, 1));470+ let was = false;471+ for (const pref of [0.2, 0.35, 0.5, 0.65, 0.8]) {472+ const now = braidLaneAlt("lane-seed", i, 4, pref);473+ assert(!was || now, `racer ${i}: lane flipped back as pref rose`);474+ was = now;475+ }476+ }477+ // Default threshold matches the historical coin exactly.478+ for (let i = 0; i < 30; i++) {479+ assertEquals(480+ braidLaneAlt("lane-seed", i, 7),481+ braidLaneAlt("lane-seed", i, 7, 0.5),482+ );483+ }484+});485+486+Deno.test("M20a traits: appended draws land in their bands", () => {487+ for (let i = 0; i < 40; i++) {488+ const p = pilotFor("band-seed", i);489+ assert(p.wake >= 0.6 && p.wake <= 1.4, `wake ${p.wake}`);490+ assert(p.lanePref >= 0.35 && p.lanePref <= 0.65, `lanePref ${p.lanePref}`);491+ assert(p.chir === -1 || p.chir === 1, `chir ${p.chir}`);492+ }493+});
M tests/multimarble_test.ts +14 -1
@@ -31,6 +31,7 @@ import {
3131 } from "../src/client/physics/rail.ts";3232 import {3333 autopilotForce,34+ draftFactor,3435 type BoostState,3536 boostStep,3637 FULL_TANK,
@@ -57,6 +58,8 @@ interface RaceResult {
5758 minPair: number;5859 fell: boolean[];5960 hash: bigint;61+ /** Decision steps where any racer's draft factor exceeded 1 (M20a). */62+ draftSteps: number;6063 }6164 6265 interface TestRacer {
@@ -114,6 +117,7 @@ function race(
114117 }115118 const mass = engine.massOf(0);116119 let minPair = Infinity;120+ let draftSteps = 0;117121 const fell = new Array<boolean>(n).fill(false);118122 let hash = 1469598103934665603n;119123 const FNV = 1099511628211n;
@@ -173,6 +177,10 @@ function race(
173177 gapAhead,174178 gapBehind,175179 );180+ // Draft law (M20a) — client parity: wake bonus behind the181+ // nearest leader, through the same envelope.182+ const draft = draftFactor(gapAhead, r.pilot.wake);183+ if (draft > 1) draftSteps++;176184 const f = autopilotForce(177185 rail,178186 r.nearest,
@@ -184,6 +192,7 @@ function race(
184192 r.dbg,185193 others,186194 r.boost.boosting,195+ draft,187196 );188197 const gd = stationGravityDelta(rail, r.nearest, mass);189198 const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);
@@ -220,7 +229,7 @@ function race(
220229 const order = racers.map((r) => r.idx).sort(221230 (a, b) => racers[b].s - racers[a].s || a - b,222231 );223- return { order, s: racers.map((r) => r.s), minPair, fell, hash };232+ return { order, s: racers.map((r) => r.s), minPair, fell, hash, draftSteps };224233 }225234 226235 Deno.test("M15 race: the pack progresses, touches, and finishes deterministically", async () => {
@@ -248,6 +257,10 @@ Deno.test("M15 race: the pack progresses, touches, and finishes deterministicall
248257 249258 // Determinism: a fresh module, the same seed → identical finish order250259 // and trajectory hash.260+ // Drafting is real (M20a): at least one wake window opened in a pack261+ // spaced 1u apart at spawn.262+ assert(a.draftSteps > 0, "no draft window ever opened across the race");263+251264 const module2 = await factory();252265 const b = race("det-seed", N, 4, 60, module2);253266 assertEquals(a.order, b.order);
A tests/personality_snapshot.json +169 -0
@@ -0,0 +1,169 @@
1+{2+ "pilot/snap-a/0": {3+ "bravery": 1.0124702676478774,4+ "lookGain": 0.6315410667657853,5+ "eagerness": 0.9451879090163857,6+ "aggro": 1.1434963363222777,7+ "regen": 0.9372440371662378,8+ "boostMult": 1.6637997077777982,9+ "efficiency": 0.9358240719651804,10+ "offset": 0,11+ "wanderAmp": 0.17273954015690834,12+ "phase1": 4.779361181337108,13+ "phase2": 0.2078798588476740214+ },15+ "lane/snap-a/0": [16+ true,17+ true,18+ false19+ ],20+ "identity/snap-a/0": {21+ "meshId": "marble-center-high",22+ "tint": [23+ 209,24+ 31,25+ 20226+ ],27+ "shell": [28+ 0.9955034380465136,29+ 0.9851057488117869,30+ 0.964241789604175331+ ],32+ "effect": {33+ "kind": "pearl",34+ "intensity": 0.744112491933628935+ }36+ },37+ "pilot/snap-a/7": {38+ "bravery": 0.9978252980904654,39+ "lookGain": 0.48361510600894686,40+ "eagerness": 0.9896552304271609,41+ "aggro": 0.9842666635755448,42+ "regen": 1.0898271288024262,43+ "boostMult": 1.4749682968482376,44+ "efficiency": 1.0855301964329556,45+ "offset": 0,46+ "wanderAmp": 0.10894386162981391,47+ "phase1": 3.278125001464396,48+ "phase2": 4.61686883866166449+ },50+ "lane/snap-a/7": [51+ false,52+ false,53+ false54+ ],55+ "identity/snap-a/7": {56+ "meshId": "marble-center-low",57+ "tint": [58+ 214,59+ 113,60+ 9161+ ],62+ "shell": [63+ 0.8719198167985822,64+ 0.9993932898299313,65+ 0.888943483792099666+ ],67+ "effect": {68+ "kind": "gloss",69+ "intensity": 0.898939275275915970+ }71+ },72+ "pilot/marbles/3": {73+ "bravery": 0.939051544428803,74+ "lookGain": 0.6268309875577688,75+ "eagerness": 1.0816793949343264,76+ "aggro": 1.2386396979447454,77+ "regen": 1.0945364360231906,78+ "boostMult": 1.354830671939999,79+ "efficiency": 0.9186742632649839,80+ "offset": 0,81+ "wanderAmp": 0.11450721696019173,82+ "phase1": 1.547540161556552,83+ "phase2": 6.03746509288350684+ },85+ "lane/marbles/3": [86+ true,87+ true,88+ false89+ ],90+ "identity/marbles/3": {91+ "meshId": "marble-center-high",92+ "tint": [93+ 192,94+ 60,95+ 19996+ ],97+ "shell": [98+ 0.8743734414691261,99+ 0.9879930199933267,100+ 0.7846903241592276101+ ],102+ "effect": {103+ "kind": "gloss",104+ "intensity": 0.4502093388233334105+ }106+ },107+ "pilot/wide-scan-13/41": {108+ "bravery": 0.9383893269300461,109+ "lookGain": 0.532027853615582,110+ "eagerness": 0.9639373406767845,111+ "aggro": 0.8489203966222703,112+ "regen": 1.1227143162395805,113+ "boostMult": 1.3731994121335447,114+ "efficiency": 1.184551779087633,115+ "offset": 0,116+ "wanderAmp": 0.10836620603222401,117+ "phase1": 4.921491974798013,118+ "phase2": 2.052609237379431119+ },120+ "lane/wide-scan-13/41": [121+ false,122+ false,123+ false124+ ],125+ "identity/wide-scan-13/41": {126+ "meshId": "marble-center-low",127+ "tint": null,128+ "shell": [129+ 0.6781005164106947,130+ 0.8444619921553679,131+ 0.9700783380232848132+ ],133+ "effect": {134+ "kind": "pearl",135+ "intensity": 0.8212035440374166136+ }137+ },138+ "pilot/det-seed/128": {139+ "bravery": 0.9214073825208471,140+ "lookGain": 0.5780512718111277,141+ "eagerness": 0.9163353832438589,142+ "aggro": 1.049104548804462,143+ "regen": 1.1780930283712223,144+ "boostMult": 1.5028206320479514,145+ "efficiency": 1.240257442474831,146+ "offset": 0,147+ "wanderAmp": 0.14140451774001123,148+ "phase1": 3.034289988254508,149+ "phase2": 4.733065487084187150+ },151+ "lane/det-seed/128": [152+ false,153+ false,154+ true155+ ],156+ "identity/det-seed/128": {157+ "meshId": "marble-high",158+ "tint": null,159+ "shell": [160+ 0.6971449477368707,161+ 0.9727084325622881,162+ 0.851681750832373163+ ],164+ "effect": {165+ "kind": "ghost",166+ "intensity": 0.5866860167589039167+ }168+ }169+}