One rule, five systems couple: riding a wake REFILLS the boost tank (DRAFT_REFILL 1/14 at full depth on top of base regen 1/12 — a committed wake rider roughly doubles its recharge; burns never sip the tow). - boostStep gains draftDepth (appended param, decision-table rows); decide() feeds (draft−1)/DRAFT_GAIN; riders keep their kinematic fake (no fuel at LOD distance). - Emergence: pilots with wake appetite line up for the economy, hoard, and detonate at attack range — cascading overtakes nobody scripted. - Telemetry: race JSON gains maxTrain + trains (s-consecutive drafting chains ≥3) so the trains are measurable in soaks. - All client-side: goldens byte-identical, scenario pins untouched. 206 green, check clean.
- commit
- 291336ca2fd71bcafcaa71b5e65417321aa5dc8c
- parent
- 7e734b734e74ea8d5796e68ad0c2f71e5c42ac5f
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 39 | +++++++++++++++++---- |
| M | src/ | 13 | ++++++- |
| M | tests/ | 56 | +++++++++++++++++++++++++++++- |
@@ -48,6 +48,7 @@ import {
4848 type BoostState,4949 boostStep,5050 braidLaneAlt,51+ DRAFT_GAIN,5152 draftFactor,5253 FULL_TANK,5354 type Neighbor,@@ -1001,6 +1002,8 @@ class Session {
10011002 spreadP10P90: number;10021003 rival: { target: number; valence: string } | null;10031004 rivalIgnitions: number;1005+ maxTrain: number;1006+ trains: number;10041007 racers: {10051008 idx: number;10061009 s: number;@@ -1017,6 +1020,21 @@ class Session {
10171020 // Pack-spread percentile gap (M20a telemetry): the liveliness-law10181021 // tripwire — a draft compression wave would collapse it.10191022 const byS = [...this.racers].sort((a, b) => a.s - b.s);1023+ // Draft trains (M22): maximal chains of s-consecutive racers whose1024+ // followers are all in a wake this decision.1025+ let maxTrain = 1;1026+ let trains = 0;1027+ let run = 1;1028+ for (let k = 1; k < byS.length; k++) {1029+ if (byS[k].draft > 1 || byS[k - 1].draft > 1) run++;1030+ else {1031+ if (run >= 3) trains++;1032+ maxTrain = Math.max(maxTrain, run);1033+ run = 1;1034+ }1035+ }1036+ if (run >= 3) trains++;1037+ maxTrain = Math.max(maxTrain, run);10201038 const p10 = byS[Math.floor((byS.length - 1) * 0.1)].s;10211039 const p90 = byS[Math.floor((byS.length - 1) * 0.9)].s;10221040 const heroRival = this.racers[0]?.rival ?? null;@@ -1030,6 +1048,8 @@ class Session {
10301048 ? { target: heroRival.target, valence: heroRival.valence }10311049 : null,10321050 rivalIgnitions: this.rivalIgnitions,1051+ maxTrain,1052+ trains,10331053 racers: top.map((r) => ({10341054 idx: r.idx,10351055 s: Number(r.s.toFixed(1)),@@ -1930,6 +1950,16 @@ class Session {
19301950 : ally !== null && aheadRacer !== null && aheadRacer.idx === ally.target19311951 ? null19321952 : gapAhead;1953+ // Drafting (M20a): wake bonus behind the SAME-LANE leader — on a1954+ // braid, a wake across lanes is fiction; elsewhere lanes coincide.1955+ // Held at decision cadence (the boost-state 1-step-lag contract).1956+ const sameLane = aheadRacer === null ||1957+ !this.braidSegs.has(r.nearest.seg) ||1958+ (aheadRacer.laneAlt === r.laneAlt &&1959+ aheadRacer.laneBraid === r.laneBraid);1960+ r.draft = sameLane ? draftFactor(gapAhead, r.pilot.wake) : 1;1961+ // M22 slipstream economy: wake time pays fuel (boostStep refill).1962+ const draftDepth = (r.draft - 1) / DRAFT_GAIN;19331963 const wasBoostingNow = r.boost.boosting;19341964 r.boost = boostStep(19351965 r.boost,@@ -1938,18 +1968,11 @@ class Session {
19381968 headroom,19391969 gapAheadEff,19401970 gapBehind,1971+ draftDepth,19411972 );19421973 if (!wasBoostingNow && r.boost.boosting && aheadIsGrudge) {19431974 this.rivalIgnitions++;19441975 }1945- // Drafting (M20a): wake bonus behind the SAME-LANE leader — on a1946- // braid, a wake across lanes is fiction; elsewhere lanes coincide.1947- // Held at decision cadence (the boost-state 1-step-lag contract).1948- const sameLane = aheadRacer === null ||1949- !this.braidSegs.has(r.nearest.seg) ||1950- (aheadRacer.laneAlt === r.laneAlt &&1951- aheadRacer.laneBraid === r.laneBraid);1952- r.draft = sameLane ? draftFactor(gapAhead, r.pilot.wake) : 1;19531976 // Overtake commitment (M20a): while the burn is an ATTACK (rival19541977 // ahead in range), hold an off-center passing line on the pilot's19551978 // chirality side. Hysteresis (engage < 3.5u, release > 5u) plus a@@ -238,6 +238,10 @@ export const BOOST_MULT = 1.55;
238238 const BOOST_DRAIN = 1 / 3;239239 /** Empty tank refills in ~12s. */240240 const BOOST_RECHARGE = 1 / 12;241+/** M22: extra refill per second at full draft depth — a committed wake242+ * rider roughly DOUBLES its regen. The economy makes trains: line up,243+ * hoard, detonate. */244+export const DRAFT_REFILL = 1 / 14;241245 /** Don't ignite below this. Sized for RHYTHM: in a tight pack a rival is242246 * always in range, so pilots ignite the moment they may — a low floor243247 * gave 0.6s sputters at the floor level. Half a tank commits to a@@ -270,13 +274,20 @@ export function boostStep(
270274 headroom: number | null,271275 gapAhead: number | null,272276 gapBehind: number | null,277+ /** M22 slipstream economy: 0..~1.4 draft depth ((draft−1)/DRAFT_GAIN).278+ * Riding a wake REFILLS the tank on top of the base regen — trains279+ * form because they pay, then detonate at attack range. */280+ draftDepth = 0,273281 ): BoostState {274282 if (st.boosting) {275283 const fuel = Math.max(0, st.fuel - dt * BOOST_DRAIN / pilot.efficiency);276284 const capped = headroom !== null && headroom < 0.1;277285 return { fuel, boosting: fuel > 0.02 && !capped };278286 }279- const fuel = Math.min(1, st.fuel + dt * BOOST_RECHARGE * pilot.regen);287+ const fuel = Math.min(288+ 1,289+ st.fuel + dt * (BOOST_RECHARGE * pilot.regen + DRAFT_REFILL * draftDepth),290+ );280291 const room = headroom !== null && headroom > 0.8;281292 const attack = gapAhead !== null && gapAhead < BOOST_ATTACK * pilot.aggro;282293 const defend = gapBehind !== null && gapBehind < BOOST_DEFEND * pilot.aggro;@@ -3,7 +3,7 @@
33 * law and the jump decision are pure functions of (rail, state), so their44 * contracts are asserted directly on synthetic rails.55 */6-import { assert, assertEquals } from "@std/assert";6+import { assert, assertAlmostEquals, assertEquals } from "@std/assert";77 import type { Rail } from "../src/client/physics/rail.ts";88 import { racerIdentity, rivalFor } from "../src/client/peersCore.ts";99 import {@@ -15,6 +15,7 @@ import {
1515 DRAFT_GAIN,1616 DRAFT_MAX,1717 DRAFT_MIN,18+ DRAFT_REFILL,1819 draftFactor,1920 FULL_TANK,2021 type Pilot,@@ -598,3 +599,56 @@ Deno.test("rivalFor: directed, label-pure, self-free, deterministic", () => {
598599 `grudge share ${(grudges / has).toFixed(2)} off the 70/30 law`,599600 );600601 });602+603+// ---------------------------------------------------------------------------604+// M22 — slipstream economy.605+// ---------------------------------------------------------------------------606+607+Deno.test("slipstream economy: wake time pays fuel, burns are untouched", () => {608+ const dt = 1 / 60;609+ // Restraint at depth 1 recharges faster than restraint alone.610+ const dry = boostStep({ fuel: 0.5, boosting: false }, DEFAULT_PILOT, dt, 4, 20, 20);611+ const wake = boostStep(612+ { fuel: 0.5, boosting: false },613+ DEFAULT_PILOT,614+ dt,615+ 4,616+ 20,617+ 20,618+ 1,619+ );620+ assert(wake.fuel > dry.fuel, "wake must refill on top of base regen");621+ assertAlmostEquals(622+ wake.fuel - 0.5,623+ dt * (1 / 12 * DEFAULT_PILOT.regen + DRAFT_REFILL),624+ 1e-12,625+ );626+ // Depth scales the refill linearly.627+ const half = boostStep(628+ { fuel: 0.5, boosting: false },629+ DEFAULT_PILOT,630+ dt,631+ 4,632+ 20,633+ 20,634+ 0.5,635+ );636+ assert(637+ Math.abs((half.fuel - dry.fuel) * 2 - (wake.fuel - dry.fuel)) < 1e-12,638+ );639+ // A BURN ignores the wake (you can't sip the tow while flooring it).640+ const burnDry = boostStep({ fuel: 0.8, boosting: true }, DEFAULT_PILOT, dt, 4, 5, null);641+ const burnWake = boostStep(642+ { fuel: 0.8, boosting: true },643+ DEFAULT_PILOT,644+ dt,645+ 4,646+ 5,647+ null,648+ 1,649+ );650+ assertEquals(burnDry.fuel, burnWake.fuel);651+ // The tank still caps at 1.652+ const full = boostStep({ fuel: 0.9999, boosting: false }, DEFAULT_PILOT, 1, 0, 20, 20, 1.4);653+ assert(full.fuel <= 1);654+});