// 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); } }