guts/web/js/levels/index.js
jing 1b5ec767cd [lane C] Round 1: schema v1, levels registry + selfcheck + pacing sim, L2 vertical slice
Data spine:
- Schema v1 ratified (TECH.md, C-owned). Additive over v0: schema/par/next, segment
  name, cross-section placement (theta/rho as a FRACTION of safe radius), spread, span,
  pickup+gate event types, hazard params. STUB_LEVEL synced (that const only, per
  ROUND1 §Lane C.1) — world.hash() unchanged (1bdf001), geometry provably untouched.
- levels/index.js: registry + loader (node+browser, no THREE), real schema selfcheck
  (qa's --selfcheck hook is now live) and a pacing simulator (--sim). Consumes the other
  lanes' contracts live rather than restating them: A's biomes.js for ids and
  wave.amp+breathe, A's SKIN, B's tuning.js hull radius, B's balance.js for scores.
- levels/enemies.js (fiction id -> archetype join table), levels/difficulty.js.

L2_esophagus — the vertical slice, complete: 3600 units, 7 beats, 47 events, 65 enemies,
10 checkpoints, 3 samples, reflux-surge finale. L1/L3 skeletons (shape only).
Anatomy is the level designer: the three real esophageal constrictions are the three
setpieces, and all three enemies are real esophageal pathology (bolus chunk, eosinophilic
esophagitis, candida esophagitis).

Measured (node web/js/levels/index.js --selfcheck|--sim; qa GREEN):
- selfcheck 3/3 valid, exit 0.
- pressure curve 0.27 -> 2.14 -> 4.35 -> 0.69 -> 4.53 -> 6.35 -> 4.40 (breather/pressure/
  spike/breather/pressure/peak/finale).
- par 3:30 vs measured 2:47 speedrun / 3:19 par-pace / 4:22 timid; par.score 9000 of a
  9400 kill budget (95%), computed from B's scores.
- all 10 checkpoint gaps within the <=30s death-cost law (max 28.8s); min clearance 2.73.

Two contract collisions found and resolved (LANE_C_NOTES):
- Biome ids: authored mouth/pharynx/cardia/pylorus; A's registry has six canonical ids and
  rejected all four. A is right — biome = look, segments[].name = anatomy. Conformed.
- Balance: my first enemies.js restated hp/size/speed/score that B owns in balance.js.
  Cut to a join table; index.js reads B's scores via scoreFor().

Caught live: A raised esophagus wave.amp 0.9 -> 1.4 mid-session, making the diaphragmatic
hiatus unflyable (2.33 vs the 2.5 floor). Widened 6.5 -> 6.8. The invariant also failed my
own aortic constriction's first draft (radius 7, symmetric squeeze) -> radius 9, directional.

No in-engine flythrough: ?stub=1&lvl= does not compose (snippet offered to F) and the stub
is single-segment, so it could only render beat 1. The pacing evidence is a kinematic model,
not a playtest — it proves L2 is flyable, paced and survivable, not that it feels good.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:53:34 +10:00

546 lines
26 KiB
JavaScript

// 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;
/** Schema version this module speaks. Levels declare `"schema": N`. */
export const SCHEMA = 1;
/** 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',
];
/** 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 });
/** A lethal chase forces the player's hand — no one dawdles in front of an acid wall. */
const CHASE_THROTTLE = 1.35;
// ---------------------------------------------------------------------------------------
// 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)),
// 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;
},
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 };
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 — Lane A's per-biome (amp + breathe) when available. */
function waveMargin(seg) {
return REG?.marginFor ? REG.marginFor(seg.biome) : PLAY.PERISTALSIS_AMP;
}
/** 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 = PROFILES[profile] ?? PROFILES.par;
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 };
}
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 (level.schema !== SCHEMA) E(`schema is ${level.schema}, this module speaks ${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");
// --- 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`);
// 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`);
}
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 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`);
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
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)'}`);
for (const id of CAMPAIGN) {
if (only && id !== only) continue;
const level = await loadJSON(`./${id}.json`);
const p = pace(level, { profile: 'par' });
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: speedrun ${mmss(fast)} (${fast.toFixed(0)}s) · par ${mmss(p.seconds)} (${p.seconds.toFixed(0)}s) · cautious ${mmss(slow)} (${slow.toFixed(0)}s)`);
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)}`);
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}`);
}
}
}
}
if (isNode && (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);
}
}