clone

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

RIVALRIES (M20b): seeded grudges and allies, temper, brake style

Dami committed fb13e4f
Directed seed-pure relationships expressed ONLY through existing
actuators — no new physics surface, zero gen-side movement (goldens and
scenario pins untouched; the A0 lock proves the trait appends clean).

- peersCore rivalFor(seed, i, n): own label rival/${i}, fixed draw order
  (has 0.5 → offset 1-16 → valence 70/30 grudge → intensity 0.5-1).
  DIRECTED — i hunts its target, the target is oblivious (a symmetric
  pair would need a shared hash, banned by the rng law). Resolved once
  at pack build.
- Traits appended AFTER M20a's (milestone order IS the draw order):
  brakeStyle 0.8-1.35 scales the PLANNING decel in the brake sweep
  (6·1.35 < A_BRAKE_MAX 10 — a late-braker can always shed what it
  planned; the band inequality is test-pinned), temper 0-1 opens a ~3s
  hot window on contact (temperStep mirrors boostStep; the contact proxy
  is a neighbor inside 2.5 radii — the shim has no contact events).
- decide() expression, levers only: grudge target ahead reads closer
  (gap/(1+intensity) into an unchanged boostStep) and ally never
  triggers an attack; separation asymmetry via the REPORTED neighbor
  bravery (−0.1·intensity, autopilot compare untouched); defensive
  blocking stands in the chasing rival's line (offset ≤0.25 <
  CORRIDOR_DEAD, engage/release hysteresis + slew, strict priority
  overtake > block > wander on the one offset actuator); braid lane
  mirroring adopts the rival's own label-pure draw (grudge follows,
  ally opposes) behind a 10u proximity gate. All transient state resets
  on demote/promote/respawn; rivalry expresses in the live band only.
- HUD: PlaceHud gains one quiet token `#3/128 · R#7`, flag-gated
  (?rival=1), Kenney-frame styling inherited — default decided at
  review. Telemetry: hero rival + grudge-ignition counter, spread/burst
  from M20a.
- Tests: temper decision table, brakeStyle envelope behavior + band
  pins, rivalFor determinism/independence/coverage laws. 196 green,
  check clean, goldens byte-identical.
commit
fb13e4f3dbbd8f5858a573c0801eae576979a7ff
parent
5c4fd0d74aee4e3302f11d16fd601f1e4cfda09d
author
Dami <dami.etoile@gmail.com>
5 files changed, +353 -11
Msrc/client/endless.ts158+++++++++++++++++++++++++++++-
Msrc/client/hud.ts10+-
Msrc/client/peersCore.ts33++++++
Msrc/client/physics/autopilot.ts54++++++++++-
Mtests/autopilot_test.ts109+++++++++++++++++++++-
M src/client/endless.ts +152 -6
@@ -56,9 +56,11 @@ import {
5656 pilotFor,5757 pilotOffset,5858 rubberMod,59+ temperHeat,60+ temperStep,5961 wantJump,6062 } from "./physics/autopilot.ts";61-import { racerIdentity } from "./peersCore.ts";63+import { racerIdentity, type Rival, rivalFor } from "./peersCore.ts";6264 import { rngFamily } from "../gen/rng.ts";6365 import {6466 addMarbleCore,
@@ -340,6 +342,17 @@ const OTK_SLEW_TAU = 0.4;
340342 * compound into a glide that outruns the hero-anchored stream. */341343 const RIDER_DRAFT_CAP = 1.08;342344 345+// Rivalry expression (M20b).346+/** Contact-proxy distance for the temper trigger (~2.5 marble radii). */347+const CONTACT_NEAR = 0.25;348+/** Defensive-block trigger range (world units, scaled by aggro and349+ * grudge intensity — mirrors BOOST_DEFEND's shape). */350+const BLOCK_RANGE = 4;351+/** Blocking line offset — inside CORRIDOR_DEAD like the overtake. */352+const BLOCK_OFF = 0.25;353+/** Braid lane mirroring engages when the rival is this close (world u). */354+const RIVAL_MIRROR_S = 10;355+343356 // --- physics LOD (pack-scale realtime) -------------------------------------344357 //345358 // N=9999 ran at ~0.27x realtime: the 10k-body Box3D step was the wall.
@@ -440,6 +453,13 @@ interface Racer {
440453 /** Progress rank this step (index into the s-sorted pack) — written in441454 * the sort pass; riders read their leader through it. */442455 rank: number;456+ /** Seeded directed relationship (M20b) — resolved once at pack build. */457+ rival: Rival | null;458+ /** Post-contact temper window remaining (seconds; 0 = cool). */459+ temperClock: number;460+ /** Defensive-block blend 0..1 (M20b) — same slew law as otkBlend;461+ * overtake > block > wander on the single offset actuator. */462+ blockBlend: number;443463 }444464 445465 /** Reusable neighbor slots (separation steering) — mutated in place. */
@@ -537,6 +557,11 @@ class Session {
537557 private windT = 0;538558 /** Respawn timestamps inside the trailing 2s (M20a pile-up telemetry). */539559 private readonly respawnTimes: number[] = [];560+ /** Grudge-gated boost ignitions (M20b declustering telemetry). */561+ private rivalIgnitions = 0;562+ /** Rival HUD token flag (M20b, ?rival=1) — default off until review. */563+ private readonly rivalHud =564+ new URLSearchParams(location.search).get("rival") === "1";540565 /** Scratch for the per-decision wind force (allocation-free). */541566 private readonly windScratch: [number, number, number] = [0, 0, 0];542567 /** M16 conjugation scratch (reoriented segments): marble state maps
@@ -764,6 +789,9 @@ class Session {
764789 draft: 1,765790 otkBlend: 0,766791 rank: 0,792+ rival: null,793+ temperClock: 0,794+ blockBlend: 0,767795 });768796 // Racer peers (M15): real bodies on a wrapped grid start —769797 // stations × 3 lanes, then stacked tiers above (a huge pack rains
@@ -925,6 +953,9 @@ class Session {
925953 draft: 1,926954 otkBlend: 0,927955 rank: 0,956+ rival: null,957+ temperClock: 0,958+ blockBlend: 0,928959 });929960 }930961 if (this.peerMesh?.instanceColor) {
@@ -935,6 +966,12 @@ class Session {
935966 }936967 }937968 969+ // Rivalries (M20b): resolved once the pack size is final. Directed,970+ // label-pure per racer — n enters only at the target mod.971+ for (const r of this.racers) {972+ r.rival = rivalFor(seed, r.idx, this.racers.length);973+ }974+938975 this.cameraProxy = {939976 obj: this.marbleObj,940977 s: this.marbleS,
@@ -957,6 +994,8 @@ class Session {
957994 leaderCam: boolean;958995 respawnBurst: number;959996 spreadP10P90: number;997+ rival: { target: number; valence: string } | null;998+ rivalIgnitions: number;960999 racers: {9611000 idx: number;9621001 s: number;
@@ -975,12 +1014,17 @@ class Session {
9751014 const byS = [...this.racers].sort((a, b) => a.s - b.s);9761015 const p10 = byS[Math.floor((byS.length - 1) * 0.1)].s;9771016 const p90 = byS[Math.floor((byS.length - 1) * 0.9)].s;1017+ const heroRival = this.racers[0]?.rival ?? null;9781018 return {9791019 n: this.racers.length,9801020 leader: leader.idx,9811021 leaderCam: this.leaderCam,9821022 respawnBurst: this.respawnTimes.filter((t) => t > this.windT - 2).length,9831023 spreadP10P90: Number((p90 - p10).toFixed(1)),1024+ rival: heroRival1025+ ? { target: heroRival.target, valence: heroRival.valence }1026+ : null,1027+ rivalIgnitions: this.rivalIgnitions,9841028 racers: top.map((r) => ({9851029 idx: r.idx,9861030 s: Number(r.s.toFixed(1)),
@@ -1646,6 +1690,8 @@ class Session {
16461690 r.stallClock = 0;16471691 r.draft = 1;16481692 r.otkBlend = 0;1693+ r.blockBlend = 0;1694+ r.temperClock = 0;16491695 if (r.boost.boosting) r.boost = { fuel: r.boost.fuel, boosting: false };16501696 if (r.wasBoosting) {16511697 r.wasBoosting = false;
@@ -1697,6 +1743,8 @@ class Session {
16971743 r.stallHopped = false;16981744 r.draft = 1;16991745 r.otkBlend = 0;1746+ r.blockBlend = 0;1747+ r.temperClock = 0;17001748 }17011749 17021750 /** Kinematic rider glide: advance progress at a personality cruise and
@@ -1792,6 +1840,9 @@ class Session {
17921840 let gapAhead: number | null = null;17931841 let gapBehind: number | null = null;17941842 let aheadRacer: Racer | null = null;1843+ let behindRacer: Racer | null = null;1844+ const grudge = r.rival?.valence === "grudge" ? r.rival : null;1845+ const ally = r.rival?.valence === "ally" ? r.rival : null;17951846 this.othersScratch.length = 0;17961847 const addNeighbor = (o: Racer) => {17971848 const slot = this.neighborPool[this.othersScratch.length];
@@ -1802,7 +1853,12 @@ class Session {
18021853 slot.pos[1] = o.pos[1];18031854 slot.pos[2] = o.pos[2];18041855 }1805- slot.bravery = o.pilotEff.bravery;1856+ // Separation asymmetry (M20b): the pilot shoves through its1857+ // grudge target more readily — the asymmetry lives entirely in1858+ // what the caller REPORTS; the autopilot compare is unchanged.1859+ slot.bravery = grudge && o.idx === grudge.target1860+ ? o.pilotEff.bravery - 0.1 * grudge.intensity1861+ : o.pilotEff.bravery;18061862 this.othersScratch.push(slot);18071863 };18081864 for (let k = k0 + 1; k < this.sorted.length && k <= k0 + 3; k++) {
@@ -1817,21 +1873,55 @@ class Session {
18171873 for (let k = k0 - 1; k >= 0 && k >= k0 - 3; k--) {18181874 const o = this.sorted[k];18191875 const d = r.s - o.s;1820- if (gapBehind === null && d > 0.05) gapBehind = d;1876+ if (gapBehind === null && d > 0.05) {1877+ gapBehind = d;1878+ behindRacer = o;1879+ }18211880 if (d < 2 && this.othersScratch.length < 4) addNeighbor(o);18221881 }1882+ // Temper (M20b): no contact events in the shim — the deterministic1883+ // proxy is a peer inside contact distance this decision (plan1884+ // space, the same slots separation sees).1885+ let contactNear = false;1886+ for (const slot of this.othersScratch) {1887+ const dx = slot.pos[0] - cpos[0];1888+ const dy = slot.pos[1] - cpos[1];1889+ const dz = slot.pos[2] - cpos[2];1890+ if (dx * dx + dy * dy + dz * dz < CONTACT_NEAR * CONTACT_NEAR) {1891+ contactNear = true;1892+ break;1893+ }1894+ }1895+ r.temperClock = temperStep(r.temperClock, FIXED_DT * K, contactNear);1896+ const heat = temperHeat(r.temperClock, r.pilot.temper);1897+ r.pilotEff.aggro *= heat.aggro;1898+ r.pilotEff.bravery *= heat.bravery;18231899 const headroom =18241900 r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined18251901 ? r.dbg.vSafe - r.dbg.wishspeed18261902 : null;1903+ // Rivalry boost bias (M20b), levers only — boostStep unchanged:1904+ // a grudge target ahead reads CLOSER (widened attack range); an1905+ // ally ahead never triggers an attack (defend/overflow untouched).1906+ const aheadIsGrudge = grudge !== null && aheadRacer !== null &&1907+ aheadRacer.idx === grudge.target;1908+ const gapAheadEff = aheadIsGrudge1909+ ? gapAhead! / (1 + grudge!.intensity)1910+ : ally !== null && aheadRacer !== null && aheadRacer.idx === ally.target1911+ ? null1912+ : gapAhead;1913+ const wasBoostingNow = r.boost.boosting;18271914 r.boost = boostStep(18281915 r.boost,18291916 r.pilotEff,18301917 FIXED_DT * K,18311918 headroom,1832- gapAhead,1919+ gapAheadEff,18331920 gapBehind,18341921 );1922+ if (!wasBoostingNow && r.boost.boosting && aheadIsGrudge) {1923+ this.rivalIgnitions++;1924+ }18351925 // Drafting (M20a): wake bonus behind the SAME-LANE leader — on a18361926 // braid, a wake across lanes is fiction; elsewhere lanes coincide.18371927 // Held at decision cadence (the boost-state 1-step-lag contract).
@@ -1851,9 +1941,36 @@ class Session {
18511941 r.otkBlend += ((otkEngaged ? 1 : 0) - r.otkBlend) *18521942 Math.min(1, (FIXED_DT * K) / OTK_SLEW_TAU);18531943 if (r.otkBlend < 0.02 && !otkEngaged) r.otkBlend = 0;1944+ // Defensive blocking (M20b): the grudge target chasing close gets1945+ // its line stood in — offset biased toward the chaser's side, same1946+ // hysteresis + slew, strictly BELOW the overtake in priority (you1947+ // can't block while attacking; one offset actuator, one owner).1948+ let blockOff = 0;1949+ const chaserIsGrudge = grudge !== null && behindRacer !== null &&1950+ behindRacer.idx === grudge.target;1951+ const blockEngaged = !otkEngaged && chaserIsGrudge &&1952+ gapBehind !== null &&1953+ gapBehind <1954+ BLOCK_RANGE * r.pilotEff.aggro * (1 + grudge!.intensity) *1955+ (r.blockBlend > 0 ? 1.3 : 1);1956+ if (chaserIsGrudge || r.blockBlend > 0) {1957+ const t = rail.tans[r.nearest.idx];1958+ if (behindRacer && t) {1959+ const cp = frame1960+ ? affineUnapply(frame, behindRacer.pos, this.frameScratch)1961+ : behindRacer.pos;1962+ const side = (cp[0] - cpos[0]) * t[2] - (cp[2] - cpos[2]) * t[0];1963+ blockOff = (side >= 0 ? 1 : -1) * BLOCK_OFF;1964+ }1965+ }1966+ r.blockBlend += ((blockEngaged ? 1 : 0) - r.blockBlend) *1967+ Math.min(1, (FIXED_DT * K) / OTK_SLEW_TAU);1968+ if (r.blockBlend < 0.02 && !blockEngaged) r.blockBlend = 0;18541969 const wander = pilotOffset(r.pilot, r.simT);1855- r.pilotEff.offset = wander * (1 - r.otkBlend) +1970+ let offset = wander * (1 - r.blockBlend) + blockOff * r.blockBlend;1971+ offset = offset * (1 - r.otkBlend) +18561972 r.pilot.chir * OVERTAKE_OFF * r.otkBlend;1973+ r.pilotEff.offset = offset;18571974 f = autopilotForce(18581975 rail,18591976 r.nearest.idx,
@@ -2318,6 +2435,8 @@ class Session {
23182435 r.laneBraid = -1;23192436 r.draft = 1;23202437 r.otkBlend = 0;2438+ r.blockBlend = 0;2439+ r.temperClock = 0;23212440 // Pile-up telemetry (M20a): trailing-window respawn counter.23222441 this.respawnTimes.push(this.windT);23232442 while (this.respawnTimes.length > 0 && this.respawnTimes[0] < this.windT - 4) {
@@ -2483,7 +2602,23 @@ class Session {
24832602 r.laneBraid = segIdx;24842603 // Lane identity is CHARACTER (M20a): base pilot's threshold, never24852604 // the rubber-banded scratch. Same label, same single draw.2486- const want = braidLaneAlt(this.seed, r.idx, segIdx, r.pilot.lanePref);2605+ let want = braidLaneAlt(this.seed, r.idx, segIdx, r.pilot.lanePref);2606+ // Lane mirroring (M20b): a nearby rival bends the choice — the2607+ // grudge FOLLOWS its target's own label-pure draw, the ally takes2608+ // the opposite (hunters chase, allies spread). No stream coupling:2609+ // the mirrored lane is a pure function of (seed, targetIdx, seg),2610+ // and the proximity gate is deterministic sim state.2611+ const rv = r.rival;2612+ const target = rv ? this.racers[rv.target] : undefined;2613+ if (rv && target && Math.abs(target.s - r.s) < RIVAL_MIRROR_S) {2614+ const theirs = braidLaneAlt(2615+ this.seed,2616+ rv.target,2617+ segIdx,2618+ target.pilot.lanePref,2619+ );2620+ want = rv.valence === "grudge" ? theirs : !theirs;2621+ }24872622 if (want !== r.laneAlt) {24882623 r.laneAlt = want;24892624 // Re-anchor on the lane rail (pre-fork stations coincide, so the
@@ -2698,9 +2833,20 @@ class Session {
26982833 this.placeClock = 0;26992834 const hero = this.racers[0];27002835 if (hero && this.racers.length > 1) {2836+ // Rival readout (M20b): flag-gated (?rival=1) while the default2837+ // is decided at milestone review — one quiet token, grudge +2838+ // rival-live only, inheriting the Kenney frame styling.2839+ let rivalPlace: number | undefined;2840+ if (this.rivalHud && hero.rival?.valence === "grudge") {2841+ const target = this.racers[hero.rival.target];2842+ if (target) {2843+ rivalPlace = racePlace(target.s, this.racers.map((r) => r.s));2844+ }2845+ }27012846 this.placeHud.set(27022847 racePlace(hero.s, this.racers.map((r) => r.s)),27032848 this.racers.length,2849+ rivalPlace,27042850 );27052851 } else this.placeHud.hide();27062852 }
M src/client/hud.ts +7 -3
M src/client/peersCore.ts +33 -0
@@ -325,6 +325,39 @@ export function racerIdentity(seed: string, index: number): RacerLook {
325325 return { meshId, tint, shell, effect: { kind, intensity } };326326 }327327 328+// --- rivalries (M20b) -------------------------------------------------------329+330+export 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+}338+339+/**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+ */348+export 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+}360+328361 /** Rich marble-glass tints (bounded like the pipe colors so peers read329362 * against every atmosphere). */330363 function drawTint(
M src/client/physics/autopilot.ts +53 -1
@@ -72,6 +72,15 @@ export interface Pilot {
7272 lanePref: number;7373 /** Overtake chirality (M20a): pass-left (−1) or pass-right (+1). */7474 chir: number;75+ /** Cornering style (M20b): scales the PLANNING decel in the brake76+ * sweep. >1 = late-braker (dive-bombs entries), <1 = early-coaster.77+ * Hard cap 1.4: planned decel 6·1.4 = 8.4 stays under A_BRAKE_MAX 10,78+ * so a late-braker can always shed the speed it planned to — envelope79+ * violations impossible by construction. */80+ brakeStyle: number;81+ /** Post-contact temper (M20b): 0-1 scales the aggression window a82+ * shove opens (temperStep). */83+ temper: number;7584 }7685 7786 export const DEFAULT_PILOT: Pilot = {
@@ -89,6 +98,8 @@ export const DEFAULT_PILOT: Pilot = {
8998 wake: 1,9099 lanePref: 0.5,91100 chir: 1,101+ brakeStyle: 1,102+ temper: 0,92103 };93104 94105 /** Seed-stable personality for marble `index` (label-derived stream —
@@ -109,6 +120,8 @@ export function pilotFor(seed: string, index = 0): Pilot {
109120 const wake = 0.6 + rng.float() * 0.8; // appended draw (M20a)110121 const lanePref = 0.35 + rng.float() * 0.3; // appended draw (M20a)111122 const chir = rng.float() < 0.5 ? -1 : 1; // appended draw (M20a)123+ const brakeStyle = 0.8 + rng.float() * 0.55; // appended draw (M20b)124+ const temper = rng.float(); // appended draw (M20b)112125 return {113126 bravery,114127 lookGain,
@@ -124,9 +137,44 @@ export function pilotFor(seed: string, index = 0): Pilot {
124137 wake,125138 lanePref,126139 chir,140+ brakeStyle,141+ temper,127142 };128143 }129144 145+// --- post-shove temper (M20b) ----------------------------------------------146+//147+// The shim has no contact events, so "shoved" is the deterministic proxy148+// the pack already computes: a peer inside contact distance during the149+// decision's neighbor scan. A contact opens a ~3s window during which150+// the pilot's aggression (boost triggers) and shove-through bravery run151+// hot, scaled by the temper trait. Pure state law, boostStep's shape —152+// drivers hold the clock per racer.153+154+/** Aggression window a contact opens (seconds). */155+export const TEMPER_WINDOW = 3;156+157+/** One fixed-step of the temper clock: `contactNear` = a peer within158+ * contact distance this decision. Returns the remaining hot-window time159+ * (0 = cool). Re-contact re-arms the full window (no accumulation). */160+export function temperStep(161+ clock: number,162+ dt: number,163+ contactNear: boolean,164+): number {165+ if (contactNear) return TEMPER_WINDOW;166+ return Math.max(0, clock - dt);167+}168+169+/** Effective aggro/bravery multipliers while the temper window runs. */170+export function temperHeat(171+ clock: number,172+ temper: number,173+): { aggro: number; bravery: number } {174+ if (clock <= 0 || temper <= 0) return { aggro: 1, bravery: 1 };175+ return { aggro: 1 + 0.5 * temper, bravery: 1 + 0.08 * temper };176+}177+130178 /** Braid lane choice (M17): true = the alt lane. Each racer draws from131179 * ITS OWN label-derived stream, `lane/${idx}/${segIndex}` — the project's132180 * rng discipline: no sequential draws, no draw-order coupling, seed-
@@ -499,7 +547,11 @@ export function autopilotForce(
499547 const vCrest = kCrest > 1e-4 ? Math.sqrt(A_CREST / kCrest) * grip : VMAX;500548 const vc = Math.min(VMAX, Math.max(targetK, Math.min(vYaw, vCrest)));501549 const dist = sk - sHere;502- const allowed = Math.sqrt(vc * vc + 2 * A_BRAKE * dist);550+ // brakeStyle (M20b): late-brakers PLAN a harder decel (capped so551+ // the actual brake clamp A_BRAKE_MAX can always deliver it).552+ const allowed = Math.sqrt(553+ vc * vc + 2 * A_BRAKE * pilot.brakeStyle * dist,554+ );503555 if (allowed < vSafe) vSafe = allowed;504556 }505557 climbTy = Math.max(climbTy, look[1]);
M tests/autopilot_test.ts +108 -1
@@ -5,7 +5,7 @@
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";8+import { racerIdentity, rivalFor } from "../src/client/peersCore.ts";99 import {1010 autopilotForce,1111 BOOST_MULT,
@@ -21,6 +21,9 @@ import {
2121 type PilotDebug,2222 pilotFor,2323 rubberMod,24+ TEMPER_WINDOW,25+ temperHeat,26+ temperStep,2427 wantJump,2528 } from "../src/client/physics/autopilot.ts";2629
@@ -491,3 +494,107 @@ Deno.test("M20a traits: appended draws land in their bands", () => {
491494 assert(p.chir === -1 || p.chir === 1, `chir ${p.chir}`);492495 }493496 });497+498+// ---------------------------------------------------------------------------499+// M20b — temper, brake style, rivalries.500+// ---------------------------------------------------------------------------501+502+/** The tight-bend rail from the brake test, reused for style checks. */503+function bendRail(): Rail {504+ const rail: Rail = { pts: [], tans: [], s: [], mods: [] };505+ let s = 0;506+ let x = 0, z = 0;507+ for (let i = 0; i < 40; i++) {508+ const a = Math.min(Math.PI / 2, Math.max(0, (i - 20) * 0.5));509+ rail.pts.push([x, 0, z]);510+ rail.tans.push([Math.cos(a), 0, Math.sin(a)]);511+ rail.s.push(s);512+ rail.mods.push(null);513+ x += Math.cos(a) * 0.5;514+ z += Math.sin(a) * 0.5;515+ s += 0.5;516+ }517+ return rail;518+}519+520+Deno.test("temperStep/temperHeat: contact opens a decaying window, cool = identity", () => {521+ // A contact arms the full window; re-contact re-arms, never accumulates.522+ let clock = temperStep(0, 1 / 30, true);523+ assertEquals(clock, TEMPER_WINDOW);524+ clock = temperStep(clock, 1, false);525+ assertEquals(clock, TEMPER_WINDOW - 1);526+ assertEquals(temperStep(clock, 1 / 30, true), TEMPER_WINDOW);527+ // Decays to exactly zero and stays there.528+ clock = temperStep(0.05, 1, false);529+ assertEquals(clock, 0);530+ // Heat: identity when cool or temperless; hot scales aggro > bravery.531+ assertEquals(temperHeat(0, 1), { aggro: 1, bravery: 1 });532+ assertEquals(temperHeat(2, 0), { aggro: 1, bravery: 1 });533+ const hot = temperHeat(2, 1);534+ assertEquals(hot.aggro, 1.5);535+ assertEquals(hot.bravery, 1.08);536+ const mild = temperHeat(2, 0.5);537+ assert(mild.aggro < hot.aggro && mild.aggro > 1);538+});539+540+Deno.test("brakeStyle: late-brakers carry more speed at entry, brake still catches", () => {541+ const rail = bendRail();542+ const early: Pilot = { ...DEFAULT_PILOT, brakeStyle: 0.8 };543+ const late: Pilot = { ...DEFAULT_PILOT, brakeStyle: 1.35 };544+ const dbgE: PilotDebug = {};545+ const dbgL: PilotDebug = {};546+ const pos: [number, number, number] = [rail.pts[14][0], 0.1, rail.pts[14][2]];547+ autopilotForce(rail, 14, pos, [8.5, 0, 0], 1, null, early, dbgE);548+ autopilotForce(rail, 14, pos, [8.5, 0, 0], 1, null, late, dbgL);549+ // The late-braker PLANS a deeper entry (higher swept envelope) …550+ assert(551+ dbgL.vSafe! > dbgE.vSafe!,552+ `late vSafe ${dbgL.vSafe} must exceed early ${dbgE.vSafe}`,553+ );554+ // … and both still detect the bend and brake when hot above it.555+ assert(dbgE.mode === "brake" && dbgL.mode === "brake");556+ // Trait band keeps the planned decel deliverable: 6·1.4 < 10 (the557+ // A_BRAKE/A_BRAKE_MAX envelope-safety inequality, pinned here so a558+ // future band widening trips it).559+ for (let i = 0; i < 40; i++) {560+ const p = pilotFor("style-seed", i);561+ assert(p.brakeStyle >= 0.8 && p.brakeStyle <= 1.35 + 1e-9);562+ assert(6 * p.brakeStyle < 10, "planned decel must stay deliverable");563+ assert(p.temper >= 0 && p.temper <= 1);564+ }565+});566+567+Deno.test("rivalFor: directed, label-pure, self-free, deterministic", () => {568+ // Deterministic and independent: racer i's relationship never moves569+ // with pack composition beyond the target mod, and never self-targets.570+ for (const n of [2, 5, 16, 128]) {571+ for (let i = 0; i < Math.min(n, 24); i++) {572+ const a = rivalFor("rival-seed", i, n);573+ const b = rivalFor("rival-seed", i, n);574+ assertEquals(a, b);575+ if (a) {576+ assert(a.target !== i, `racer ${i} self-rival at n=${n}`);577+ assert(a.target >= 0 && a.target < n);578+ assert(a.intensity >= 0.5 && a.intensity <= 1);579+ assert(a.valence === "grudge" || a.valence === "ally");580+ }581+ }582+ }583+ // Solo pack: nobody to fixate on.584+ assertEquals(rivalFor("rival-seed", 0, 1), null);585+ // Coverage ~50%, grudges dominate ~70/30 across a corpus.586+ let has = 0;587+ let grudges = 0;588+ for (let i = 0; i < 300; i++) {589+ const r = rivalFor("corpus-seed", i, 300);590+ if (r) {591+ has++;592+ if (r.valence === "grudge") grudges++;593+ }594+ }595+ assert(has > 100 && has < 200, `coverage ${has}/300 off the 50% law`);596+ assert(597+ grudges / has > 0.55 && grudges / has < 0.85,598+ `grudge share ${(grudges / has).toFixed(2)} off the 70/30 law`,599+ );600+});