Landed before the cut: schema v2 (segments[].wave.amp override, validated — breathe stays biome-owned); hiatus re-authored tight AND calm (radius back to 6.5, amp 0.6, 3.3u steady clearance); par 210 -> 180 with full rationale (below throttle-only par-pace, above a surf run — the medal teaches the mechanic); finale surge 21 -> 24 so surfing is the stylish escape (required 1.20x); surf profile reads A's live CREST_FACTOR; chaseAnalysis() + escapability laws in validate; NEW --probe builds A's real canal and measures clearance against the model (all L2 segments clear the 2.5 floor, hiatus 3.75); isMain guard + importSpline shim — fixes A's selfcheck process.exit killing C's mid-run (qa.sh had been green on one level out of three). Cut off before: fly-through of the integrated build (blocked on B), pickup economy handover (B proposed numbers in balance.js meanwhile — reconcile), L1 encounter design doc, GDD boss folding, NOTES. Committed by F to protect the shared tree; levels selfcheck 3/3 GREEN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
787 lines
38 KiB
JavaScript
787 lines
38 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;
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|