1/**2 * Autopilot contract tests (M14a/M14b) — rule-based, no wasm: the control3 * law and the jump decision are pure functions of (rail, state), so their4 * contracts are asserted directly on synthetic rails.5 */6import { assert, assertAlmostEquals, assertEquals } from "@std/assert";7import type { Rail } from "../src/client/physics/rail.ts";8import { racerIdentity, rivalFor } from "../src/client/peersCore.ts";9import {10 autopilotForce,11 BOOST_MULT,12 boostStep,13 braidLaneAlt,14 DEFAULT_PILOT,15 DRAFT_GAIN,16 DRAFT_MAX,17 DRAFT_MIN,18 DRAFT_REFILL,19 draftFactor,20 FULL_TANK,21 type Pilot,22 type PilotDebug,23 pilotFor,24 rubberMod,25 TEMPER_WINDOW,26 temperHeat,27 temperStep,28 wantJump,29} from "../src/client/physics/autopilot.ts";3031/** Synthetic rail builder: stations every 0.5 along +x from the origin,32 * with per-station y and mods. */33function railOf(34 ys: number[],35 mods: (Rail["mods"][number])[] = [],36): Rail {37 const rail: Rail = { pts: [], tans: [], s: [], mods: [] };38 let s = 0;39 for (let i = 0; i < ys.length; i++) {40 const dy = (ys[i + 1] ?? ys[i]) - ys[i];41 const l = Math.hypot(0.5, dy) || 1;42 rail.pts.push([i * 0.5, ys[i], 0]);43 rail.tans.push([0.5 / l, dy / l, 0]);44 rail.s.push(s);45 rail.mods.push(mods[i] ?? null);46 s += l;47 }48 return rail;49}5051const flat = (n: number): number[] => new Array(n).fill(0);5253Deno.test("autopilot: momentum is sacred — no force between wishspeed and the envelope", () => {54 const rail = railOf(flat(40));55 // Rolling at 5 on a flat straight: above cruise 3.2, below VMAX 9 —56 // the old law would brake to target; the autopilot must coast.57 const f = autopilotForce(rail, 10, [5, 0.1, 0], [5, 0, 0], 1);58 assertEquals(f, null);59});6061Deno.test("autopilot: brakes above the swept envelope before a tight bend", () => {62 // Flat run into a hard 90° yaw bend (r ≈ 1): stations turn MAX yaw.63 const rail: Rail = { pts: [], tans: [], s: [], mods: [] };64 let s = 0;65 let x = 0, z = 0;66 for (let i = 0; i < 40; i++) {67 const a = Math.min(Math.PI / 2, Math.max(0, (i - 20) * 0.5));68 rail.pts.push([x, 0, z]);69 rail.tans.push([Math.cos(a), 0, Math.sin(a)]);70 rail.s.push(s);71 rail.mods.push(null);72 x += Math.cos(a) * 0.5;73 z += Math.sin(a) * 0.5;74 s += 0.5;75 }76 // Arriving hot (8.5) with the bend inside the sweep window: brake.77 const dbg: PilotDebug = {};78 const f = autopilotForce(79 rail,80 14,81 [rail.pts[14][0], 0.1, rail.pts[14][2]],82 [8.5, 0, 0],83 1,84 null,85 undefined,86 dbg,87 );88 assert(dbg.mode === "brake", `expected brake, got ${dbg.mode}`);89 assert(f !== null && f[0] < 0, "brake force must oppose travel");90});9192Deno.test("autopilot: climb authority exceeds along-slope gravity (emergent carry)", () => {93 // 0.5-per-station climb (ty ≈ 0.45, the steepest slant family) with the94 // marble slowed to 1.0 — the deficit-driven authority must beat95 // g·ty ≈ 9 so the marble climbs instead of cycling at the base.96 const ys: number[] = [];97 for (let i = 0; i < 30; i++) ys.push(i < 10 ? 0 : (i - 10) * 0.25);98 const rail = railOf(ys);99 const idx = 14; // mid-climb100 const t = rail.tans[idx];101 const f = autopilotForce(102 rail,103 idx,104 [rail.pts[idx][0], rail.pts[idx][1] + 0.1, 0],105 [t[0] * 1.0, t[1] * 1.0, 0],106 1,107 );108 assert(f !== null, "climb must produce force");109 const along = f[0] * t[0] + f[1] * t[1] + f[2] * t[2];110 assert(111 along > -(-20) * t[1],112 `authority ${along.toFixed(1)} must exceed slope gravity ${113 (20 * t[1]).toFixed(1)114 }`,115 );116});117118Deno.test("autopilot: icy grip caps both response and ceiling", () => {119 const rail = railOf(flat(40), flat(40).map(() => ({ icy: true })));120 const dry = railOf(flat(40));121 const fIcy = autopilotForce(rail, 10, [5, 0.1, 0], [0.5, 0, 0], 1);122 const fDry = autopilotForce(dry, 10, [5, 0.1, 0], [0.5, 0, 0], 1);123 assert(fIcy !== null && fDry !== null);124 const aIcy = Math.hypot(...fIcy);125 const aDry = Math.hypot(...fDry);126 assert(127 aIcy < aDry * 0.45,128 `icy authority ${aIcy.toFixed(1)} must be a sliver of dry ${129 aDry.toFixed(1)130 }`,131 );132});133134Deno.test("autopilot: airborne control is weak and horizontal", () => {135 const rail = railOf(flat(40));136 // 1.2 above the rail floor: airborne — only the small air accel,137 // horizontal only.138 const f = autopilotForce(rail, 10, [5, 1.2, 0], [3, 2, 0], 1);139 assert(f !== null);140 assertEquals(f[1], 0);141 assert(Math.hypot(...f) <= 5.01, "air authority must be ≤ AIR_ACCEL");142});143144Deno.test("autopilot: crest lookahead never thrusts into the floor", () => {145 // Ramp up then immediately down: the lookahead point sits BELOW the146 // marble past the crest. Pursuit must steer yaw only — a 3D seek aimed147 // the pilot into the crest face (accelerate-toward-floor jitter).148 const ys: number[] = [];149 for (let i = 0; i < 30; i++) {150 ys.push(i < 10 ? i * 0.25 : Math.max(0, (20 - i) * 0.25));151 }152 const rail = railOf(ys);153 // Mid-climb two stations before the crest, rolling up-slope at speed —154 // the lookahead lands well down the far side.155 const idx = 8;156 const t = rail.tans[idx];157 const pos: [number, number, number] = [158 rail.pts[idx][0],159 rail.pts[idx][1] + 0.1,160 0,161 ];162 const f = autopilotForce(rail, idx, pos, [t[0] * 3, t[1] * 3, 0], 1);163 assert(f !== null, "climb must produce force");164 // The force may press along the slope but never predominantly DOWN165 // into the floor: its downward part must stay below its horizontal166 // magnitude (a 3D seek gave a dominant −y here).167 const horiz = Math.hypot(f[0], f[2]);168 assert(169 f[1] > -horiz,170 `force aims into the floor: fy=${f[1].toFixed(2)} vs horiz=${171 horiz.toFixed(2)172 }`,173 );174});175176Deno.test("boost economy: spend on triggers with headroom, recharge on restraint", () => {177 const dt = 1 / 60;178 // Full tank + headroom + a rival ahead → ignition.179 let st = boostStep({ ...FULL_TANK }, DEFAULT_PILOT, dt, 4, 5, null);180 assert(st.boosting, "attack trigger with headroom must ignite");181 // Burning drains at ~1/3 per second…182 const f0 = st.fuel;183 st = boostStep(st, DEFAULT_PILOT, dt, 4, 5, null);184 assert(st.fuel < f0, "burning must drain fuel");185 // …and the envelope capping the boost ends the burn (corner ahead).186 st = boostStep(187 { fuel: 0.8, boosting: true },188 DEFAULT_PILOT,189 dt,190 0.05,191 5,192 null,193 );194 assert(!st.boosting, "a capped envelope must end the burn");195 // An empty tank ends the burn.196 st = boostStep({ fuel: 0.01, boosting: true }, DEFAULT_PILOT, dt, 4, 5, null);197 assert(!st.boosting, "an empty tank must end the burn");198 // No trigger, tank not full: restraint — recharge, no ignition.199 st = boostStep({ fuel: 0.5, boosting: false }, DEFAULT_PILOT, dt, 4, 20, 20);200 assert(!st.boosting && st.fuel > 0.5, "restraint must recharge");201 // Overflow: a full tank on open track bleeds even without rivals.202 st = boostStep(203 { fuel: 1, boosting: false },204 DEFAULT_PILOT,205 dt,206 4,207 null,208 null,209 );210 assert(st.boosting, "an overflowing tank must spend");211 // No headroom (curve ahead): never ignite, whatever the trigger.212 st = boostStep({ fuel: 1, boosting: false }, DEFAULT_PILOT, dt, 0.2, 2, 1);213 assert(!st.boosting, "no ignition without envelope headroom");214 // Below the ignition floor: no sputters.215 st = boostStep({ fuel: 0.2, boosting: false }, DEFAULT_PILOT, dt, 4, 2, null);216 assert(!st.boosting, "no ignition below the fuel floor");217 // A defender close behind triggers too (breakaway).218 st = boostStep({ fuel: 0.8, boosting: false }, DEFAULT_PILOT, dt, 4, null, 2);219 assert(st.boosting, "a chaser close behind must trigger the burn");220});221222Deno.test("boost raises the target inside the same envelope", () => {223 const rail = railOf(flat(40));224 const dbgOff: PilotDebug = {};225 const dbgOn: PilotDebug = {};226 autopilotForce(rail, 10, [5, 0.1, 0], [1, 0, 0], 1, null, undefined, dbgOff);227 autopilotForce(228 rail,229 10,230 [5, 0.1, 0],231 [1, 0, 0],232 1,233 null,234 undefined,235 dbgOn,236 undefined,237 true,238 );239 assert(240 Math.abs(dbgOn.wishspeed! - dbgOff.wishspeed! * BOOST_MULT) < 1e-9,241 `boosted wishspeed ${dbgOn.wishspeed} vs base ${dbgOff.wishspeed}`,242 );243 assertEquals(dbgOn.vSafe, dbgOff.vSafe); // the envelope is untouched244});245246/** Gap rail at real jump proportions (M11: 2-world gap, 1.0 drop): 8 flat247 * approach stations, 3 flight stations descending to the landing 1.0248 * below, then a flat landing run. */249function gapRail(): Rail {250 const ys: number[] = [];251 const mods: (Rail["mods"][number])[] = [];252 for (let i = 0; i < 8; i++) {253 ys.push(0);254 mods.push(null);255 }256 // The dive begins INSIDE the flight window (real jump rails follow the257 // reversed-slant piece), so the lip station's tangent stays flat.258 for (const y of [0, -0.5, -1]) {259 ys.push(y);260 mods.push({ flight: true });261 }262 for (let i = 0; i < 8; i++) {263 ys.push(-1);264 mods.push(null);265 }266 return railOf(ys, mods);267}268269Deno.test("rubber-banding: skill modulation, never thrust", () => {270 const base: Pilot = pilotFor("rubber-seed", 3);271 const out = { ...DEFAULT_PILOT };272273 // Mid-pack (or lone racer) is exactly neutral.274 rubberMod(base, 0.5, out);275 assertEquals(out.bravery, base.bravery);276 assertEquals(out.eagerness, base.eagerness);277278 // Trailing: braver corners + earlier jump commits, monotone in the279 // deficit; leading: conservative by the same law.280 rubberMod(base, 1, out);281 const trailBrave = out.bravery;282 const trailEager = out.eagerness;283 assert(trailBrave > base.bravery);284 assert(trailEager > base.eagerness);285 rubberMod(base, 0, out);286 assert(out.bravery < base.bravery);287 assert(out.eagerness < base.eagerness);288289 // Capped: personality still dominates (≤ ±8% corners, ≤ ±25% jumps).290 assert(trailBrave <= base.bravery * 1.08 + 1e-12);291 assert(trailEager <= base.eagerness * 1.25 + 1e-12);292293 // Out-of-range positions clamp; other traits copy through untouched.294 rubberMod(base, 7, out);295 assertEquals(out.bravery, trailBrave);296 assertEquals(out.lookGain, base.lookGain);297 assertEquals(out.boostMult, base.boostMult);298 // The base pilot is never mutated.299 assertEquals(base, pilotFor("rubber-seed", 3));300});301302Deno.test("wantJump: fires only at the lip, only on a rescuable undershoot", () => {303 const rail = gapRail();304 const lip = 7;305 const lipPos: [number, number, number] = [306 rail.pts[lip][0],307 rail.pts[lip][1] + 0.1,308 0,309 ];310 // Moderate undershoot (4.2 vs the priced ≈6.1): the flat arc falls311 // short of the landing → top-up, aimed to make the landing exactly.312 const j = wantJump(rail, lip, lipPos, [4.2, 0, 0], null);313 assert(j !== null, "undershooting lip arrival must jump");314 const [n, target] = j;315 assert(n[1] > 0.99, "approach is flat — normal ≈ up");316 const land = rail.pts.findIndex((_, i) => i > lip && !rail.mods[i]?.flight);317 const G = Math.hypot(rail.pts[land][0] - lipPos[0], -lipPos[2]);318 const tF = G / 4.2;319 const yAt = target * n[1] * tF - 10 * tF * tF;320 assert(321 yAt >= rail.pts[land][1] - lipPos[1] - 1e-6,322 `topped-up arc lands at ${yAt.toFixed(2)}, below the landing`,323 );324325 // Away from the lip: silent (there is still floor under the arc).326 assertEquals(wantJump(rail, lip - 3, lipPos, [4.2, 0, 0], null), null);327 // Priced arrival: the flight works — silent.328 assertEquals(wantJump(rail, lip, lipPos, [6.1, 0, 0], null), null);329 // Already rising (jumped/bounced): once only — silent.330 assertEquals(wantJump(rail, lip, lipPos, [4.2, 1.5, 0], null), null);331 // Airborne: silent.332 assertEquals(333 wantJump(rail, lip, [lipPos[0], 0.8, 0], [4.2, 0, 0], null),334 null,335 );336 // Hopeless (creeping — the needed top-up exceeds JUMP_MAX): silent;337 // the catch pan or the respawn net owns it.338 assertEquals(wantJump(rail, lip, lipPos, [1.2, 0, 0], null), null);339});340341// ---------------------------------------------------------------------------342// M20a A0 — the appended-draw regression lock.343//344// tests/personality_snapshot.json was captured BEFORE the M20 trait345// appends. The comparison walks ONLY the fixture's keys: appending new346// draws after the old ones keeps every fixture field byte-identical347// (test green); any in-stream insert or reorder moves them (test red).348// Update the fixture ONLY by regenerating it from a commit where the349// old fields are known-good — never by hand.350// ---------------------------------------------------------------------------351352Deno.test("appended-draw lock: pilots, identities and lane draws are frozen", async () => {353 const snapshot = JSON.parse(354 await Deno.readTextFile(355 new URL("./personality_snapshot.json", import.meta.url),356 ),357 ) as Record<string, unknown>;358 const cases: [string, number][] = [359 ["snap-a", 0],360 ["snap-a", 7],361 ["marbles", 3],362 ["wide-scan-13", 41],363 ["det-seed", 128],364 ];365 for (const [seed, i] of cases) {366 const pilot = pilotFor(seed, i) as unknown as Record<string, unknown>;367 const want = snapshot[`pilot/${seed}/${i}`] as Record<string, unknown>;368 for (const key of Object.keys(want)) {369 assertEquals(370 pilot[key],371 want[key],372 `pilotFor(${seed}, ${i}).${key} moved — in-stream draw change`,373 );374 }375 assertEquals(376 [0, 5, 12].map((s) => braidLaneAlt(seed, i, s)),377 snapshot[`lane/${seed}/${i}`],378 `braidLaneAlt(${seed}, ${i}) raw draws moved`,379 );380 const identity = racerIdentity(seed, i) as unknown as Record<381 string,382 unknown383 >;384 const wantId = snapshot[`identity/${seed}/${i}`] as Record<385 string,386 unknown387 >;388 for (const key of Object.keys(wantId)) {389 assertEquals(390 identity[key],391 wantId[key],392 `racerIdentity(${seed}, ${i}).${key} moved`,393 );394 }395 }396});397398// ---------------------------------------------------------------------------399// M20a — drafting, lane personality, trait bands.400// ---------------------------------------------------------------------------401402Deno.test("draftFactor: window, falloff, wake scaling", () => {403 // Outside the window: no bonus.404 assertEquals(draftFactor(null, 1), 1);405 assertEquals(draftFactor(0.8, 1), 1); // contact/separation owns < DRAFT_MIN406 assertEquals(draftFactor(5.0, 1), 1);407 assertEquals(draftFactor(9, 1.4), 1);408 // At the window's near edge: peak bonus, scaled by wake.409 const peak = draftFactor(DRAFT_MIN, 1);410 assert(Math.abs(peak - (1 + DRAFT_GAIN)) < 1e-9, `peak ${peak}`);411 assert(412 Math.abs(draftFactor(DRAFT_MIN, 0.6) - (1 + DRAFT_GAIN * 0.6)) < 1e-9,413 );414 // Monotone falloff across the window.415 let prev = peak;416 for (let g = DRAFT_MIN + 0.2; g < DRAFT_MAX; g += 0.2) {417 const f = draftFactor(g, 1);418 assert(f <= prev + 1e-12 && f >= 1, `non-monotone at ${g}`);419 prev = f;420 }421});422423Deno.test("draft raises the target inside the same envelope (never past vSafe)", () => {424 const rail = railOf(flat(40));425 const dbgOff: PilotDebug = {};426 const dbgOn: PilotDebug = {};427 autopilotForce(rail, 10, [5, 0.1, 0], [1, 0, 0], 1, null, undefined, dbgOff);428 autopilotForce(429 rail,430 10,431 [5, 0.1, 0],432 [1, 0, 0],433 1,434 null,435 undefined,436 dbgOn,437 undefined,438 false,439 1.1,440 );441 assert(442 Math.abs(dbgOn.wishspeed! - dbgOff.wishspeed! * 1.1) < 1e-9,443 `drafted wishspeed ${dbgOn.wishspeed} vs base ${dbgOff.wishspeed}`,444 );445 assertEquals(dbgOn.vSafe, dbgOff.vSafe); // the envelope is untouched446 // An absurd multiplier is still capped by the envelope.447 const dbgCap: PilotDebug = {};448 autopilotForce(449 rail,450 10,451 [5, 0.1, 0],452 [1, 0, 0],453 1,454 null,455 undefined,456 dbgCap,457 undefined,458 false,459 10,460 );461 assert(462 dbgCap.wishspeed! <= dbgCap.vSafe! + 1e-9,463 `wishspeed ${dbgCap.wishspeed} exceeded vSafe ${dbgCap.vSafe}`,464 );465});466467Deno.test("braid lane threshold: same draw, monotone in pref, per-racer independent", () => {468 for (let i = 0; i < 30; i++) {469 // pref 0 never takes the alt lane; pref 1 always does; monotone: a470 // racer alt at some pref stays alt at any higher pref (single draw471 // compared against a moving threshold).472 assert(!braidLaneAlt("lane-seed", i, 4, 0));473 assert(braidLaneAlt("lane-seed", i, 4, 1));474 let was = false;475 for (const pref of [0.2, 0.35, 0.5, 0.65, 0.8]) {476 const now = braidLaneAlt("lane-seed", i, 4, pref);477 assert(!was || now, `racer ${i}: lane flipped back as pref rose`);478 was = now;479 }480 }481 // Default threshold matches the historical coin exactly.482 for (let i = 0; i < 30; i++) {483 assertEquals(484 braidLaneAlt("lane-seed", i, 7),485 braidLaneAlt("lane-seed", i, 7, 0.5),486 );487 }488});489490Deno.test("M20a traits: appended draws land in their bands", () => {491 for (let i = 0; i < 40; i++) {492 const p = pilotFor("band-seed", i);493 assert(p.wake >= 0.6 && p.wake <= 1.4, `wake ${p.wake}`);494 assert(p.lanePref >= 0.35 && p.lanePref <= 0.65, `lanePref ${p.lanePref}`);495 assert(p.chir === -1 || p.chir === 1, `chir ${p.chir}`);496 }497});498499// ---------------------------------------------------------------------------500// M20b — temper, brake style, rivalries.501// ---------------------------------------------------------------------------502503/** The tight-bend rail from the brake test, reused for style checks. */504function bendRail(): Rail {505 const rail: Rail = { pts: [], tans: [], s: [], mods: [] };506 let s = 0;507 let x = 0, z = 0;508 for (let i = 0; i < 40; i++) {509 const a = Math.min(Math.PI / 2, Math.max(0, (i - 20) * 0.5));510 rail.pts.push([x, 0, z]);511 rail.tans.push([Math.cos(a), 0, Math.sin(a)]);512 rail.s.push(s);513 rail.mods.push(null);514 x += Math.cos(a) * 0.5;515 z += Math.sin(a) * 0.5;516 s += 0.5;517 }518 return rail;519}520521Deno.test("temperStep/temperHeat: contact opens a decaying window, cool = identity", () => {522 // A contact arms the full window; re-contact re-arms, never accumulates.523 let clock = temperStep(0, 1 / 30, true);524 assertEquals(clock, TEMPER_WINDOW);525 clock = temperStep(clock, 1, false);526 assertEquals(clock, TEMPER_WINDOW - 1);527 assertEquals(temperStep(clock, 1 / 30, true), TEMPER_WINDOW);528 // Decays to exactly zero and stays there.529 clock = temperStep(0.05, 1, false);530 assertEquals(clock, 0);531 // Heat: identity when cool or temperless; hot scales aggro > bravery.532 assertEquals(temperHeat(0, 1), { aggro: 1, bravery: 1 });533 assertEquals(temperHeat(2, 0), { aggro: 1, bravery: 1 });534 const hot = temperHeat(2, 1);535 assertEquals(hot.aggro, 1.5);536 assertEquals(hot.bravery, 1.08);537 const mild = temperHeat(2, 0.5);538 assert(mild.aggro < hot.aggro && mild.aggro > 1);539});540541Deno.test("brakeStyle: late-brakers carry more speed at entry, brake still catches", () => {542 const rail = bendRail();543 const early: Pilot = { ...DEFAULT_PILOT, brakeStyle: 0.8 };544 const late: Pilot = { ...DEFAULT_PILOT, brakeStyle: 1.35 };545 const dbgE: PilotDebug = {};546 const dbgL: PilotDebug = {};547 const pos: [number, number, number] = [rail.pts[14][0], 0.1, rail.pts[14][2]];548 autopilotForce(rail, 14, pos, [8.5, 0, 0], 1, null, early, dbgE);549 autopilotForce(rail, 14, pos, [8.5, 0, 0], 1, null, late, dbgL);550 // The late-braker PLANS a deeper entry (higher swept envelope) …551 assert(552 dbgL.vSafe! > dbgE.vSafe!,553 `late vSafe ${dbgL.vSafe} must exceed early ${dbgE.vSafe}`,554 );555 // … and both still detect the bend and brake when hot above it.556 assert(dbgE.mode === "brake" && dbgL.mode === "brake");557 // Trait band keeps the planned decel deliverable: 6·1.4 < 10 (the558 // A_BRAKE/A_BRAKE_MAX envelope-safety inequality, pinned here so a559 // future band widening trips it).560 for (let i = 0; i < 40; i++) {561 const p = pilotFor("style-seed", i);562 assert(p.brakeStyle >= 0.8 && p.brakeStyle <= 1.35 + 1e-9);563 assert(6 * p.brakeStyle < 10, "planned decel must stay deliverable");564 assert(p.temper >= 0 && p.temper <= 1);565 }566});567568Deno.test("rivalFor: directed, label-pure, self-free, deterministic", () => {569 // Deterministic and independent: racer i's relationship never moves570 // with pack composition beyond the target mod, and never self-targets.571 for (const n of [2, 5, 16, 128]) {572 for (let i = 0; i < Math.min(n, 24); i++) {573 const a = rivalFor("rival-seed", i, n);574 const b = rivalFor("rival-seed", i, n);575 assertEquals(a, b);576 if (a) {577 assert(a.target !== i, `racer ${i} self-rival at n=${n}`);578 assert(a.target >= 0 && a.target < n);579 assert(a.intensity >= 0.5 && a.intensity <= 1);580 assert(a.valence === "grudge" || a.valence === "ally");581 }582 }583 }584 // Solo pack: nobody to fixate on.585 assertEquals(rivalFor("rival-seed", 0, 1), null);586 // Coverage ~50%, grudges dominate ~70/30 across a corpus.587 let has = 0;588 let grudges = 0;589 for (let i = 0; i < 300; i++) {590 const r = rivalFor("corpus-seed", i, 300);591 if (r) {592 has++;593 if (r.valence === "grudge") grudges++;594 }595 }596 assert(has > 100 && has < 200, `coverage ${has}/300 off the 50% law`);597 assert(598 grudges / has > 0.55 && grudges / has < 0.85,599 `grudge share ${(grudges / has).toFixed(2)} off the 70/30 law`,600 );601});602603// ---------------------------------------------------------------------------604// M22 — slipstream economy.605// ---------------------------------------------------------------------------606607Deno.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});