guts/web/js/levels/index.js
type-two 3c059f20db [lane B+C+F] The biofilm gate: the reputation cash-out, "it helps you in level 5" made real
The deeper payoff of the flora loop. A new `biofilm` level event: cross one and combat checks
BIOME STANDING — high enough (>= need), the native flora PART the biofilm, budding a wave of phage
allies + refilling coat, then spending the goodwill; too low, it stays sealed and you run the
gauntlet raw. Emits `biofilm {opened}` so E can voice both.

- combat/index.js: biofilmGate(ev) on the level:event pump (fields need/allies/coat).
- levels/index.js: the selfcheck knows the `biofilm` type now (validates need 0..100).
- L2: one demoed at s2500 — the opening flora/akkermansia/beacon let you BANK standing, and the
  gate cashes it into a phage escort right before the hiatus + reflux finale.

The mechanism is complete; L5 also gets the fast-route/Appendix branch when that level + world
support land. Verified in-engine: high standing -> opened, 3 phages, coat 50->90; low -> sealed.

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

791 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 'biofilm':
// reputation cash-out (combat/index.js biofilmGate). Optional need (0..100)/allies/coat.
if (e.need != null && (e.need < 0 || e.need > 100)) E(`${at}: biofilm need ${e.need} out of 0..100`);
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);
}
}