User direction: real competition beyond contact randomness — a limited boost on a fuel system. Each pilot carries a tank (burns in ~3s, refills in ~12s scaled by a per-pilot regen trait); ignition is a strategic decision in autopilot.ts boostStep: attack a rival ahead (8·aggro units), break away from a chaser (4·aggro), or bleed an overflowing tank — always gated on envelope headroom, so wishspeed = min(target·1.55, vSafe) and a boosting marble still brakes for every corner (safety unchanged by construction). BOOST_MIN_FUEL 0.5 commits burns to ~1.4s attacks instead of floor-level sputters; regen spread desynchronizes the tanks so the pack duels instead of surging in unison. aggro/regen are appended pilotFor draws (older traits stay byte-identical). Boosting marbles glow (cloned materials, emissive); the audio boost cue fires for the camera subject; fuel/boosting land in race telemetry. Gates: 115 tests green (new boostStep decision-table + boost-envelope contract tests; all drives run the economy live); 60-seed fuzz 0/0 (median completion 123s→117s — overflow bleed at work); pack fuzz 30×6 0 fails 0 respawns; browser: ~20% burn duty, desynchronized tanks, genuine mid-race lead trades, 0 console errors. Fixed en route: race-order measurements must stop before the horizon (finished racers ratchet to equal s and order collapses to the sort tie-breaker).
- commit
- f3ce236907735158dd8b9c4630a58d746fb708d0
- parent
- c4eac0b20c5b80a382f35ee3ee9258cb7eef043d
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 17 | ++++ |
| M | src/ | 117 | +++++++++++++++++++++++++----- |
| M | src/ | 105 | +++++++++++++++++++++++++-- |
| M | tests/ | 60 | +++++++++++++++ |
| M | tests/ | 34 | ++++++++- |
| M | tests/ | 14 | ++++ |
| M | tools/ | 12 | +++ |
@@ -1217,6 +1217,23 @@ with 0.75u hysteresis on lead changes (neck-and-neck racers alternate
12171217 the top spot every few frames; without hysteresis the shot flickered12181218 and the trail reset endlessly).12191219 1220+BOOST ECONOMY (competition round, user: "limited boost that recharges1221+over time... pushing forward aggressively on a fuel system"): each pilot1222+carries a fuel tank (burns ~3s, refills ~12s x regen trait); ignition is1223+a DECISION — attack (rival ahead within 8·aggro), breakaway (chaser1224+within 4·aggro), or overflow bleed — gated on envelope HEADROOM1225+(wishspeed = min(target·1.55, vSafe): a boosting marble still brakes for1226+every corner, safety unchanged by construction). BOOST_MIN_FUEL 0.51227+commits burns to ~1.4s attacks (a low floor sputtered); per-pilot regen1228+0.75–1.25 desynchronizes tanks (identical rates surged the pack in1229+unison). aggro + regen are APPENDED pilotFor draws — older traits stay1230+byte-identical. Boosting marbles glow (own cloned materials, emissive);1231+audio boost cue for the camera subject; fuel/boosting in race telemetry.1232+Measured: ~20% burn duty, lead changes 5→785 on one seed, mid-race1233+orders genuinely shuffle. NOTE race-order measurement pitfall: racers1234+reaching the built rail's END all ratchet to the same s — sample order1235+BEFORE the horizon (multimarble test stops at endS−6).1236+12201237 REMAINING / NEXT IDEAS: marble-marble collision-immunity window on12211238 respawn (shim filter bits — deferred from M15); rubber-banding via pilot12221239 skill params (never thrust — Melder); race HUD (leaderboard strip, name@@ -40,8 +40,12 @@ import {
4040 } from "./physics/rail.ts";4141 import {4242 autopilotForce,43+ type BoostState,44+ boostStep,45+ FULL_TANK,4346 type Neighbor,4447 type Pilot,48+ type PilotDebug,4549 pilotFor,4650 pilotOffset,4751 wantJump,@@ -280,6 +284,11 @@ interface Racer {
280284 stallClock: number;281285 stallHopped: boolean;282286 wasHop: boolean;287+ /** Fuel-boost economy (competition round). */288+ boost: BoostState;289+ wasBoosting: boolean;290+ /** Last force step's introspection — headroom feeds the boost law. */291+ dbg: PilotDebug;283292 }284293 285294 class Session {@@ -407,10 +416,25 @@ class Session {
407416 this.pivot = new THREE.Group();408417 this.marbleObj.add(this.pivot);409418 const mesh = assets.getPiece(MARBLE_PIECE);419+ const heroMaterials: THREE.Material[] = [];410420 if (mesh) {411421 // Half the kit's authored size (user: "shrink the ball 50%") —412422 // matches the physics MARBLE_RADIUS.413423 mesh.scale.setScalar(MARBLE_SCALE);424+ // Own materials (boost glow writes emissive; shared kit materials425+ // would light every piece using them).426+ mesh.traverse((child: THREE.Object3D) => {427+ const m = child as THREE.Mesh;428+ if (!m.isMesh) return;429+ const cloneOne = (mat: THREE.Material): THREE.Material => {430+ const clone = mat.clone();431+ heroMaterials.push(clone);432+ return clone;433+ };434+ m.material = Array.isArray(m.material)435+ ? m.material.map(cloneOne)436+ : cloneOne(m.material);437+ });414438 const box = new THREE.Box3().setFromObject(mesh);415439 const center = box.getCenter(new THREE.Vector3());416440 const size = box.getSize(new THREE.Vector3());@@ -459,7 +483,7 @@ class Session {
459483 mass: this.engine.massOf(0),460484 obj: this.marbleObj,461485 pivot: this.pivot,462- materials: [],486+ materials: heroMaterials,463487 nearest: { seg: 0, idx: 0 },464488 s: 0,465489 speed: 0,@@ -468,6 +492,9 @@ class Session {
468492 stallClock: 0,469493 stallHopped: false,470494 wasHop: false,495+ boost: { ...FULL_TANK },496+ wasBoosting: false,497+ dbg: {},471498 });472499 // Racer peers (M15): real bodies staggered ahead of the hero on the473500 // first straight, alternating lanes so the grid start reads.@@ -497,6 +524,9 @@ class Session {
497524 stallClock: 0,498525 stallHopped: false,499526 wasHop: false,527+ boost: { ...FULL_TANK },528+ wasBoosting: false,529+ dbg: {},500530 });501531 }502532 }@@ -521,7 +551,13 @@ class Session {
521551 n: number;522552 leader: number;523553 leaderCam: boolean;524- racers: { idx: number; s: number; speed: number }[];554+ racers: {555+ idx: number;556+ s: number;557+ speed: number;558+ fuel: number;559+ boosting: boolean;560+ }[];525561 } | null {526562 if (this.racers.length === 0) return null;527563 let leader = this.racers[0];@@ -534,6 +570,8 @@ class Session {
534570 idx: r.idx,535571 s: Number(r.s.toFixed(1)),536572 speed: Number(r.speed.toFixed(2)),573+ fuel: Number(r.boost.fuel.toFixed(2)),574+ boosting: r.boost.boosting,537575 })),538576 };539577 }@@ -857,10 +895,32 @@ class Session {
857895 r.simT += FIXED_DT;858896 r.pilot.offset = pilotOffset(r.pilot, r.simT);859897 const others: Neighbor[] = [];898+ let gapAhead: number | null = null;899+ let gapBehind: number | null = null;860900 for (const [oi, o] of this.racers.entries()) {861901 if (o === r) continue;862902 others.push({ pos: states[oi].pos, bravery: o.pilot.bravery });903+ const d = o.s - r.s;904+ if (d > 0.05) {905+ gapAhead = gapAhead === null ? d : Math.min(gapAhead, d);906+ } else if (d < -0.05) {907+ gapBehind = gapBehind === null ? -d : Math.min(gapBehind, -d);908+ }863909 }910+ // The boost economy: headroom from the LAST force step's911+ // introspection (1 fixed step of lag, fully deterministic).912+ const headroom =913+ r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined914+ ? r.dbg.vSafe - r.dbg.wishspeed915+ : null;916+ r.boost = boostStep(917+ r.boost,918+ r.pilot,919+ FIXED_DT,920+ headroom,921+ gapAhead,922+ gapBehind,923+ );864924 f = autopilotForce(865925 rail,866926 r.nearest.idx,@@ -869,8 +929,9 @@ class Session {
869929 r.mass,870930 nextRail,871931 r.pilot,872- undefined,932+ r.dbg,873933 others,934+ r.boost.boosting,874935 );875936 } else {876937 f = stationForce(@@ -928,6 +989,22 @@ class Session {
928989 r.s = Math.max(r.s, seg.startS + rail.s[r.nearest.idx]);929990 }930991 992+ // Boost glow: ignition/flameout toggles the marble's own emissive;993+ // the audio cue fires for the camera subject only.994+ if (r.boost.boosting !== r.wasBoosting) {995+ r.wasBoosting = r.boost.boosting;996+ for (const m of r.materials) {997+ const std = m as THREE.MeshStandardMaterial;998+ if (std.emissive) {999+ std.emissive.setHex(r.boost.boosting ? 0x2255ff : 0x000000);1000+ std.emissiveIntensity = r.boost.boosting ? 0.9 : 1;1001+ }1002+ }1003+ if (r.boost.boosting && r.idx === this.followedIdx) {1004+ this.audio.cue("boost");1005+ }1006+ }1007+9311008 if (r.idx === 0) {9321009 // Hero cues off the station modifier transitions (rule-derived).9331010 const mod = rail?.mods[r.nearest.idx] ?? null;@@ -1075,22 +1152,24 @@ class Session {
10751152 this.assets.getPiece(MARBLE_PIECE);10761153 if (mesh) {10771154 mesh.scale.setScalar(MARBLE_SCALE);1078- if (identity.tint) {1079- const [tr, tg, tb] = identity.tint;1080- mesh.traverse((child: THREE.Object3D) => {1081- const m = child as THREE.Mesh;1082- if (!m.isMesh) return;1083- const tintOne = (mat: THREE.Material): THREE.Material => {1084- const clone = (mat as THREE.MeshStandardMaterial).clone();1085- clone.color.setRGB(tr / 255, tg / 255, tb / 255);1086- materials.push(clone);1087- return clone;1088- };1089- m.material = Array.isArray(m.material)1090- ? m.material.map(tintOne)1091- : tintOne(m.material);1092- });1093- }1155+ // Always clone materials (boost glow needs its own emissive even1156+ // on untinted marbles).1157+ const tint = identity.tint;1158+ mesh.traverse((child: THREE.Object3D) => {1159+ const m = child as THREE.Mesh;1160+ if (!m.isMesh) return;1161+ const cloneOne = (mat: THREE.Material): THREE.Material => {1162+ const clone = (mat as THREE.MeshStandardMaterial).clone();1163+ if (tint) {1164+ clone.color.setRGB(tint[0] / 255, tint[1] / 255, tint[2] / 255);1165+ }1166+ materials.push(clone);1167+ return clone;1168+ };1169+ m.material = Array.isArray(m.material)1170+ ? m.material.map(cloneOne)1171+ : cloneOne(m.material);1172+ });10941173 const box = new THREE.Box3().setFromObject(mesh);10951174 const center = box.getCenter(new THREE.Vector3());10961175 pivot.position.copy(center);@@ -42,6 +42,14 @@ export interface Pilot {
4242 lookGain: number;4343 /** Jump-eagerness: multiplies the max top-up the pilot will attempt. */4444 eagerness: number;45+ /** Boost aggression: scales the attack/defend trigger ranges — when46+ * this pilot chooses to SPEND fuel (drawn last in pilotFor, so adding47+ * it never re-rolled the older traits). */48+ aggro: number;49+ /** Recharge-rate multiplier: identical rates locked every tank into50+ * the same cycle and the pack surged in unison — regen spread turns51+ * the waves into duels. */52+ regen: number;4553 /** Lateral offset target from the rail line (world units; drivers set4654 * this per step from pilotOffset() — 0 rides the line). */4755 offset: number;@@ -56,6 +64,8 @@ export const DEFAULT_PILOT: Pilot = {
5664 bravery: 1,5765 lookGain: 0.55,5866 eagerness: 1,67+ aggro: 1,68+ regen: 1,5969 offset: 0,6070 wanderAmp: 0,6171 phase1: 0,@@ -63,20 +73,97 @@ export const DEFAULT_PILOT: Pilot = {
6373 };6474 6575 /** Seed-stable personality for marble `index` (label-derived stream —66- * adding marbles never perturbs earlier ones). */76+ * adding marbles never perturbs earlier ones; new traits draw AFTER all77+ * older ones so existing personalities stay byte-identical). */6778 export function pilotFor(seed: string, index = 0): Pilot {6879 const rng = rngFamily(seed, `pilot/${index}`);80+ const bravery = 0.92 + rng.float() * 0.18;81+ const lookGain = 0.48 + rng.float() * 0.16;82+ const eagerness = 0.9 + rng.float() * 0.2;83+ const wanderAmp = 0.1 + rng.float() * 0.1;84+ const phase1 = rng.float() * Math.PI * 2;85+ const phase2 = rng.float() * Math.PI * 2;86+ const aggro = 0.7 + rng.float() * 0.6; // appended draw (boost round)87+ const regen = 0.75 + rng.float() * 0.5; // appended draw (boost round)6988 return {70- bravery: 0.92 + rng.float() * 0.18,71- lookGain: 0.48 + rng.float() * 0.16,72- eagerness: 0.9 + rng.float() * 0.2,89+ bravery,90+ lookGain,91+ eagerness,92+ aggro,93+ regen,7394 offset: 0,74- wanderAmp: 0.1 + rng.float() * 0.1,75- phase1: rng.float() * Math.PI * 2,76- phase2: rng.float() * Math.PI * 2,95+ wanderAmp,96+ phase1,97+ phase2,7798 };7899 }79100 101+// --- the boost economy (competition round) ---------------------------------102+//103+// Racing on autopilot needs a resource to SPEND or every outcome is104+// contact luck: each pilot gets a fuel tank that burns in ~3s of boost105+// and recharges in ~12s of restraint. Ignition is a decision — attack a106+// rival ahead, break away from a chaser, or bleed an overflowing tank —107+// gated on envelope HEADROOM (wishspeed stays min(target·mult, vSafe),108+// so a boosting marble still brakes for every corner: safety is109+// unchanged by construction; boost only buys speed where the track110+// permits it). Aggro scales the trigger ranges: personalities differ in111+// WHEN they spend, which is what makes the race a contest.112+113+/** Wishspeed multiplier while boosting. */114+export const BOOST_MULT = 1.55;115+/** Full tank burns in ~3s. */116+const BOOST_DRAIN = 1 / 3;117+/** Empty tank refills in ~12s. */118+const BOOST_RECHARGE = 1 / 12;119+/** Don't ignite below this. Sized for RHYTHM: in a tight pack a rival is120+ * always in range, so pilots ignite the moment they may — a low floor121+ * gave 0.6s sputters at the floor level. Half a tank commits to a122+ * ~1.4s attack with ~6s of visible restraint between burns. */123+const BOOST_MIN_FUEL = 0.5;124+/** Attack range: a rival ahead within aggro·this many units. */125+const BOOST_ATTACK = 8;126+/** Defend range: a chaser behind within aggro·this many units. */127+const BOOST_DEFEND = 4;128+129+export interface BoostState {130+ /** Tank level 0..1. */131+ fuel: number;132+ boosting: boolean;133+}134+135+export const FULL_TANK: BoostState = { fuel: 1, boosting: false };136+137+/**138+ * One fixed-step of the boost economy. `headroom` is vSafe − wishspeed139+ * from the pilot's last force step (PilotDebug) — while boosting, the140+ * envelope capping the boost (headroom ≈ 0) ends the burn; rival gaps141+ * are rail-progress deltas (null = nobody there). Pure and142+ * deterministic; drivers hold the state per racer.143+ */144+export function boostStep(145+ st: BoostState,146+ pilot: Pilot,147+ dt: number,148+ headroom: number | null,149+ gapAhead: number | null,150+ gapBehind: number | null,151+): BoostState {152+ if (st.boosting) {153+ const fuel = Math.max(0, st.fuel - dt * BOOST_DRAIN);154+ const capped = headroom !== null && headroom < 0.1;155+ return { fuel, boosting: fuel > 0.02 && !capped };156+ }157+ const fuel = Math.min(1, st.fuel + dt * BOOST_RECHARGE * pilot.regen);158+ const room = headroom !== null && headroom > 0.8;159+ const attack = gapAhead !== null && gapAhead < BOOST_ATTACK * pilot.aggro;160+ const defend = gapBehind !== null && gapBehind < BOOST_DEFEND * pilot.aggro;161+ const overflow = fuel >= 1;162+ const ignite = fuel >= BOOST_MIN_FUEL && room &&163+ (attack || defend || overflow);164+ return { fuel, boosting: ignite };165+}166+80167 /** Lateral offset target at sim-time t — a slow two-tone wander (closed81168 * form: deterministic under fixed stepping, no draw-order coupling, and82169 * mean-reverting like an OU process without carrying state). Stays well@@ -233,6 +320,8 @@ export function autopilotForce(
233320 pilot: Pilot = DEFAULT_PILOT,234321 dbg?: PilotDebug,235322 others?: readonly Neighbor[],323+ /** Fuel burn (boostStep): raises the target inside the same envelope. */324+ boosting = false,236325 ): [number, number, number] | null {237326 const t = rail.tans[idx];238327 if (!t) return null;@@ -265,7 +354,7 @@ export function autopilotForce(
265354 266355 // --- speed planning: online backward brake sweep over the lookahead ---267356 const sHere = rail.s[idx];268- const targetHere = CRUISE * (mod?.speed ?? 1);357+ const targetHere = CRUISE * (mod?.speed ?? 1) * (boosting ? BOOST_MULT : 1);269358 let vSafe = VMAX;270359 let climbTy = t[1];271360 let prev: readonly [number, number, number] = t;@@ -7,6 +7,10 @@ import { assert, assertEquals } from "@std/assert";
77 import type { Rail } from "../src/client/physics/rail.ts";88 import {99 autopilotForce,10+ BOOST_MULT,11+ boostStep,12+ DEFAULT_PILOT,13+ FULL_TANK,1014 type PilotDebug,1115 wantJump,1216 } from "../src/client/physics/autopilot.ts";@@ -124,6 +128,62 @@ Deno.test("autopilot: airborne control is weak and horizontal", () => {
124128 assert(Math.hypot(...f) <= 5.01, "air authority must be ≤ AIR_ACCEL");125129 });126130 131+Deno.test("boost economy: spend on triggers with headroom, recharge on restraint", () => {132+ const dt = 1 / 60;133+ // Full tank + headroom + a rival ahead → ignition.134+ let st = boostStep({ ...FULL_TANK }, DEFAULT_PILOT, dt, 4, 5, null);135+ assert(st.boosting, "attack trigger with headroom must ignite");136+ // Burning drains at ~1/3 per second…137+ const f0 = st.fuel;138+ st = boostStep(st, DEFAULT_PILOT, dt, 4, 5, null);139+ assert(st.fuel < f0, "burning must drain fuel");140+ // …and the envelope capping the boost ends the burn (corner ahead).141+ st = boostStep({ fuel: 0.8, boosting: true }, DEFAULT_PILOT, dt, 0.05, 5, null);142+ assert(!st.boosting, "a capped envelope must end the burn");143+ // An empty tank ends the burn.144+ st = boostStep({ fuel: 0.01, boosting: true }, DEFAULT_PILOT, dt, 4, 5, null);145+ assert(!st.boosting, "an empty tank must end the burn");146+ // No trigger, tank not full: restraint — recharge, no ignition.147+ st = boostStep({ fuel: 0.5, boosting: false }, DEFAULT_PILOT, dt, 4, 20, 20);148+ assert(!st.boosting && st.fuel > 0.5, "restraint must recharge");149+ // Overflow: a full tank on open track bleeds even without rivals.150+ st = boostStep({ fuel: 1, boosting: false }, DEFAULT_PILOT, dt, 4, null, null);151+ assert(st.boosting, "an overflowing tank must spend");152+ // No headroom (curve ahead): never ignite, whatever the trigger.153+ st = boostStep({ fuel: 1, boosting: false }, DEFAULT_PILOT, dt, 0.2, 2, 1);154+ assert(!st.boosting, "no ignition without envelope headroom");155+ // Below the ignition floor: no sputters.156+ st = boostStep({ fuel: 0.2, boosting: false }, DEFAULT_PILOT, dt, 4, 2, null);157+ assert(!st.boosting, "no ignition below the fuel floor");158+ // A defender close behind triggers too (breakaway).159+ st = boostStep({ fuel: 0.8, boosting: false }, DEFAULT_PILOT, dt, 4, null, 2);160+ assert(st.boosting, "a chaser close behind must trigger the burn");161+});162+163+Deno.test("boost raises the target inside the same envelope", () => {164+ const rail = railOf(flat(40));165+ const dbgOff: PilotDebug = {};166+ const dbgOn: PilotDebug = {};167+ autopilotForce(rail, 10, [5, 0.1, 0], [1, 0, 0], 1, null, undefined, dbgOff);168+ autopilotForce(169+ rail,170+ 10,171+ [5, 0.1, 0],172+ [1, 0, 0],173+ 1,174+ null,175+ undefined,176+ dbgOn,177+ undefined,178+ true,179+ );180+ assert(181+ Math.abs(dbgOn.wishspeed! - dbgOff.wishspeed! * BOOST_MULT) < 1e-9,182+ `boosted wishspeed ${dbgOn.wishspeed} vs base ${dbgOff.wishspeed}`,183+ );184+ assertEquals(dbgOn.vSafe, dbgOff.vSafe); // the envelope is untouched185+});186+127187 /** Gap rail at real jump proportions (M11: 2-world gap, 1.0 drop): 8 flat128188 * approach stations, 3 flight stations descending to the landing 1.0129189 * below, then a flat landing run. */@@ -33,8 +33,12 @@ import {
3333 } from "../src/client/physics/rail.ts";3434 import {3535 autopilotForce,36+ type BoostState,37+ boostStep,38+ FULL_TANK,3639 type Neighbor,3740 type Pilot,41+ type PilotDebug,3842 pilotFor,3943 pilotOffset,4044 wantJump,@@ -70,6 +74,8 @@ interface TestRacer {
7074 nearest: number;7175 s: number;7276 simT: number;77+ boost: BoostState;78+ dbg: PilotDebug;7379 }7480 7581 function race(@@ -111,6 +117,8 @@ function race(
111117 nearest: at,112118 s: rail.s[at],113119 simT: 0,120+ boost: { ...FULL_TANK },121+ dbg: {},114122 });115123 }116124 const mass = engine.massOf(0);@@ -123,8 +131,12 @@ function race(
123131 hash = ((hash ^ q) * FNV) & 0xffffffffffffffffn;124132 };125133 134+ // Stop BEFORE the horizon: racers reaching the rail's end all ratchet135+ // to the same s and "order" degrades to the sort tie-breaker.136+ const endS = rail.s[rail.s.length - 1] - 6;126137 const totalSteps = Math.round(simSeconds * 60);127138 for (let step = 0; step < totalSteps; step++) {139+ if (racers.some((r) => r.s >= endS)) break;128140 engine.step(1 / 60, () => {129141 const states = racers.map((r) => engine.state(r.idx));130142 for (const [ri, r] of racers.entries()) {@@ -146,10 +158,29 @@ function race(
146158 r.simT += 1 / 60;147159 r.pilot.offset = pilotOffset(r.pilot, r.simT);148160 const others: Neighbor[] = [];161+ let gapAhead: number | null = null;162+ let gapBehind: number | null = null;149163 for (const [oi, o] of racers.entries()) {150164 if (o === r || fell[oi]) continue;151165 others.push({ pos: states[oi].pos, bravery: o.pilot.bravery });166+ const d = o.s - r.s;167+ if (d > 0.05) gapAhead = gapAhead === null ? d : Math.min(gapAhead, d);168+ else if (d < -0.05) {169+ gapBehind = gapBehind === null ? -d : Math.min(gapBehind, -d);170+ }152171 }172+ const headroom =173+ r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined174+ ? r.dbg.vSafe - r.dbg.wishspeed175+ : null;176+ r.boost = boostStep(177+ r.boost,178+ r.pilot,179+ 1 / 60,180+ headroom,181+ gapAhead,182+ gapBehind,183+ );153184 const f = autopilotForce(154185 rail,155186 r.nearest,@@ -158,8 +189,9 @@ function race(
158189 mass,159190 null,160191 r.pilot,161- undefined,192+ r.dbg,162193 others,194+ r.boost.boosting,163195 );164196 const gd = stationGravityDelta(rail, r.nearest, mass);165197 const fx = (f?.[0] ?? 0) + (gd?.[0] ?? 0);@@ -34,6 +34,9 @@ import {
3434 } from "../src/client/physics/rail.ts";3535 import {3636 autopilotForce,37+ boostStep,38+ FULL_TANK,39+ type PilotDebug,3740 pilotFor,3841 pilotOffset,3942 wantJump,@@ -113,6 +116,8 @@ function driveWith(
113116 114117 const pilot = pilotFor(seed);115118 let simT = 0;119+ let boost = { ...FULL_TANK };120+ const dbg: PilotDebug = {};116121 let nearest = spawnIdx;117122 let reachedS = rail.s[spawnIdx];118123 let minY = Infinity;@@ -151,6 +156,12 @@ function driveWith(
151156 engine.setRestitution(0, stationRestitution(rail, nearest));152157 simT += 1 / 60;153158 pilot.offset = pilotOffset(pilot, simT);159+ // The boost economy (competition round): a lone marble still bleeds160+ // an overflowing tank on open track — surges are part of the law.161+ const headroom = dbg.vSafe !== undefined && dbg.wishspeed !== undefined162+ ? dbg.vSafe - dbg.wishspeed163+ : null;164+ boost = boostStep(boost, pilot, 1 / 60, headroom, null, null);154165 const f = autopilotForce(155166 rail,156167 nearest,@@ -159,6 +170,9 @@ function driveWith(
159170 engine.mass,160171 null,161172 pilot,173+ dbg,174+ undefined,175+ boost.boosting,162176 );163177 // Gravity zones as per-marble delta forces (M15 — world gravity is164178 // global and fixed).@@ -23,6 +23,9 @@ import {
2323 } from "../../src/client/physics/rail.ts";2424 import {2525 autopilotForce,26+ boostStep,27+ FULL_TANK,28+ type PilotDebug,2629 pilotFor,2730 pilotOffset,2831 wantJump,@@ -66,6 +69,8 @@ let nearest = 0;
6669 let lastS = 0, stall = 0, fell = false;6770 const pilot = pilotFor(Deno.args[0] ?? "cua-seed-1");6871 let simT = 0;72+let boost = { ...FULL_TANK };73+const dbg: PilotDebug = {};6974 for (let step = 0; step < 240 * 60; step++) {7075 engine.step(1 / 60, () => {7176 const p = engine.state().pos;@@ -89,6 +94,10 @@ for (let step = 0; step < 240 * 60; step++) {
8994 if (law === autopilotForce) {9095 simT += 1 / 60;9196 pilot.offset = pilotOffset(pilot, simT);97+ const headroom = dbg.vSafe !== undefined && dbg.wishspeed !== undefined98+ ? dbg.vSafe - dbg.wishspeed99+ : null;100+ boost = boostStep(boost, pilot, 1 / 60, headroom, null, null);92101 f = autopilotForce(93102 rail,94103 nearest,@@ -97,6 +106,9 @@ for (let step = 0; step < 240 * 60; step++) {
97106 engine.mass,98107 null,99108 pilot,109+ dbg,110+ undefined,111+ boost.boosting,100112 );101113 const j = wantJump(rail, nearest, st.pos, st.vel, null, pilot);102114 if (j) engine.jump(0, j[0], j[1]);