// levels/index.js (Lane C) — level registry, loader, schema selfcheck, pacing simulator. // // Runs in BOTH the browser (boot.js: `import('./levels/index.js').getLevel(id)`) and node // (`node web/js/levels/index.js --selfcheck`, called by tools/qa.sh). That dual life is why // this file imports no THREE and touches no DOM: it is data and arithmetic, nothing else. // // node web/js/levels/index.js --selfcheck validate every registered level (qa gate) // node web/js/levels/index.js --sim pacing report for every level // node web/js/levels/index.js --sim L2_esophagus pacing report for one level // // Schema of record: docs/TECH.md §Level data schema v1 (Lane C owns it; changes go through // LANE_C_NOTES.md + F referee, never silently). import { ENEMIES, getEnemy, ARCHETYPES, PICKUPS, HAZARDS } from './enemies.js'; import { applyDifficulty, getDifficulty, DEFAULT_DIFFICULTY } from './difficulty.js'; export { ENEMIES, getEnemy, ARCHETYPES, PICKUPS, HAZARDS }; export { applyDifficulty, getDifficulty, DIFFICULTIES, DEFAULT_DIFFICULTY } from './difficulty.js'; const isNode = typeof process !== 'undefined' && !!process.versions?.node; /** * 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']; /** Booting with no ?lvl= gives you the vertical slice. Lane F: see LANE_C_NOTES §→ Lane F. */ export const DEFAULT_LEVEL = 'L2_esophagus'; /** * Playability constants — Lane C's model of "is there room to fly here". * * These MIRROR the other lanes rather than assert anything: A's world/index.js computes * `wallRho = radiusAt(s) - waveMaxAt(s) - SKIN` with `waveMaxAt = wave.amp + wave.breathe` * (per-biome, read live from A's registry — see waveMargin()), and B's flight/tuning.js * sets the hull collision sphere to 0.9. Only MIN_CLEARANCE is Lane C's own call. * * If A or B move their numbers, this module notices on the next qa run and the levels get * re-tuned — which is not hypothetical: A raised the esophagus amp 0.9 -> 1.4 during round 1 * and it cost the diaphragmatic hiatus 0.5 units of flyable radius. See LANE_C_NOTES.md. */ export const PLAY = Object.freeze({ PERISTALSIS_AMP: 0.9, // fallback only, used when A's registry is unreachable (stub value) SKIN: 0.6, // collision safety margin — matches A's world/index.js SKIN SHIP_R: 0.9, // ENDO-1 collision sphere — from B's flight/tuning.js ship.radius MIN_CLEARANCE: 2.5, // C'S LAW: the tightest point must still leave a 2.5-unit free disc // (~2.8x the hull's collision radius). Below that a constriction // stops being a challenge and becomes a toll you simply pay. }); /** * Fallback biome list, used only when Lane A's registry cannot be loaded. * Mirrors world/biomes.js §Canonical ids. Lane A's registry is AUTHORITATIVE when present — * biome = the visual/tissue family, `segments[].name` carries the anatomy. That division is * why C needs no 'cardia'/'pylorus'/'pharynx' ids: those are names, not looks. */ export const BIOMES_EXPECTED = [ 'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix', ]; /** * 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 // --------------------------------------------------------------------------------------- const cache = new Map(); async function loadJSON(rel) { const url = new URL(rel, import.meta.url); // resolves against THIS file in node + browser if (isNode) { const { readFile } = await import('node:fs/promises'); return JSON.parse(await readFile(url, 'utf8')); } const res = await fetch(url); if (!res.ok) throw new Error(`level fetch failed: ${rel} (${res.status})`); return res.json(); } /** * → the level object for `id` (defaults to the vertical slice), validated. * Throws on unknown id or on validation errors — a broken level must never reach the world. */ export async function getLevel(id = DEFAULT_LEVEL, { difficulty = DEFAULT_DIFFICULTY } = {}) { const key = id ?? DEFAULT_LEVEL; if (!CAMPAIGN.includes(key)) throw new Error(`unknown level "${key}" (have: ${CAMPAIGN.join(', ')})`); if (!cache.has(key)) cache.set(key, loadJSON(`./${key}.json`)); const level = await cache.get(key); const { errors } = await validate(level, { expectId: key }); if (errors.length) throw new Error(`level "${key}" failed validation:\n - ${errors.join('\n - ')}`); return applyDifficulty(level, difficulty); } /** → [{ id, name, tagline, length, par }] for menus (Lane E). */ export async function listLevels() { const out = []; for (const id of CAMPAIGN) { const l = await loadJSON(`./${id}.json`); out.push({ id, name: l.name, tagline: l.tagline, length: totalLength(l), par: l.par, skeleton: isSkeleton(l) }); } return out; } /** * Lane A's biome registry, or C's fallback. Cached module-level so the sync clearance * helpers can read wave amplitudes without every call site going async. * We consume A's `wave.amp` rather than assuming the stub's 0.9: peristalsis amplitude is * per-biome (small_intestine is 1.1), and it is subtracted from the flyable radius, so a * level that is comfortable in the esophagus could be a wall in the ileum. */ let REG = null; async function biomeRegistry() { if (REG) return REG; try { const m = await import('../world/biomes.js'); // Lane A's, when it lands if (m.BIOMES && Object.keys(m.BIOMES).length) { REG = { ids: new Set(Object.keys(m.BIOMES)), 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), ampFor: () => PLAY.PERISTALSIS_AMP, breatheFor: () => 0, authoritative: false, }; return REG; } // --------------------------------------------------------------------------------------- // geometry / pacing helpers (shared by validate + pace) // --------------------------------------------------------------------------------------- export function totalLength(level) { return level.segments.reduce((a, s) => a + s.length, 0); } /** → the segment containing s, plus its start offset. */ export function segmentAt(level, s) { let s0 = 0; for (const seg of level.segments) { if (s < s0 + seg.length || seg === level.segments[level.segments.length - 1]) return { seg, s0 }; s0 += seg.length; } return { seg: level.segments[0], s0: 0 }; } const isSkeleton = (level) => /^SKELETON/.test(level.design?.status ?? ''); /** * Lane B's combat balance, or false. Same guarded-import pattern as the biome registry: * Lane C's registry CONSUMES the other lanes' contracts and owns only composition, so * score lives in exactly one place (B's balance.js, keyed by archetype) and this module * reads it rather than restating it. */ let BAL = null; async function combatBalance() { if (BAL !== null) return BAL; try { const m = await import('../combat/balance.js'); // Lane B's, when it lands if (m.BALANCE?.enemies) { BAL = m.BALANCE; return BAL; } } catch { /* Lane B hasn't landed yet */ } BAL = false; return BAL; } /** → the score Lane B awards for killing `enemyId`, or null if B's balance isn't loaded. */ export function scoreFor(enemyId) { const spec = getEnemy(enemyId); if (!spec || !BAL) return null; return BAL.enemies?.[spec.archetype]?.score ?? null; } /** → total points available from kills, or null. Used to sanity-check par.score. */ export function scoreBudget(level) { if (!BAL) return null; let total = 0; for (const e of level.events ?? []) { if (e.type !== 'spawn') continue; const s = scoreFor(e.enemy); if (s == null) return null; total += s * (e.count ?? 1); } return total; } /** * 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) { 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. */ function staticClearance(seg) { const r = seg.radius.base * (1 - (seg.radius.wobble ?? 0)); return r - waveMargin(seg) - PLAY.SKIN - PLAY.SHIP_R; } /** * Clearance including span hazards that eat into the tube. * A one-sided squeeze (aortic) does NOT halve the tube: it bites `amplitude*R` out of one * arc, so the largest circle you can still fly through is wallRho - intrusion/2. * A ring_gate is exempt — it seals on purpose and is a timing gate, not a corridor. */ function hazardClearance(level, seg, s0) { let clear = staticClearance(seg); const rStatic = seg.radius.base * (1 - (seg.radius.wobble ?? 0)); for (const e of level.events) { if (e.type !== 'hazard' || !e.span) continue; const overlaps = e.s < s0 + seg.length && e.s + e.span > s0; if (!overlaps) continue; if (e.kind === 'aortic_squeeze') { const intrusion = (e.amplitude ?? 0) * rStatic; clear = Math.min(clear, staticClearance(seg) - intrusion / 2); } } return clear; } /** How fast the player is actually moving at s, under a throttle profile. */ function speedAt(level, s, k) { const { seg } = segmentAt(level, s); let throttle = k; for (const e of level.events) { if (e.type === 'hazard' && e.kind === 'reflux_surge' && e.lethal && e.span && s >= e.s && s <= e.s + e.span) throttle = Math.max(throttle, CHASE_THROTTLE); } return Math.max(0.1, seg.flow * throttle); } /** Seconds to travel [a, b] under profile k. Numeric integration; 2-unit steps. */ export function travelTime(level, a, b, k) { const STEP = 2; let t = 0; for (let s = a; s < b; s += STEP) { const ds = Math.min(STEP, b - s); t += ds / speedAt(level, s + ds / 2, k); } return t; } // --------------------------------------------------------------------------------------- // the pacing simulator — Lane C's evidence (you cannot screenshot data) // --------------------------------------------------------------------------------------- /** * Models the level as a kinematic ride and reports the encounter curve. * This is a MODEL, not a playtest: it assumes a player who holds a constant throttle and is * never slowed by combat (true-ish in tube mode — the current carries you and you cannot * stop). Treat times as lower bounds on a real run and pressure as a relative index only. * * pressure = density x tightness x hazard, where * density = enemies per 100 units in the segment * tightness = widest segment radius / this segment radius (how little room you have) * hazard = 1 + a per-kind term (see hazardWeight) */ export function pace(level, { profile = 'par' } = {}) { const k = profileK(profile); const total = totalLength(level); const widest = Math.max(...level.segments.map((s) => s.radius.base)); const beats = []; let s0 = 0, tAcc = 0; for (const seg of level.segments) { const s1 = s0 + seg.length; const inSeg = (e) => e.s >= s0 && e.s < s1; const enemies = level.events.filter((e) => e.type === 'spawn' && inSeg(e)) .reduce((a, e) => a + (e.count ?? 1), 0); const hazards = level.events.filter((e) => e.type === 'hazard' && inSeg(e)); const density = (enemies / seg.length) * 100; const tightness = widest / seg.radius.base; const hazard = hazards.reduce((a, h) => a * hazardWeight(h), 1); const dt = travelTime(level, s0, s1, k); tAcc += dt; beats.push({ name: seg.name ?? seg.biome, s0, s1, length: seg.length, flow: seg.flow, enemies, density, tightness, hazard, pressure: density * tightness * hazard, clearance: hazardClearance(level, seg, s0), seconds: dt, cumulative: tAcc, hazardKinds: hazards.map((h) => h.kind), }); s0 = s1; } const checkpoints = level.events.filter((e) => e.type === 'checkpoint'); const gaps = checkpoints.map((cp, i) => { const next = checkpoints[i + 1]?.s ?? total; return { from: cp.s, to: next, name: cp.name ?? '', units: next - cp.s, seconds: travelTime(level, cp.s, next, PROFILES.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); case 'ring_gate': return 1 + (1 - (h.open ?? 1) / (h.period ?? 1)); case 'reflux_surge': return h.lethal ? 1.8 : 1.15; default: return 1.1; } } // --------------------------------------------------------------------------------------- // validate — the schema selfcheck (qa.sh gate) // --------------------------------------------------------------------------------------- const TAU = Math.PI * 2; /** * → { errors, warnings }. Errors block qa; warnings are advisory. * Skeleton levels (design.status starts "SKELETON") downgrade CONTENT rules (pacing, * samples, checkpoint coverage) to warnings — a level that has not been designed yet must * not fail the gate for not being designed yet. STRUCTURAL rules always apply. */ export async function validate(level, { expectId = null } = {}) { const errors = [], warnings = []; const E = (m) => errors.push(m); const W = (m) => warnings.push(m); const skel = isSkeleton(level); const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m)); // --- structural ----------------------------------------------------------------- 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)})`); if (typeof level.name !== 'string' || !level.name) E('missing name'); if (!Array.isArray(level.segments) || !level.segments.length) { E('segments must be a non-empty array'); return { errors, warnings }; } if (!Array.isArray(level.events)) E('events must be an array'); if (!Array.isArray(level.arenas)) E('arenas must be an array'); const total = totalLength(level); const { ids: biomes, authoritative } = await biomeRegistry(); 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; for (const [i, seg] of level.segments.entries()) { const at = `segments[${i}] "${seg.name ?? seg.biome}"`; if (!biomes.has(seg.biome)) E(`${at}: unknown biome "${seg.biome}"`); if (!(seg.length > 0)) E(`${at}: length must be > 0`); if (!(seg.radius?.base > 0)) E(`${at}: radius.base must be > 0`); const wob = seg.radius?.wobble ?? 0; if (wob < 0 || wob >= 1) E(`${at}: radius.wobble must be in [0,1) (got ${wob})`); 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) { E(`${at}: free radius ${clear.toFixed(2)} < MIN_CLEARANCE ${PLAY.MIN_CLEARANCE} ` + `(base ${seg.radius.base}, wobble ${wob}, minus wave ${waveMargin(seg).toFixed(2)} + skin ${PLAY.SKIN} + hull ${PLAY.SHIP_R}` + `${clear < staticClearance(seg) ? ' + span hazard' : ''}) — unflyable`); } s0 += seg.length; } // --- events --------------------------------------------------------------------- let prev = -Infinity; for (const [i, e] of (level.events ?? []).entries()) { const at = `events[${i}] ${e.type}@${e.s}`; if (typeof e.s !== 'number') { E(`${at}: missing numeric s`); continue; } if (e.s < prev) E(`${at}: events must be sorted by s (previous was ${prev})`); prev = e.s; if (e.s < 0 || e.s > total) E(`${at}: s outside level (0..${total})`); if (e.span != null && e.s + e.span > total) E(`${at}: span runs past the end of the level`); const thetas = Array.isArray(e.theta) ? e.theta : (e.theta != null ? [e.theta] : []); for (const t of thetas) if (!(t >= 0 && t < TAU)) E(`${at}: theta ${t} outside [0, 2pi)`); if (e.rho != null && !(e.rho >= 0 && e.rho <= 1)) E(`${at}: rho ${e.rho} outside [0,1] (rho is a FRACTION of the safe radius, not a distance)`); switch (e.type) { case 'spawn': { const spec = getEnemy(e.enemy); if (!spec) { E(`${at}: unknown enemy "${e.enemy}" (not in enemies.js catalogue)`); break; } if (!ARCHETYPES.includes(spec.archetype)) E(`${at}: enemy "${e.enemy}" has archetype "${spec.archetype}" which Lane B does not implement (${ARCHETYPES.join('/')})`); else if (bal && !bal.enemies?.[spec.archetype]) E(`${at}: Lane B's balance.js has no row for archetype "${spec.archetype}" — the contract drifted`); if (spec.placeholder) W(`${at}: spawns bare archetype "${e.enemy}" — placeholder with no fiction; give it a real catalogue entry`); if (spec.wall && !thetas.length) E(`${at}: "${e.enemy}" is wall-mounted and needs an explicit theta`); if (Array.isArray(e.theta) && e.theta.length !== (e.count ?? 1)) E(`${at}: theta array has ${e.theta.length} entries but count is ${e.count ?? 1}`); break; } case 'pickup': if (!PICKUPS[e.kind]) E(`${at}: unknown pickup kind "${e.kind}"`); break; case 'hazard': { const h = HAZARDS[e.kind]; if (!h) { E(`${at}: unknown hazard kind "${e.kind}"`); break; } for (const p of h.needs) if (e[p] == null) E(`${at}: hazard "${e.kind}" requires "${p}"`); if (e.kind === 'ring_gate') { const duty = (e.open ?? 0) / (e.period ?? 1); if (duty < 0.3) W(`${at}: ring_gate open only ${(duty * 100) | 0}% of the time — below C's 30% fairness floor`); } if (e.warn != null && e.warn < 2 && e.lethal !== false) W(`${at}: warn ${e.warn}s is under the 2s telegraph law for lethal hazards`); break; } case 'checkpoint': case 'boss': case 'gate': break; default: W(`${at}: unknown event type "${e.type}" — B/E will ignore it`); } } // --- arenas --------------------------------------------------------------------- for (const [i, a] of (level.arenas ?? []).entries()) { const at = `arenas[${i}]`; if (!(a.radius > 0)) E(`${at}: radius must be > 0`); if (a.at - a.radius < 0 || a.at + a.radius > total) E(`${at}: sphere (at ${a.at} r ${a.radius}) spans ${a.at - a.radius}..${a.at + a.radius}, outside 0..${total}`); if (a.biome && !biomes.has(a.biome)) E(`${at}: unknown biome "${a.biome}"`); } // --- content laws (downgraded for skeletons) ------------------------------------ const cps = (level.events ?? []).filter((e) => e.type === 'checkpoint'); for (const e of level.events ?? []) { if (e.type === 'boss' && !cps.some((c) => c.s <= e.s && e.s - c.s < 200)) C(`boss "${e.id}"@${e.s} has no checkpoint within 200 units before it`); if (e.type === 'hazard' && e.lethal && !cps.some((c) => c.s <= e.s && e.s - c.s < 120)) C(`lethal hazard "${e.kind}"@${e.s} has no checkpoint within 120 units before it`); } // death costs <= 30s of progress (charter). Measured at par throttle. for (const [i, cp] of cps.entries()) { const next = cps[i + 1]?.s ?? total; const secs = travelTime(level, cp.s, next, PROFILES.par); 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: '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 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'); if (level.par?.score > 0 && (level.events ?? []).some((e) => e.type === 'spawn')) { const budget = scoreBudget(level); if (budget != null && level.par.score > budget) W(`par.score ${level.par.score} exceeds the kill budget ${budget} (Lane B's scores x C's counts) — only reachable via pickups and combo multipliers`); } // gate wiring for (const e of level.events ?? []) { if (e.type === 'gate' && e.to && !CAMPAIGN.includes(e.to)) W(`gate "${e.id}" points at "${e.to}" which is not in the campaign registry yet`); } if (level.next && !CAMPAIGN.includes(level.next)) W(`next "${level.next}" is not in the campaign registry yet`); return { errors, warnings }; } // --------------------------------------------------------------------------------------- // CLI (node only — guarded so importing this in the browser has zero side effects) // --------------------------------------------------------------------------------------- function bar(v, max, width = 22) { const n = Math.max(0, Math.min(width, Math.round((v / max) * width))); return '#'.repeat(n) + '.'.repeat(width - n); } async function cliSelfcheck() { let bad = 0, warned = 0; for (const id of CAMPAIGN) { let level; try { level = await loadJSON(`./${id}.json`); } catch (err) { console.error(` x ${id}: cannot load — ${err.message}`); bad++; continue; } const { errors, warnings } = await validate(level, { expectId: id }); const tag = isSkeleton(level) ? ' (skeleton)' : ''; if (errors.length) { bad++; console.error(` x ${id}${tag}: ${errors.length} error(s)`); for (const e of errors) console.error(` ERROR ${e}`); } else { console.log(` . ${id}${tag}: ok — ${level.segments.length} segments, ${totalLength(level)} units, ${level.events.length} events`); } for (const w of warnings) { warned++; console.log(` warn ${w}`); } } console.log(`levels selfcheck: ${CAMPAIGN.length - bad}/${CAMPAIGN.length} valid, ${warned} warning(s)`); return bad === 0; } 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')}`; console.log(`\n=== ${id} — ${level.name}${isSkeleton(level) ? ' [SKELETON]' : ''} ===`); 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: 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) { console.log( ` ${(b.name ?? '').padEnd(26).slice(0, 26)} ${String(b.s0).padStart(4)}-${String(b.s1).padStart(4)} ` + `${String(b.length).padStart(5)} ${String(b.flow).padStart(4)} ${String(b.enemies).padStart(3)} ` + `${b.density.toFixed(2).padStart(5)} ${b.tightness.toFixed(2).padStart(5)} ${b.hazard.toFixed(2).padStart(4)} ` + `${b.pressure.toFixed(2).padStart(6)} ${bar(b.pressure, maxP, 10)} ${b.clearance.toFixed(2).padStart(5)} ` + `${b.seconds.toFixed(0).padStart(4)}s ${mmss(b.cumulative).padStart(5)}`); } 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) { const flag = g.seconds > 30 ? ' <-- OVER' : ''; console.log(` ${String(g.from).padStart(4)} -> ${String(g.to).padStart(4)} ${String(g.units).padStart(4)}u ${g.seconds.toFixed(1).padStart(5)}s ${g.name}${flag}`); } } } } /** * `--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]); } if (process.argv.includes('--selfcheck')) { const ok = await cliSelfcheck(); process.exit(ok ? 0 : 1); } }