meaning "miss nothing" Audit item 13 — the systems the levels were authored against, none of which worked. THE COMBO MULTIPLIER DID NOT MULTIPLY. balance.js's own comment cites the GDD line "score x style multiplier"; what shipped was a 2.5 s window and a counter. The HUD printed "x7 chain", comms shouted about it at 5, the music added a layer at 3 — and combat/index.js added the flat base score every time. Every par.score in every level was authored against a lever that did not exist, which is exactly why they all sat at 75-94% of the un-multiplied ceiling. Now step 0.12, cap 2.5 at a 14-chain: reachable in L4's ileum and nowhere else, because a cap you hit every fight is not a skill expression. Verified in the running game: six chained floaters pay 100/112/124/136/148/160 where they used to pay 600 flat. ALLIES COUNTED AS KILLS. combat/index.js did an unconditional score.kills++ on enemy:die, and the ally branch of enemies.js emits enemy:die with score 0 — so shooting a lactobacillus reef, the one act this game's whole reputation loop exists to punish, ticked your PATHOGENS tally up by four. The pause card and the operative record told you that you were doing well at the exact moment the biome decided otherwise. enemy:die carries `foe` now. Verified: seven deaths, six kills. THE PAR CHECK WAS COUNTING A THIRD OF THE GAME. scoreBudget() summed spawn events and nothing else — not the 500 points a biopsy sample is worth, not the several thousand a boss puts in the room. Four of six levels therefore shipped a permanent "par.score exceeds the kill budget" warning, and a warning that is wrong two thirds of the time is one everybody learns to scroll past. It counts pickups and boss rings now (nodes once per phase — a floor, since a phase re-rings for as long as the fight lasts and no honest ceiling exists). Six levels, zero warnings, and the ratios that were 94/75/52/59/44/32% of a fictional budget are now real. AND THE CHECK THAT SHOULD HAVE CAUGHT L1. The only par test asked whether par exceeded the ceiling. L1 sat at 94% of it: par the tutorial and you had to kill all eight foes, take all seven orbs and all three samples — miss ONE sample and the medal was gone. That is a clear-sheet requirement wearing par's clothes, in the level that exists to teach. par.score is 1900 now (71%), and the validator warns above 85% so the next one gets caught by a machine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
847 lines
42 KiB
JavaScript
847 lines
42 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. */
|
||
// LS_appendix is the secret and is NOT on the critical path: L4's ileocecal valve routes there
|
||
// only when BIOME STANDING is high enough (the gate's `secretTo`/`secretNeed`, honoured in
|
||
// boot.js). It lives in the registry so it can be loaded, chained out of, and selfchecked.
|
||
export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach', 'L4_small_intestine', 'L5_large_intestine', 'LS_appendix'];
|
||
|
||
/**
|
||
* Booting with no ?lvl= starts the CAMPAIGN, at level 1.
|
||
*
|
||
* This was 'L2_esophagus' for three rounds and that was correct at the time — L2 was the vertical
|
||
* slice and L1 was a 2-event skeleton, so the default pointed at the only finished level.
|
||
* LANE_C_NOTES §→ Lane F #2 says in as many words: "Change it if you would rather it be L1."
|
||
* L1 is authored now (24 events, the Mast Cell, the whole teaching order), so leaving the default
|
||
* on L2 meant every new player skipped the tutorial and started in the middle of the campaign,
|
||
* which is the worst first impression the game is capable of making.
|
||
*/
|
||
export const DEFAULT_LEVEL = 'L1_mouth';
|
||
|
||
/**
|
||
* 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;
|
||
let BOSS = null; // combat/boss.js BOSSES, for pricing a boss into scoreBudget
|
||
|
||
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;
|
||
// Same lane, same optionality: boss.js imports only balance.js (no THREE), so it loads
|
||
// headless. A missing boss table just means bosses price at zero, as before.
|
||
BOSS = await import('../combat/boss.js').then((b) => b.BOSSES ?? null).catch(() => null);
|
||
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 in a level, or null if Lane B's balance isn't loaded.
|
||
*
|
||
* Counts everything that scores: spawned enemies, scoring pickups, and the bodies a boss puts
|
||
* in the room. It counted spawn events ALONE, which is why four of six levels shipped with a
|
||
* "par.score exceeds the kill budget" warning — the budget was ignoring 500 points a biopsy
|
||
* sample and several thousand points of boss. A warning that is wrong two thirds of the time
|
||
* is a warning everyone learns to scroll past, which is worse than no warning.
|
||
*
|
||
* Deliberately does NOT model the combo multiplier: that is upside, so a par under this number
|
||
* is reachable on a chain of one, which is the property the check wants.
|
||
*
|
||
* Boss nodes are counted ONCE PER PHASE, a floor rather than a ceiling — a phase re-rings on
|
||
* its `period` for as long as the fight lasts, so the true figure is unbounded and no honest
|
||
* ceiling exists. A floor can only ever make this check stricter than reality.
|
||
*/
|
||
export function scoreBudget(level) {
|
||
if (!BAL) return null;
|
||
let total = 0;
|
||
for (const e of level.events ?? []) {
|
||
if (e.type === 'spawn') {
|
||
const s = scoreFor(e.enemy);
|
||
if (s == null) return null;
|
||
total += s * (e.count ?? 1);
|
||
} else if (e.type === 'pickup') {
|
||
total += (BAL.pickups?.[e.kind]?.score ?? 0) * (e.count ?? 1);
|
||
} else if (e.type === 'boss') {
|
||
const b = BOSS?.[e.id];
|
||
const node = BAL.enemies?.[b?.node]?.score ?? 0;
|
||
for (const ph of b?.phases ?? []) total += node * (ph.nodes ?? 0);
|
||
}
|
||
}
|
||
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 'acid':
|
||
// acid-sea level change (world/acid.js set()). `height` is a world-space Y.
|
||
if (typeof e.height !== 'number') E(`${at}: acid event needs a numeric height`);
|
||
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})`;
|
||
// NOT `if (!c.lethal) continue`. An unwinnable chase is a design failure whatever its
|
||
// lethality: a non-lethal one you cannot outrun is a continuous-damage trap that grazes you
|
||
// until its span ends, which is how L1's tutorial tide shipped as a death loop with this gate
|
||
// green. A level that genuinely wants an inescapable wash should author a ZONE hazard, not a
|
||
// chase. Lethality only changes the wording.
|
||
if (false) 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)
|
||
E(`par.score ${level.par.score} exceeds everything in the level (${budget}) — unachievable`);
|
||
// A par you can only hit by collecting literally everything is not a par, it is a
|
||
// completion requirement wearing par's clothes. L1 sat at 94% — miss ONE biopsy sample in
|
||
// the tutorial and the medal was gone — and nothing said so, because the only check asked
|
||
// whether par was over the ceiling. 85% leaves room for one missed pickup on every level.
|
||
else if (budget > 0 && level.par.score > budget * 0.85)
|
||
W(`par.score ${level.par.score} is ${((level.par.score / budget) * 100) | 0}% of everything in the level (${budget}) — that is a clear-sheet requirement, not a par`);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|