From 2e85ec062217c2d3342af6f85208fe0225616f48 Mon Sep 17 00:00:00 2001 From: jing Date: Thu, 16 Jul 2026 16:26:42 +1000 Subject: [PATCH] [lane C] Round 2 WIP (session cut off): schema v2, surf-aware pacing, chase analysis, --probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landed before the cut: schema v2 (segments[].wave.amp override, validated — breathe stays biome-owned); hiatus re-authored tight AND calm (radius back to 6.5, amp 0.6, 3.3u steady clearance); par 210 -> 180 with full rationale (below throttle-only par-pace, above a surf run — the medal teaches the mechanic); finale surge 21 -> 24 so surfing is the stylish escape (required 1.20x); surf profile reads A's live CREST_FACTOR; chaseAnalysis() + escapability laws in validate; NEW --probe builds A's real canal and measures clearance against the model (all L2 segments clear the 2.5 floor, hiatus 3.75); isMain guard + importSpline shim — fixes A's selfcheck process.exit killing C's mid-run (qa.sh had been green on one level out of three). Cut off before: fly-through of the integrated build (blocked on B), pickup economy handover (B proposed numbers in balance.js meanwhile — reconcile), L1 encounter design doc, GDD boss folding, NOTES. Committed by F to protect the shared tree; levels selfcheck 3/3 GREEN. Co-Authored-By: Claude Fable 5 --- web/js/levels/L1_mouth.json | 2 +- web/js/levels/L2_esophagus.json | 12 +- web/js/levels/L3_stomach.json | 2 +- web/js/levels/index.js | 277 +++++++++++++++++++++++++++++--- 4 files changed, 268 insertions(+), 25 deletions(-) diff --git a/web/js/levels/L1_mouth.json b/web/js/levels/L1_mouth.json index 71cee38..0672aff 100644 --- a/web/js/levels/L1_mouth.json +++ b/web/js/levels/L1_mouth.json @@ -1,5 +1,5 @@ { - "schema": 1, + "schema": 2, "id": "L1_mouth", "name": "Oral Cavity", "tagline": "Learn to swim", diff --git a/web/js/levels/L2_esophagus.json b/web/js/levels/L2_esophagus.json index 0480ed3..d84ef8a 100644 --- a/web/js/levels/L2_esophagus.json +++ b/web/js/levels/L2_esophagus.json @@ -1,10 +1,10 @@ { - "schema": 1, + "schema": 2, "id": "L2_esophagus", "name": "Esophagus", "tagline": "Ride the swallow", "seed": 20260716, - "par": { "time": 210, "score": 9000, "samples": 3 }, + "par": { "time": 180, "score": 9000, "samples": 3 }, "next": "L3_stomach", "design": { "owner": "Lane C", @@ -12,6 +12,7 @@ "anatomy": "Real esophagus, real order: upper esophageal sphincter -> cervical -> the broncho-aortic constriction -> thoracic -> diaphragmatic hiatus -> abdominal esophagus -> cardiac sphincter (LES) into the stomach. The three narrowings are the three real anatomical constrictions and they are this level's three setpieces.", "scale": "Cross-sections are ANATOMICALLY TRUE (radius 10-12 units = ~2cm lumen at 1 unit : 1 mm). Lengths are DILATED ~14x (real esophagus is 25cm; this level is 3600 units). The canal is proportioned, not measured. See LANE_C_NOTES.md.", "curve": "breather -> pressure -> SPIKE -> breather -> pressure -> SPIKE -> lethal finale. Pressure indices measured in LANE_C_NOTES.md §L2 pacing.", + "par_rationale": "180s (3:00), retuned in round 2 for the CREST 1.6 surf ruling. Measured: a perfect crest ride is 2:26, throttle-mashing is 2:47, par-pace throttle (1.15x) is 3:19, timid is 4:22. So par 3:00 sits BELOW throttle-only competent play and ABOVE a surf run: you cannot reach it by holding the stick forward, and you can reach it comfortably by learning to surf. That is deliberate - L2's signature mechanic is riding peristalsis crests, and the time medal is the thing that teaches it. Par is a medal threshold, not a pass condition: missing it costs a medal, not the level.", "teach_then_test": "flow (b1) / bolus (b1 solo -> b3 in the pinch) / candida (b2 solo, clear sightline -> b3 in the squeeze) / eosinophil (b2 open stretch -> b6 in the hiatus) / ring_gate (b5 open 2.4s -> b6 open 1.2s) / reflux (b4 non-lethal burp -> b7 lethal finale)." }, "segments": [ @@ -64,10 +65,11 @@ "biome": "esophagus", "name": "Diaphragmatic Hiatus", "length": 400, - "radius": { "base": 6.8, "wobble": 0.15 }, + "radius": { "base": 6.5, "wobble": 0.15 }, "curviness": 0.3, "flow": 18, - "note": "BEAT 6 - SPIKE. The tightest hole in the level: the esophagus threads the diaphragm. Free radius ~2.7 units - the narrowest thing Lane C will author, and only 0.2 above the MIN_CLEARANCE law. Base was 6.5 until Lane A measured the esophagus peristalsis at amp 1.4 (up from the stub's 0.9); the wave eats the difference, so the hole had to widen to stay a challenge instead of a toll. Straightened out on purpose - the challenge is the ring gate's timing, and a writhing tube would make that unreadable." + "wave": { "amp": 0.6 }, + "note": "BEAT 6 - SPIKE, and the level's precision test. The tightest hole in the game: the esophagus threads the diaphragm. TIGHT AND CALM is the whole design - schema v2's wave override (amp 0.6 vs the esophagus's 1.4) exists for this segment. Anatomically the hiatus is a fixed muscular ring held by the diaphragm crura and does NOT have big peristaltic waves; mechanically, a big wave in the smallest gap turned a precision setpiece into a lottery you cannot stop to time. Calm waves let the tube go back to base 6.5 (it was widened to 6.8 in round 1 purely to survive the biome-wide amp) and still leave 3.3 units of steady clearance. Steady-tight is a skill; pulsing-tight is a toll. Straightened out (curviness 0.3) for the same reason: the challenge is the ring gate's timing, and a writhing tube would make that unreadable." }, { "biome": "esophagus", @@ -127,7 +129,7 @@ { "s": 2960, "type": "pickup", "kind": "mucin_glob", "count": 2, "spread": 20, "note": "Last top-up in the level. Take it or regret it for 600 units." }, { "s": 2980, "type": "checkpoint", "name": "Cardia Approach", "note": "The finale's checkpoint. Sits 20 units before the surge trigger: a death in the chase costs you the chase, not the hiatus." }, - { "s": 3000, "type": "hazard", "kind": "reflux_surge", "speed": 21, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true, "note": "THE FINALE. Acid wall, 21 u/s, spawning 40 units behind you and chasing for 600 units. Flow here is 20, so throttle 1.05+ outruns it and throttle 0.6 (12 u/s) kills you: the level's last instruction is 'do not dawdle'. Par pace (~26 u/s) finishes ~5s clear. Antacid fired backward buys ~10s." }, + { "s": 3000, "type": "hazard", "kind": "reflux_surge", "speed": 24, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true, "note": "THE FINALE. Acid wall, 24 u/s, spawning 40 units behind you and chasing for 600 units. Retuned in round 2 for the CREST 1.6 surf ruling: at the old 21 u/s, throttle-max (28) escaped by 7 u/s and surfing (32) was merely a bigger cushion, so the level's signature mechanic was decoration in its own finale. At 24 against flow 20 the required throttle is 1.20x - so the finale reads: dawdling at 12 u/s dies, pushing hard (28) escapes by a white-knuckle ~4.5s, and SURFING (32) escapes clean by ~6s. Surf is now the stylish answer and the level's last exam is its signature verb. Hard mode (surgeSpeed x1.1 = 26.4) needs throttle 1.32+ or a crest; easy (x0.92 = 22.1) forgives at 1.11+. Antacid fired backward still buys ~10s - now the safety net for players who cannot surf rather than the clever trick, which is a better job for it." }, { "s": 3080, "type": "pickup", "kind": "antacid_ammo", "count": 1, "note": "On the racing line, mid-chase: the one pickup in the level you get for free at speed." }, { "s": 3120, "type": "spawn", "enemy": "bolus_chunk", "count": 5, "spread": 200, "note": "Traffic, not enemies. They drift 3.5 u/s slower than the current, so at chase pace you close on them fast." }, { "s": 3200, "type": "pickup", "kind": "biopsy_sample", "count": 1, "theta": 4.7124, "rho": 0.85, "note": "SAMPLE 3/3. Cost = the chase itself. Off the racing line by ~1.5s with an acid wall 5s behind you. The cruellest of the three and the only one that is a real decision at full speed." }, diff --git a/web/js/levels/L3_stomach.json b/web/js/levels/L3_stomach.json index 1086390..bf511e1 100644 --- a/web/js/levels/L3_stomach.json +++ b/web/js/levels/L3_stomach.json @@ -1,5 +1,5 @@ { - "schema": 1, + "schema": 2, "id": "L3_stomach", "name": "Stomach", "tagline": "The acid sea", diff --git a/web/js/levels/index.js b/web/js/levels/index.js index 93403dc..b4c6504 100644 --- a/web/js/levels/index.js +++ b/web/js/levels/index.js @@ -19,8 +19,42 @@ export { applyDifficulty, getDifficulty, DIFFICULTIES, DEFAULT_DIFFICULTY } from const isNode = typeof process !== 'undefined' && !!process.versions?.node; -/** Schema version this module speaks. Levels declare `"schema": N`. */ -export const SCHEMA = 1; +/** + * True only when THIS file is what node was asked to run — not when someone imports it. + * `process.argv.includes('--selfcheck')` is not enough: a module that merely gets imported + * by a process carrying that flag would fire its own CLI (and its own `process.exit`) inside + * its importer. That is not hypothetical — see importSpline(). + */ +const isMain = isNode && !!process.argv[1] && import.meta.filename === process.argv[1]; + +/** + * Import a Lane A world module without letting its CLI guard fire. + * + * Lane A's `spline.js` ends with `if (process.argv.includes('--selfcheck')) selfcheck() + * .then(f => process.exit(...))` — evaluated at import time. So `node levels/index.js + * --selfcheck` importing it for CREST_FACTOR made A's selfcheck run *inside* C's, and A's + * `process.exit(0)` killed C's process after only L1 had been checked: qa.sh went green on a + * gate that had silently validated one level out of three. + * + * Hiding our flags for the duration of the import is the fix available from inside C's own + * territory. The real fix is an entry-point guard in A's file (the `isMain` above) — asked + * for in LANE_C_NOTES.md §→ Lane A. Remove this shim once A lands it. + */ +async function importSpline() { + const saved = process.argv; + try { + if (isNode) process.argv = [saved[0], saved[1]]; + return await import('../world/spline.js'); + } finally { + if (isNode) process.argv = saved; + } +} + +/** Schema version new levels are authored to. Levels declare `"schema": N`. */ +export const SCHEMA = 2; + +/** v2 is additive over v1 (only `segments[].wave` was added), so v1 levels still load. */ +export const SUPPORTED_SCHEMAS = new Set([1, 2]); /** Campaign order. L4/L5/LS land in later rounds; the registry is the source of truth. */ export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach']; @@ -59,12 +93,38 @@ export const BIOMES_EXPECTED = [ 'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix', ]; -/** Throttle profiles for the pacing sim. GDD: throttle gives +/-40% of biome flow. */ -export const PROFILES = Object.freeze({ cautious: 0.85, par: 1.15, speedrun: 1.4 }); +/** + * Speed profiles for the pacing sim, as multiples of the segment's `flow`. + * GDD: throttle gives ±40% of biome flow, so 0.6..1.4 is the whole throttle range. + * + * `surf` is not a throttle — it is riding a peristalsis crest, speed-locked to + * `world.crestSpeed(s) = CREST_FACTOR × flow(s)` (round-2 ruling #1). It is read live from + * A's `spline.js` so C's pars can never drift from A's crest law; 1.6 is only the fallback. + * At 1.6 it beats throttleMax 1.4 by 14%, which is the whole point of the ruling: surfing + * had to stop being strictly dominated by mashing the throttle. + */ +export const PROFILES = Object.freeze({ cautious: 0.85, par: 1.15, speedrun: 1.4, surf: 1.6 }); /** A lethal chase forces the player's hand — no one dawdles in front of an acid wall. */ const CHASE_THROTTLE = 1.35; +/** A's CREST_FACTOR, refreshed by crestFactor(). Fallback = PROFILES.surf. */ +let CREST = PROFILES.surf; + +async function crestFactor() { + try { + const m = await importSpline(); // Lane A's, node-safe by design + if (typeof m.CREST_FACTOR === 'number') CREST = m.CREST_FACTOR; + } catch { /* Lane A not present — keep the fallback */ } + return CREST; +} + +/** Resolve a profile name to a flow multiplier. `surf` tracks A's live crest law. */ +function profileK(name) { + if (name === 'surf') return CREST; + return PROFILES[name] ?? PROFILES.par; +} + // --------------------------------------------------------------------------------------- // loading // --------------------------------------------------------------------------------------- @@ -122,17 +182,18 @@ async function biomeRegistry() { if (m.BIOMES && Object.keys(m.BIOMES).length) { REG = { ids: new Set(Object.keys(m.BIOMES)), - // Mirror A's waveMaxAt() exactly: amp + breathe, not amp alone. - marginFor: (id) => { - const w = m.BIOMES[id]?.wave; - return w ? w.amp + (w.breathe ?? 0) : PLAY.PERISTALSIS_AMP; - }, + ampFor: (id) => m.BIOMES[id]?.wave?.amp ?? PLAY.PERISTALSIS_AMP, + breatheFor: (id) => m.BIOMES[id]?.wave?.breathe ?? 0, authoritative: true, }; return REG; } } catch { /* Lane A hasn't landed yet — fall back to C's expectation */ } - REG = { ids: new Set(BIOMES_EXPECTED), marginFor: () => PLAY.PERISTALSIS_AMP, authoritative: false }; + REG = { + ids: new Set(BIOMES_EXPECTED), + ampFor: () => PLAY.PERISTALSIS_AMP, breatheFor: () => 0, + authoritative: false, + }; return REG; } @@ -194,9 +255,25 @@ export function scoreBudget(level) { return total; } -/** What the wall eats into the lumen — Lane A's per-biome (amp + breathe) when available. */ +/** + * What the wall eats into the lumen at its worst, mirroring A's `waveMaxAt` = amp + breathe. + * + * **Schema v2:** `segments[].wave.amp` overrides the biome's amplitude for this segment + * (round-2 ruling #8, A implements `waveAmpAt` preferring it — `spline.js` GET.waveAmp). + * `breathe` is NOT overridable: it is idle tissue motion, a property of the tissue, and A + * reads it from the biome registry regardless. + * + * Why the override exists: the diaphragmatic hiatus is a fixed muscular ring held by the + * diaphragm crura — anatomically it does *not* have big peristaltic waves. But it is also + * L2's tightest hole, so a biome-wide amplitude put the game's biggest wave in its smallest + * gap, and the level's precision setpiece became a wave-luck lottery you cannot even stop to + * time. Tight AND calm is the design; v2 is how it gets said. + */ function waveMargin(seg) { - return REG?.marginFor ? REG.marginFor(seg.biome) : PLAY.PERISTALSIS_AMP; + const amp = (typeof seg.wave?.amp === 'number') + ? seg.wave.amp + : (REG?.ampFor ? REG.ampFor(seg.biome) : PLAY.PERISTALSIS_AMP); + return amp + (REG?.breatheFor ? REG.breatheFor(seg.biome) : 0); } /** Static free-flight radius of a segment at its narrowest wobble trough. */ @@ -264,7 +341,7 @@ export function travelTime(level, a, b, k) { * hazard = 1 + a per-kind term (see hazardWeight) */ export function pace(level, { profile = 'par' } = {}) { - const k = PROFILES[profile] ?? PROFILES.par; + const k = profileK(profile); const total = totalLength(level); const widest = Math.max(...level.segments.map((s) => s.radius.base)); @@ -301,6 +378,38 @@ export function pace(level, { profile = 'par' } = {}) { return { id: level.id, profile, total, beats, checkpoints: gaps, seconds: tAcc }; } +/** + * The escape math for every chase hazard. This is the finale's whole design in one number: + * `required` = the throttle multiple you must hold to merely hold the gap (surge / flow). + * + * Read it against the player's options: throttle tops out at 1.4, surfing a crest gives + * CREST_FACTOR (1.6, A's law). So required 1.2 means "push hard, or surf"; required > 1.4 + * means "surf or use antacid, throttle alone cannot save you"; required > CREST means + * unwinnable. `clear` is units of daylight at the end of the span (head start + ground + * gained), i.e. how close it actually feels. + */ +export function chaseAnalysis(level) { + const out = []; + for (const e of level.events ?? []) { + if (e.type !== 'hazard' || e.kind !== 'reflux_surge') continue; + const { seg } = segmentAt(level, e.s); + const span = e.span ?? 0; + const head = Math.abs(e.from ?? 0); + const at = (k) => { + const v = seg.flow * k; + const t = v > 0 ? span / v : 0; + const clear = head + (v - e.speed) * t; + return { k, v, t, clear, escapes: clear > 0 }; + }; + out.push({ + s: e.s, lethal: !!e.lethal, surge: e.speed, flow: seg.flow, span, head, + required: e.speed / seg.flow, + throttleMax: at(1.4), surfing: at(CREST), parPace: at(Math.max(PROFILES.par, CHASE_THROTTLE)), + }); + } + return out; +} + function hazardWeight(h) { switch (h.kind) { case 'aortic_squeeze': return 1 + (h.amplitude ?? 0); @@ -330,7 +439,7 @@ export async function validate(level, { expectId = null } = {}) { const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m)); // --- structural ----------------------------------------------------------------- - if (level.schema !== SCHEMA) E(`schema is ${level.schema}, this module speaks ${SCHEMA}`); + if (!SUPPORTED_SCHEMAS.has(level.schema)) E(`schema is ${level.schema}, this module speaks ${[...SUPPORTED_SCHEMAS].join('/')} (authoring v${SCHEMA})`); if (typeof level.id !== 'string' || !level.id) E('missing id'); if (expectId && level.id !== expectId) E(`id "${level.id}" does not match filename "${expectId}"`); if (!Number.isInteger(level.seed)) E(`seed must be an integer (got ${JSON.stringify(level.seed)})`); @@ -344,6 +453,7 @@ export async function validate(level, { expectId = null } = {}) { if (!authoritative) W(`Lane A's world/biomes.js not present — biome ids checked against Lane C's expected list (${BIOMES_EXPECTED.length} ids)`); const bal = await combatBalance(); if (!bal) W("Lane B's combat/balance.js not present — archetype rows and score budget unchecked"); + await crestFactor(); // so par/chase checks use A's live CREST_FACTOR, not our fallback // --- segments ------------------------------------------------------------------- let s0 = 0; @@ -357,6 +467,16 @@ export async function validate(level, { expectId = null } = {}) { if (seg.curviness < 0 || seg.curviness > 1) E(`${at}: curviness must be in [0,1] (got ${seg.curviness})`); if (!(seg.flow >= 0)) E(`${at}: flow must be >= 0`); + // schema v2: segments[].wave.amp override (A reads it as spline.js GET.waveAmp) + if (seg.wave !== undefined) { + if (level.schema < 2) E(`${at}: segments[].wave needs "schema": 2`); + if (typeof seg.wave.amp !== 'number' || !(seg.wave.amp > 0)) E(`${at}: wave.amp must be a number > 0`); + else if (seg.wave.amp > 3) W(`${at}: wave.amp ${seg.wave.amp} is huge — that is a wall that breathes at you, not peristalsis`); + if (seg.wave.breathe !== undefined) W(`${at}: wave.breathe is not overridable — breathe is a property of the tissue and A reads it from the biome registry. Ignored.`); + const biomeAmp = REG?.ampFor ? REG.ampFor(seg.biome) : PLAY.PERISTALSIS_AMP; + if (seg.wave.amp === biomeAmp) W(`${at}: wave.amp ${seg.wave.amp} equals the biome default — the override says nothing, drop it`); + } + // C's playability invariant — the check that catches "I made a cool tube you can't fly". const clear = hazardClearance(level, seg, s0); if (clear < PLAY.MIN_CLEARANCE) { @@ -435,15 +555,28 @@ export async function validate(level, { expectId = null } = {}) { if (secs > 30) C(`checkpoint gap ${cp.s}..${next} ("${cp.name ?? ''}") = ${secs.toFixed(1)}s at par throttle — over the 30s death-cost law`); } + // chase hazards must be escapable, and the escape must cost something (ruling #1) + for (const c of chaseAnalysis(level)) { + const where = `reflux_surge@${c.s} (surge ${c.surge} vs flow ${c.flow})`; + if (!c.lethal) continue; + if (c.required > CREST) + E(`${where}: requires throttle ${c.required.toFixed(2)}× — above even a crest ride (${CREST}×). Unwinnable.`); + else if (c.required > 1.4) + W(`${where}: requires ${c.required.toFixed(2)}× — throttle alone (max 1.4×) cannot escape; only surfing or antacid can. Deliberate?`); + else if (c.required <= 1.0) + C(`${where}: requires only ${c.required.toFixed(2)}× — the current alone outruns it, so the chase is theatre`); + if (!c.surfing.escapes) E(`${where}: not escapable even while surfing — check span/from`); + } + const samples = (level.events ?? []).filter((e) => e.type === 'pickup' && e.kind === 'biopsy_sample') .reduce((a, e) => a + (e.count ?? 1), 0); if (samples !== 3) C(`${samples} biopsy samples — the GDD says exactly 3 per level`); // par sanity: an unbeatable par is a bug, a free par is a waste. if (level.par?.time > 0) { - const fast = pace(level, { profile: 'speedrun' }).seconds; + const fast = pace(level, { profile: 'surf' }).seconds; const slow = pace(level, { profile: 'cautious' }).seconds; - if (level.par.time < fast) E(`par.time ${level.par.time}s is faster than a full-throttle run (${fast.toFixed(0)}s) — unachievable`); + if (level.par.time < fast) E(`par.time ${level.par.time}s is faster than a perfect crest ride (${fast.toFixed(0)}s at ${CREST}× flow) — unachievable`); else if (level.par.time > slow) C(`par.time ${level.par.time}s is slower than a timid run (${slow.toFixed(0)}s) — free medal`); } else C('no par.time'); @@ -495,12 +628,15 @@ async function cliSelfcheck() { async function cliSim(only) { const reg = await biomeRegistry(); // so clearance uses Lane A's real wave amplitudes const bal = await combatBalance(); // so the score budget uses Lane B's real scores + const crest = await crestFactor(); // so surf pacing uses Lane A's real crest law console.log(`biome registry: ${reg.authoritative ? "Lane A's world/biomes.js" : 'Lane C fallback list (Lane A not present)'}`); console.log(`combat balance: ${bal ? "Lane B's combat/balance.js" : 'not present (score budget unavailable)'}`); + console.log(`crest law: CREST_FACTOR ${crest}× flow${crest === PROFILES.surf ? '' : " (Lane A's spline.js)"}`); for (const id of CAMPAIGN) { if (only && id !== only) continue; const level = await loadJSON(`./${id}.json`); const p = pace(level, { profile: 'par' }); + const surf = pace(level, { profile: 'surf' }).seconds; const fast = pace(level, { profile: 'speedrun' }).seconds; const slow = pace(level, { profile: 'cautious' }).seconds; const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`; @@ -509,7 +645,12 @@ async function cliSim(only) { const budget = scoreBudget(level); console.log(`${p.total} units · par.time ${level.par?.time ?? '?'}s (${mmss(level.par?.time ?? 0)}) · par.score ${level.par?.score ?? '?'}` + (budget != null ? ` of ${budget} kill budget (${((level.par.score / budget) * 100) | 0}%)` : '')); - console.log(`sim: speedrun ${mmss(fast)} (${fast.toFixed(0)}s) · par ${mmss(p.seconds)} (${p.seconds.toFixed(0)}s) · cautious ${mmss(slow)} (${slow.toFixed(0)}s)`); + console.log(`sim: surf ${mmss(surf)} (${surf.toFixed(0)}s) · speedrun ${mmss(fast)} (${fast.toFixed(0)}s) · par ${mmss(p.seconds)} (${p.seconds.toFixed(0)}s) · cautious ${mmss(slow)} (${slow.toFixed(0)}s)`); + const verdict = level.par?.time == null ? '' + : level.par.time < fast ? ' → par REQUIRES surfing (throttle alone misses it)' + : level.par.time < p.seconds ? ' → par requires pushing above par-pace' + : ' → par is reachable at par-pace throttle'; + if (verdict) console.log(`par ${level.par.time}s vs surf ${surf.toFixed(0)} / throttle ${fast.toFixed(0)} / par-pace ${p.seconds.toFixed(0)}${verdict}`); const maxP = Math.max(...p.beats.map((b) => b.pressure), 0.001); console.log('\n beat s-range len flow en dens tight haz PRESSURE clear t cum'); for (const b of p.beats) { @@ -523,6 +664,23 @@ async function cliSim(only) { const totEn = p.beats.reduce((a, b) => a + b.enemies, 0); console.log(` ${'TOTAL'.padEnd(26)} ${String(0).padStart(4)}-${String(p.total).padStart(4)} ${String(p.total).padStart(5)} ${'-'.padStart(4)} ${String(totEn).padStart(3)}`); + const chases = chaseAnalysis(level); + if (chases.length) { + console.log('\n chase hazards — "required" = throttle multiple needed to hold the gap'); + console.log(' (throttle tops out at 1.40; surfing a crest = ' + CREST.toFixed(2) + ')'); + for (const c of chases) { + const tag = c.lethal ? 'LETHAL' : 'teach '; + const answer = c.required > CREST ? 'UNWINNABLE' + : c.required > 1.4 ? 'surf/antacid ONLY' + : c.required <= 1.0 ? 'theatre (flow outruns it)' + : 'push hard, or surf'; + console.log(` ${tag} s=${String(c.s).padStart(4)} surge ${String(c.surge).padStart(4)} vs flow ${String(c.flow).padStart(2)} ` + + `→ required ${c.required.toFixed(2)}× ${answer}`); + console.log(` clearance at the gate: throttle-max ${c.throttleMax.clear.toFixed(0)}u (${(c.throttleMax.clear / c.throttleMax.v).toFixed(1)}s) · ` + + `surfing ${c.surfing.clear.toFixed(0)}u (${(c.surfing.clear / c.surfing.v).toFixed(1)}s)`); + } + } + if (p.checkpoints.length) { console.log('\n checkpoint gaps (death cost at par throttle; law: <= 30s)'); for (const g of p.checkpoints) { @@ -533,7 +691,90 @@ async function cliSim(only) { } } -if (isNode && (process.argv.includes('--selfcheck') || process.argv.includes('--sim'))) { +/** + * `--probe` — measure Lane A's REAL canal and check C's model against it. + * + * The pacing sim is a model of the world; this builds the actual thing (A's spline.js is + * node-safe by design) and reports measured radius/clearance along s. If C's model and A's + * world ever disagree, every par and every clearance in this module is fiction — so the + * disagreement should be a number on a screen, not a surprise in round 4. + */ +async function cliProbe(only) { + let buildSpline, createRngFn; + try { + ({ buildSpline } = await importSpline()); + ({ createRng: createRngFn } = await import('../core/rng.js')); + } catch (err) { + console.error(`probe: Lane A's world/spline.js not available — ${err.message}`); + return false; + } + await biomeRegistry(); + await crestFactor(); + + for (const id of CAMPAIGN) { + if (only && id !== only) continue; + const level = await loadJSON(`./${id}.json`); + if (isSkeleton(level)) { console.log(`\n=== ${id}: skeleton, skipped ===`); continue; } + + // A's index.js fills segments[].wave from the biome registry before spline sees them. + const fed = { + ...level, + segments: level.segments.map((s) => ({ + ...s, + wave: { amp: s.wave?.amp ?? (REG.ampFor ? REG.ampFor(s.biome) : PLAY.PERISTALSIS_AMP) }, + })), + }; + const spline = buildSpline(fed, createRngFn(level.seed)); + const st = spline.stats ? spline.stats() : null; + + console.log(`\n=== ${id} — PROBE of Lane A's real canal ===`); + console.log(`spline length ${spline.length.toFixed(1)} (authored ${totalLength(level)}) · hash ${spline.hash?.() ?? '?'}`); + if (st) console.log(`minRadius ${st.minRadius.toFixed(2)} · maxRadius ${st.maxRadius.toFixed(2)} · pinchRatio ${st.pinchRatio.toFixed(2)} (A's guard: >1.5)`); + + // measured clearance along s, vs C's model + console.log('\n segment s-range model MEASURED min@s verdict'); + let s0 = 0; + for (const seg of level.segments) { + const s1 = s0 + seg.length; + const model = hazardClearance(level, seg, s0); + let worst = Infinity, worstS = s0; + for (let s = s0; s <= s1; s += 1) { + const clear = spline.radiusAt(s) - waveMargin(seg) - PLAY.SKIN - PLAY.SHIP_R; + if (clear < worst) { worst = clear; worstS = s; } + } + const bad = worst < PLAY.MIN_CLEARANCE; + console.log( + ` ${(seg.name ?? seg.biome).padEnd(26).slice(0, 26)} ${String(s0).padStart(4)}-${String(s1).padStart(4)} ` + + `${model.toFixed(2).padStart(6)} ${worst.toFixed(2).padStart(9)} ${String(worstS).padStart(7)} ` + + `${bad ? 'UNDER FLOOR ' + PLAY.MIN_CLEARANCE : 'ok'}`); + s0 = s1; + } + + // taper profile across the authored joins — C's constrictions must narrow, not step + const joins = []; + let acc = 0; + for (let i = 0; i < level.segments.length - 1; i++) { + acc += level.segments[i].length; + if (level.segments[i].radius.base !== level.segments[i + 1].radius.base) joins.push(acc); + } + if (joins.length) { + console.log('\n radius taper across joins (A blends; a step here would read as a level seam)'); + for (const j of joins) { + const pts = [-40, -25, -12, 0, 12, 25, 40].map((d) => `${d >= 0 ? '+' : ''}${d}:${spline.radiusAt(j + d).toFixed(2)}`); + console.log(` s=${String(j).padStart(4)} ${pts.join(' ')}`); + } + } + } + return true; +} + +if (isMain && process.argv.includes('--probe')) { + const i = process.argv.indexOf('--probe'); + const ok = await cliProbe(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]); + if (!process.argv.includes('--selfcheck') && !process.argv.includes('--sim')) process.exit(ok ? 0 : 1); +} + +if (isMain && (process.argv.includes('--selfcheck') || process.argv.includes('--sim'))) { if (process.argv.includes('--sim')) { const i = process.argv.indexOf('--sim'); await cliSim(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);