Data spine: - Schema v1 ratified (TECH.md, C-owned). Additive over v0: schema/par/next, segment name, cross-section placement (theta/rho as a FRACTION of safe radius), spread, span, pickup+gate event types, hazard params. STUB_LEVEL synced (that const only, per ROUND1 §Lane C.1) — world.hash() unchanged (1bdf001), geometry provably untouched. - levels/index.js: registry + loader (node+browser, no THREE), real schema selfcheck (qa's --selfcheck hook is now live) and a pacing simulator (--sim). Consumes the other lanes' contracts live rather than restating them: A's biomes.js for ids and wave.amp+breathe, A's SKIN, B's tuning.js hull radius, B's balance.js for scores. - levels/enemies.js (fiction id -> archetype join table), levels/difficulty.js. L2_esophagus — the vertical slice, complete: 3600 units, 7 beats, 47 events, 65 enemies, 10 checkpoints, 3 samples, reflux-surge finale. L1/L3 skeletons (shape only). Anatomy is the level designer: the three real esophageal constrictions are the three setpieces, and all three enemies are real esophageal pathology (bolus chunk, eosinophilic esophagitis, candida esophagitis). Measured (node web/js/levels/index.js --selfcheck|--sim; qa GREEN): - selfcheck 3/3 valid, exit 0. - pressure curve 0.27 -> 2.14 -> 4.35 -> 0.69 -> 4.53 -> 6.35 -> 4.40 (breather/pressure/ spike/breather/pressure/peak/finale). - par 3:30 vs measured 2:47 speedrun / 3:19 par-pace / 4:22 timid; par.score 9000 of a 9400 kill budget (95%), computed from B's scores. - all 10 checkpoint gaps within the <=30s death-cost law (max 28.8s); min clearance 2.73. Two contract collisions found and resolved (LANE_C_NOTES): - Biome ids: authored mouth/pharynx/cardia/pylorus; A's registry has six canonical ids and rejected all four. A is right — biome = look, segments[].name = anatomy. Conformed. - Balance: my first enemies.js restated hp/size/speed/score that B owns in balance.js. Cut to a join table; index.js reads B's scores via scoreFor(). Caught live: A raised esophagus wave.amp 0.9 -> 1.4 mid-session, making the diaphragmatic hiatus unflyable (2.33 vs the 2.5 floor). Widened 6.5 -> 6.8. The invariant also failed my own aortic constriction's first draft (radius 7, symmetric squeeze) -> radius 9, directional. No in-engine flythrough: ?stub=1&lvl= does not compose (snippet offered to F) and the stub is single-segment, so it could only render beat 1. The pacing evidence is a kinematic model, not a playtest — it proves L2 is flyable, paced and survivable, not that it feels good. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
546 lines
26 KiB
JavaScript
546 lines
26 KiB
JavaScript
// levels/index.js (Lane C) — level registry, loader, schema selfcheck, pacing simulator.
|
|
//
|
|
// Runs in BOTH the browser (boot.js: `import('./levels/index.js').getLevel(id)`) and node
|
|
// (`node web/js/levels/index.js --selfcheck`, called by tools/qa.sh). That dual life is why
|
|
// this file imports no THREE and touches no DOM: it is data and arithmetic, nothing else.
|
|
//
|
|
// node web/js/levels/index.js --selfcheck validate every registered level (qa gate)
|
|
// node web/js/levels/index.js --sim pacing report for every level
|
|
// node web/js/levels/index.js --sim L2_esophagus pacing report for one level
|
|
//
|
|
// Schema of record: docs/TECH.md §Level data schema v1 (Lane C owns it; changes go through
|
|
// LANE_C_NOTES.md + F referee, never silently).
|
|
|
|
import { ENEMIES, getEnemy, ARCHETYPES, PICKUPS, HAZARDS } from './enemies.js';
|
|
import { applyDifficulty, getDifficulty, DEFAULT_DIFFICULTY } from './difficulty.js';
|
|
|
|
export { ENEMIES, getEnemy, ARCHETYPES, PICKUPS, HAZARDS };
|
|
export { applyDifficulty, getDifficulty, DIFFICULTIES, DEFAULT_DIFFICULTY } from './difficulty.js';
|
|
|
|
const isNode = typeof process !== 'undefined' && !!process.versions?.node;
|
|
|
|
/** Schema version this module speaks. Levels declare `"schema": N`. */
|
|
export const SCHEMA = 1;
|
|
|
|
/** Campaign order. L4/L5/LS land in later rounds; the registry is the source of truth. */
|
|
export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach'];
|
|
|
|
/** Booting with no ?lvl= gives you the vertical slice. Lane F: see LANE_C_NOTES §→ Lane F. */
|
|
export const DEFAULT_LEVEL = 'L2_esophagus';
|
|
|
|
/**
|
|
* Playability constants — Lane C's model of "is there room to fly here".
|
|
*
|
|
* These MIRROR the other lanes rather than assert anything: A's world/index.js computes
|
|
* `wallRho = radiusAt(s) - waveMaxAt(s) - SKIN` with `waveMaxAt = wave.amp + wave.breathe`
|
|
* (per-biome, read live from A's registry — see waveMargin()), and B's flight/tuning.js
|
|
* sets the hull collision sphere to 0.9. Only MIN_CLEARANCE is Lane C's own call.
|
|
*
|
|
* If A or B move their numbers, this module notices on the next qa run and the levels get
|
|
* re-tuned — which is not hypothetical: A raised the esophagus amp 0.9 -> 1.4 during round 1
|
|
* and it cost the diaphragmatic hiatus 0.5 units of flyable radius. See LANE_C_NOTES.md.
|
|
*/
|
|
export const PLAY = Object.freeze({
|
|
PERISTALSIS_AMP: 0.9, // fallback only, used when A's registry is unreachable (stub value)
|
|
SKIN: 0.6, // collision safety margin — matches A's world/index.js SKIN
|
|
SHIP_R: 0.9, // ENDO-1 collision sphere — from B's flight/tuning.js ship.radius
|
|
MIN_CLEARANCE: 2.5, // C'S LAW: the tightest point must still leave a 2.5-unit free disc
|
|
// (~2.8x the hull's collision radius). Below that a constriction
|
|
// stops being a challenge and becomes a toll you simply pay.
|
|
});
|
|
|
|
/**
|
|
* Fallback biome list, used only when Lane A's registry cannot be loaded.
|
|
* Mirrors world/biomes.js §Canonical ids. Lane A's registry is AUTHORITATIVE when present —
|
|
* biome = the visual/tissue family, `segments[].name` carries the anatomy. That division is
|
|
* why C needs no 'cardia'/'pylorus'/'pharynx' ids: those are names, not looks.
|
|
*/
|
|
export const BIOMES_EXPECTED = [
|
|
'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix',
|
|
];
|
|
|
|
/** Throttle profiles for the pacing sim. GDD: throttle gives +/-40% of biome flow. */
|
|
export const PROFILES = Object.freeze({ cautious: 0.85, par: 1.15, speedrun: 1.4 });
|
|
|
|
/** A lethal chase forces the player's hand — no one dawdles in front of an acid wall. */
|
|
const CHASE_THROTTLE = 1.35;
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// loading
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
const cache = new Map();
|
|
|
|
async function loadJSON(rel) {
|
|
const url = new URL(rel, import.meta.url); // resolves against THIS file in node + browser
|
|
if (isNode) {
|
|
const { readFile } = await import('node:fs/promises');
|
|
return JSON.parse(await readFile(url, 'utf8'));
|
|
}
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`level fetch failed: ${rel} (${res.status})`);
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* → the level object for `id` (defaults to the vertical slice), validated.
|
|
* Throws on unknown id or on validation errors — a broken level must never reach the world.
|
|
*/
|
|
export async function getLevel(id = DEFAULT_LEVEL, { difficulty = DEFAULT_DIFFICULTY } = {}) {
|
|
const key = id ?? DEFAULT_LEVEL;
|
|
if (!CAMPAIGN.includes(key)) throw new Error(`unknown level "${key}" (have: ${CAMPAIGN.join(', ')})`);
|
|
if (!cache.has(key)) cache.set(key, loadJSON(`./${key}.json`));
|
|
const level = await cache.get(key);
|
|
const { errors } = await validate(level, { expectId: key });
|
|
if (errors.length) throw new Error(`level "${key}" failed validation:\n - ${errors.join('\n - ')}`);
|
|
return applyDifficulty(level, difficulty);
|
|
}
|
|
|
|
/** → [{ id, name, tagline, length, par }] for menus (Lane E). */
|
|
export async function listLevels() {
|
|
const out = [];
|
|
for (const id of CAMPAIGN) {
|
|
const l = await loadJSON(`./${id}.json`);
|
|
out.push({ id, name: l.name, tagline: l.tagline, length: totalLength(l), par: l.par, skeleton: isSkeleton(l) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Lane A's biome registry, or C's fallback. Cached module-level so the sync clearance
|
|
* helpers can read wave amplitudes without every call site going async.
|
|
* We consume A's `wave.amp` rather than assuming the stub's 0.9: peristalsis amplitude is
|
|
* per-biome (small_intestine is 1.1), and it is subtracted from the flyable radius, so a
|
|
* level that is comfortable in the esophagus could be a wall in the ileum.
|
|
*/
|
|
let REG = null;
|
|
|
|
async function biomeRegistry() {
|
|
if (REG) return REG;
|
|
try {
|
|
const m = await import('../world/biomes.js'); // Lane A's, when it lands
|
|
if (m.BIOMES && Object.keys(m.BIOMES).length) {
|
|
REG = {
|
|
ids: new Set(Object.keys(m.BIOMES)),
|
|
// Mirror A's waveMaxAt() exactly: amp + breathe, not amp alone.
|
|
marginFor: (id) => {
|
|
const w = m.BIOMES[id]?.wave;
|
|
return w ? w.amp + (w.breathe ?? 0) : PLAY.PERISTALSIS_AMP;
|
|
},
|
|
authoritative: true,
|
|
};
|
|
return REG;
|
|
}
|
|
} catch { /* Lane A hasn't landed yet — fall back to C's expectation */ }
|
|
REG = { ids: new Set(BIOMES_EXPECTED), marginFor: () => PLAY.PERISTALSIS_AMP, authoritative: false };
|
|
return REG;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// geometry / pacing helpers (shared by validate + pace)
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
export function totalLength(level) {
|
|
return level.segments.reduce((a, s) => a + s.length, 0);
|
|
}
|
|
|
|
/** → the segment containing s, plus its start offset. */
|
|
export function segmentAt(level, s) {
|
|
let s0 = 0;
|
|
for (const seg of level.segments) {
|
|
if (s < s0 + seg.length || seg === level.segments[level.segments.length - 1]) return { seg, s0 };
|
|
s0 += seg.length;
|
|
}
|
|
return { seg: level.segments[0], s0: 0 };
|
|
}
|
|
|
|
const isSkeleton = (level) => /^SKELETON/.test(level.design?.status ?? '');
|
|
|
|
/**
|
|
* Lane B's combat balance, or false. Same guarded-import pattern as the biome registry:
|
|
* Lane C's registry CONSUMES the other lanes' contracts and owns only composition, so
|
|
* score lives in exactly one place (B's balance.js, keyed by archetype) and this module
|
|
* reads it rather than restating it.
|
|
*/
|
|
let BAL = null;
|
|
|
|
async function combatBalance() {
|
|
if (BAL !== null) return BAL;
|
|
try {
|
|
const m = await import('../combat/balance.js'); // Lane B's, when it lands
|
|
if (m.BALANCE?.enemies) { BAL = m.BALANCE; return BAL; }
|
|
} catch { /* Lane B hasn't landed yet */ }
|
|
BAL = false;
|
|
return BAL;
|
|
}
|
|
|
|
/** → the score Lane B awards for killing `enemyId`, or null if B's balance isn't loaded. */
|
|
export function scoreFor(enemyId) {
|
|
const spec = getEnemy(enemyId);
|
|
if (!spec || !BAL) return null;
|
|
return BAL.enemies?.[spec.archetype]?.score ?? null;
|
|
}
|
|
|
|
/** → total points available from kills, or null. Used to sanity-check par.score. */
|
|
export function scoreBudget(level) {
|
|
if (!BAL) return null;
|
|
let total = 0;
|
|
for (const e of level.events ?? []) {
|
|
if (e.type !== 'spawn') continue;
|
|
const s = scoreFor(e.enemy);
|
|
if (s == null) return null;
|
|
total += s * (e.count ?? 1);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/** What the wall eats into the lumen — Lane A's per-biome (amp + breathe) when available. */
|
|
function waveMargin(seg) {
|
|
return REG?.marginFor ? REG.marginFor(seg.biome) : PLAY.PERISTALSIS_AMP;
|
|
}
|
|
|
|
/** Static free-flight radius of a segment at its narrowest wobble trough. */
|
|
function staticClearance(seg) {
|
|
const r = seg.radius.base * (1 - (seg.radius.wobble ?? 0));
|
|
return r - waveMargin(seg) - PLAY.SKIN - PLAY.SHIP_R;
|
|
}
|
|
|
|
/**
|
|
* Clearance including span hazards that eat into the tube.
|
|
* A one-sided squeeze (aortic) does NOT halve the tube: it bites `amplitude*R` out of one
|
|
* arc, so the largest circle you can still fly through is wallRho - intrusion/2.
|
|
* A ring_gate is exempt — it seals on purpose and is a timing gate, not a corridor.
|
|
*/
|
|
function hazardClearance(level, seg, s0) {
|
|
let clear = staticClearance(seg);
|
|
const rStatic = seg.radius.base * (1 - (seg.radius.wobble ?? 0));
|
|
for (const e of level.events) {
|
|
if (e.type !== 'hazard' || !e.span) continue;
|
|
const overlaps = e.s < s0 + seg.length && e.s + e.span > s0;
|
|
if (!overlaps) continue;
|
|
if (e.kind === 'aortic_squeeze') {
|
|
const intrusion = (e.amplitude ?? 0) * rStatic;
|
|
clear = Math.min(clear, staticClearance(seg) - intrusion / 2);
|
|
}
|
|
}
|
|
return clear;
|
|
}
|
|
|
|
/** How fast the player is actually moving at s, under a throttle profile. */
|
|
function speedAt(level, s, k) {
|
|
const { seg } = segmentAt(level, s);
|
|
let throttle = k;
|
|
for (const e of level.events) {
|
|
if (e.type === 'hazard' && e.kind === 'reflux_surge' && e.lethal && e.span &&
|
|
s >= e.s && s <= e.s + e.span) throttle = Math.max(throttle, CHASE_THROTTLE);
|
|
}
|
|
return Math.max(0.1, seg.flow * throttle);
|
|
}
|
|
|
|
/** Seconds to travel [a, b] under profile k. Numeric integration; 2-unit steps. */
|
|
export function travelTime(level, a, b, k) {
|
|
const STEP = 2;
|
|
let t = 0;
|
|
for (let s = a; s < b; s += STEP) {
|
|
const ds = Math.min(STEP, b - s);
|
|
t += ds / speedAt(level, s + ds / 2, k);
|
|
}
|
|
return t;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// the pacing simulator — Lane C's evidence (you cannot screenshot data)
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Models the level as a kinematic ride and reports the encounter curve.
|
|
* This is a MODEL, not a playtest: it assumes a player who holds a constant throttle and is
|
|
* never slowed by combat (true-ish in tube mode — the current carries you and you cannot
|
|
* stop). Treat times as lower bounds on a real run and pressure as a relative index only.
|
|
*
|
|
* pressure = density x tightness x hazard, where
|
|
* density = enemies per 100 units in the segment
|
|
* tightness = widest segment radius / this segment radius (how little room you have)
|
|
* hazard = 1 + a per-kind term (see hazardWeight)
|
|
*/
|
|
export function pace(level, { profile = 'par' } = {}) {
|
|
const k = PROFILES[profile] ?? PROFILES.par;
|
|
const total = totalLength(level);
|
|
const widest = Math.max(...level.segments.map((s) => s.radius.base));
|
|
|
|
const beats = [];
|
|
let s0 = 0, tAcc = 0;
|
|
for (const seg of level.segments) {
|
|
const s1 = s0 + seg.length;
|
|
const inSeg = (e) => e.s >= s0 && e.s < s1;
|
|
const enemies = level.events.filter((e) => e.type === 'spawn' && inSeg(e))
|
|
.reduce((a, e) => a + (e.count ?? 1), 0);
|
|
const hazards = level.events.filter((e) => e.type === 'hazard' && inSeg(e));
|
|
const density = (enemies / seg.length) * 100;
|
|
const tightness = widest / seg.radius.base;
|
|
const hazard = hazards.reduce((a, h) => a * hazardWeight(h), 1);
|
|
const dt = travelTime(level, s0, s1, k);
|
|
tAcc += dt;
|
|
beats.push({
|
|
name: seg.name ?? seg.biome, s0, s1, length: seg.length, flow: seg.flow,
|
|
enemies, density, tightness, hazard,
|
|
pressure: density * tightness * hazard,
|
|
clearance: hazardClearance(level, seg, s0),
|
|
seconds: dt, cumulative: tAcc,
|
|
hazardKinds: hazards.map((h) => h.kind),
|
|
});
|
|
s0 = s1;
|
|
}
|
|
|
|
const checkpoints = level.events.filter((e) => e.type === 'checkpoint');
|
|
const gaps = checkpoints.map((cp, i) => {
|
|
const next = checkpoints[i + 1]?.s ?? total;
|
|
return { from: cp.s, to: next, name: cp.name ?? '', units: next - cp.s, seconds: travelTime(level, cp.s, next, PROFILES.par) };
|
|
});
|
|
|
|
return { id: level.id, profile, total, beats, checkpoints: gaps, seconds: tAcc };
|
|
}
|
|
|
|
function hazardWeight(h) {
|
|
switch (h.kind) {
|
|
case 'aortic_squeeze': return 1 + (h.amplitude ?? 0);
|
|
case 'ring_gate': return 1 + (1 - (h.open ?? 1) / (h.period ?? 1));
|
|
case 'reflux_surge': return h.lethal ? 1.8 : 1.15;
|
|
default: return 1.1;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// validate — the schema selfcheck (qa.sh gate)
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
const TAU = Math.PI * 2;
|
|
|
|
/**
|
|
* → { errors, warnings }. Errors block qa; warnings are advisory.
|
|
* Skeleton levels (design.status starts "SKELETON") downgrade CONTENT rules (pacing,
|
|
* samples, checkpoint coverage) to warnings — a level that has not been designed yet must
|
|
* not fail the gate for not being designed yet. STRUCTURAL rules always apply.
|
|
*/
|
|
export async function validate(level, { expectId = null } = {}) {
|
|
const errors = [], warnings = [];
|
|
const E = (m) => errors.push(m);
|
|
const W = (m) => warnings.push(m);
|
|
const skel = isSkeleton(level);
|
|
const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m));
|
|
|
|
// --- structural -----------------------------------------------------------------
|
|
if (level.schema !== SCHEMA) E(`schema is ${level.schema}, this module speaks ${SCHEMA}`);
|
|
if (typeof level.id !== 'string' || !level.id) E('missing id');
|
|
if (expectId && level.id !== expectId) E(`id "${level.id}" does not match filename "${expectId}"`);
|
|
if (!Number.isInteger(level.seed)) E(`seed must be an integer (got ${JSON.stringify(level.seed)})`);
|
|
if (typeof level.name !== 'string' || !level.name) E('missing name');
|
|
if (!Array.isArray(level.segments) || !level.segments.length) { E('segments must be a non-empty array'); return { errors, warnings }; }
|
|
if (!Array.isArray(level.events)) E('events must be an array');
|
|
if (!Array.isArray(level.arenas)) E('arenas must be an array');
|
|
|
|
const total = totalLength(level);
|
|
const { ids: biomes, authoritative } = await biomeRegistry();
|
|
if (!authoritative) W(`Lane A's world/biomes.js not present — biome ids checked against Lane C's expected list (${BIOMES_EXPECTED.length} ids)`);
|
|
const bal = await combatBalance();
|
|
if (!bal) W("Lane B's combat/balance.js not present — archetype rows and score budget unchecked");
|
|
|
|
// --- segments -------------------------------------------------------------------
|
|
let s0 = 0;
|
|
for (const [i, seg] of level.segments.entries()) {
|
|
const at = `segments[${i}] "${seg.name ?? seg.biome}"`;
|
|
if (!biomes.has(seg.biome)) E(`${at}: unknown biome "${seg.biome}"`);
|
|
if (!(seg.length > 0)) E(`${at}: length must be > 0`);
|
|
if (!(seg.radius?.base > 0)) E(`${at}: radius.base must be > 0`);
|
|
const wob = seg.radius?.wobble ?? 0;
|
|
if (wob < 0 || wob >= 1) E(`${at}: radius.wobble must be in [0,1) (got ${wob})`);
|
|
if (seg.curviness < 0 || seg.curviness > 1) E(`${at}: curviness must be in [0,1] (got ${seg.curviness})`);
|
|
if (!(seg.flow >= 0)) E(`${at}: flow must be >= 0`);
|
|
|
|
// C's playability invariant — the check that catches "I made a cool tube you can't fly".
|
|
const clear = hazardClearance(level, seg, s0);
|
|
if (clear < PLAY.MIN_CLEARANCE) {
|
|
E(`${at}: free radius ${clear.toFixed(2)} < MIN_CLEARANCE ${PLAY.MIN_CLEARANCE} ` +
|
|
`(base ${seg.radius.base}, wobble ${wob}, minus wave ${waveMargin(seg).toFixed(2)} + skin ${PLAY.SKIN} + hull ${PLAY.SHIP_R}` +
|
|
`${clear < staticClearance(seg) ? ' + span hazard' : ''}) — unflyable`);
|
|
}
|
|
s0 += seg.length;
|
|
}
|
|
|
|
// --- events ---------------------------------------------------------------------
|
|
let prev = -Infinity;
|
|
for (const [i, e] of (level.events ?? []).entries()) {
|
|
const at = `events[${i}] ${e.type}@${e.s}`;
|
|
if (typeof e.s !== 'number') { E(`${at}: missing numeric s`); continue; }
|
|
if (e.s < prev) E(`${at}: events must be sorted by s (previous was ${prev})`);
|
|
prev = e.s;
|
|
if (e.s < 0 || e.s > total) E(`${at}: s outside level (0..${total})`);
|
|
if (e.span != null && e.s + e.span > total) E(`${at}: span runs past the end of the level`);
|
|
|
|
const thetas = Array.isArray(e.theta) ? e.theta : (e.theta != null ? [e.theta] : []);
|
|
for (const t of thetas) if (!(t >= 0 && t < TAU)) E(`${at}: theta ${t} outside [0, 2pi)`);
|
|
if (e.rho != null && !(e.rho >= 0 && e.rho <= 1)) E(`${at}: rho ${e.rho} outside [0,1] (rho is a FRACTION of the safe radius, not a distance)`);
|
|
|
|
switch (e.type) {
|
|
case 'spawn': {
|
|
const spec = getEnemy(e.enemy);
|
|
if (!spec) { E(`${at}: unknown enemy "${e.enemy}" (not in enemies.js catalogue)`); break; }
|
|
if (!ARCHETYPES.includes(spec.archetype)) E(`${at}: enemy "${e.enemy}" has archetype "${spec.archetype}" which Lane B does not implement (${ARCHETYPES.join('/')})`);
|
|
else if (bal && !bal.enemies?.[spec.archetype]) E(`${at}: Lane B's balance.js has no row for archetype "${spec.archetype}" — the contract drifted`);
|
|
if (spec.placeholder) W(`${at}: spawns bare archetype "${e.enemy}" — placeholder with no fiction; give it a real catalogue entry`);
|
|
if (spec.wall && !thetas.length) E(`${at}: "${e.enemy}" is wall-mounted and needs an explicit theta`);
|
|
if (Array.isArray(e.theta) && e.theta.length !== (e.count ?? 1)) E(`${at}: theta array has ${e.theta.length} entries but count is ${e.count ?? 1}`);
|
|
break;
|
|
}
|
|
case 'pickup':
|
|
if (!PICKUPS[e.kind]) E(`${at}: unknown pickup kind "${e.kind}"`);
|
|
break;
|
|
case 'hazard': {
|
|
const h = HAZARDS[e.kind];
|
|
if (!h) { E(`${at}: unknown hazard kind "${e.kind}"`); break; }
|
|
for (const p of h.needs) if (e[p] == null) E(`${at}: hazard "${e.kind}" requires "${p}"`);
|
|
if (e.kind === 'ring_gate') {
|
|
const duty = (e.open ?? 0) / (e.period ?? 1);
|
|
if (duty < 0.3) W(`${at}: ring_gate open only ${(duty * 100) | 0}% of the time — below C's 30% fairness floor`);
|
|
}
|
|
if (e.warn != null && e.warn < 2 && e.lethal !== false) W(`${at}: warn ${e.warn}s is under the 2s telegraph law for lethal hazards`);
|
|
break;
|
|
}
|
|
case 'checkpoint': case 'boss': case 'gate': break;
|
|
default: W(`${at}: unknown event type "${e.type}" — B/E will ignore it`);
|
|
}
|
|
}
|
|
|
|
// --- arenas ---------------------------------------------------------------------
|
|
for (const [i, a] of (level.arenas ?? []).entries()) {
|
|
const at = `arenas[${i}]`;
|
|
if (!(a.radius > 0)) E(`${at}: radius must be > 0`);
|
|
if (a.at - a.radius < 0 || a.at + a.radius > total) E(`${at}: sphere (at ${a.at} r ${a.radius}) spans ${a.at - a.radius}..${a.at + a.radius}, outside 0..${total}`);
|
|
if (a.biome && !biomes.has(a.biome)) E(`${at}: unknown biome "${a.biome}"`);
|
|
}
|
|
|
|
// --- content laws (downgraded for skeletons) ------------------------------------
|
|
const cps = (level.events ?? []).filter((e) => e.type === 'checkpoint');
|
|
for (const e of level.events ?? []) {
|
|
if (e.type === 'boss' && !cps.some((c) => c.s <= e.s && e.s - c.s < 200))
|
|
C(`boss "${e.id}"@${e.s} has no checkpoint within 200 units before it`);
|
|
if (e.type === 'hazard' && e.lethal && !cps.some((c) => c.s <= e.s && e.s - c.s < 120))
|
|
C(`lethal hazard "${e.kind}"@${e.s} has no checkpoint within 120 units before it`);
|
|
}
|
|
|
|
// death costs <= 30s of progress (charter). Measured at par throttle.
|
|
for (const [i, cp] of cps.entries()) {
|
|
const next = cps[i + 1]?.s ?? total;
|
|
const secs = travelTime(level, cp.s, next, PROFILES.par);
|
|
if (secs > 30) C(`checkpoint gap ${cp.s}..${next} ("${cp.name ?? ''}") = ${secs.toFixed(1)}s at par throttle — over the 30s death-cost law`);
|
|
}
|
|
|
|
const samples = (level.events ?? []).filter((e) => e.type === 'pickup' && e.kind === 'biopsy_sample')
|
|
.reduce((a, e) => a + (e.count ?? 1), 0);
|
|
if (samples !== 3) C(`${samples} biopsy samples — the GDD says exactly 3 per level`);
|
|
|
|
// par sanity: an unbeatable par is a bug, a free par is a waste.
|
|
if (level.par?.time > 0) {
|
|
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
|
const slow = pace(level, { profile: 'cautious' }).seconds;
|
|
if (level.par.time < fast) E(`par.time ${level.par.time}s is faster than a full-throttle run (${fast.toFixed(0)}s) — unachievable`);
|
|
else if (level.par.time > slow) C(`par.time ${level.par.time}s is slower than a timid run (${slow.toFixed(0)}s) — free medal`);
|
|
} else C('no par.time');
|
|
|
|
if (level.par?.score > 0 && (level.events ?? []).some((e) => e.type === 'spawn')) {
|
|
const budget = scoreBudget(level);
|
|
if (budget != null && level.par.score > budget)
|
|
W(`par.score ${level.par.score} exceeds the kill budget ${budget} (Lane B's scores x C's counts) — only reachable via pickups and combo multipliers`);
|
|
}
|
|
|
|
// gate wiring
|
|
for (const e of level.events ?? []) {
|
|
if (e.type === 'gate' && e.to && !CAMPAIGN.includes(e.to)) W(`gate "${e.id}" points at "${e.to}" which is not in the campaign registry yet`);
|
|
}
|
|
if (level.next && !CAMPAIGN.includes(level.next)) W(`next "${level.next}" is not in the campaign registry yet`);
|
|
|
|
return { errors, warnings };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------
|
|
// CLI (node only — guarded so importing this in the browser has zero side effects)
|
|
// ---------------------------------------------------------------------------------------
|
|
|
|
function bar(v, max, width = 22) {
|
|
const n = Math.max(0, Math.min(width, Math.round((v / max) * width)));
|
|
return '#'.repeat(n) + '.'.repeat(width - n);
|
|
}
|
|
|
|
async function cliSelfcheck() {
|
|
let bad = 0, warned = 0;
|
|
for (const id of CAMPAIGN) {
|
|
let level;
|
|
try { level = await loadJSON(`./${id}.json`); }
|
|
catch (err) { console.error(` x ${id}: cannot load — ${err.message}`); bad++; continue; }
|
|
const { errors, warnings } = await validate(level, { expectId: id });
|
|
const tag = isSkeleton(level) ? ' (skeleton)' : '';
|
|
if (errors.length) {
|
|
bad++;
|
|
console.error(` x ${id}${tag}: ${errors.length} error(s)`);
|
|
for (const e of errors) console.error(` ERROR ${e}`);
|
|
} else {
|
|
console.log(` . ${id}${tag}: ok — ${level.segments.length} segments, ${totalLength(level)} units, ${level.events.length} events`);
|
|
}
|
|
for (const w of warnings) { warned++; console.log(` warn ${w}`); }
|
|
}
|
|
console.log(`levels selfcheck: ${CAMPAIGN.length - bad}/${CAMPAIGN.length} valid, ${warned} warning(s)`);
|
|
return bad === 0;
|
|
}
|
|
|
|
async function cliSim(only) {
|
|
const reg = await biomeRegistry(); // so clearance uses Lane A's real wave amplitudes
|
|
const bal = await combatBalance(); // so the score budget uses Lane B's real scores
|
|
console.log(`biome registry: ${reg.authoritative ? "Lane A's world/biomes.js" : 'Lane C fallback list (Lane A not present)'}`);
|
|
console.log(`combat balance: ${bal ? "Lane B's combat/balance.js" : 'not present (score budget unavailable)'}`);
|
|
for (const id of CAMPAIGN) {
|
|
if (only && id !== only) continue;
|
|
const level = await loadJSON(`./${id}.json`);
|
|
const p = pace(level, { profile: 'par' });
|
|
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
|
const slow = pace(level, { profile: 'cautious' }).seconds;
|
|
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
|
|
|
console.log(`\n=== ${id} — ${level.name}${isSkeleton(level) ? ' [SKELETON]' : ''} ===`);
|
|
const budget = scoreBudget(level);
|
|
console.log(`${p.total} units · par.time ${level.par?.time ?? '?'}s (${mmss(level.par?.time ?? 0)}) · par.score ${level.par?.score ?? '?'}` +
|
|
(budget != null ? ` of ${budget} kill budget (${((level.par.score / budget) * 100) | 0}%)` : ''));
|
|
console.log(`sim: speedrun ${mmss(fast)} (${fast.toFixed(0)}s) · par ${mmss(p.seconds)} (${p.seconds.toFixed(0)}s) · cautious ${mmss(slow)} (${slow.toFixed(0)}s)`);
|
|
const maxP = Math.max(...p.beats.map((b) => b.pressure), 0.001);
|
|
console.log('\n beat s-range len flow en dens tight haz PRESSURE clear t cum');
|
|
for (const b of p.beats) {
|
|
console.log(
|
|
` ${(b.name ?? '').padEnd(26).slice(0, 26)} ${String(b.s0).padStart(4)}-${String(b.s1).padStart(4)} ` +
|
|
`${String(b.length).padStart(5)} ${String(b.flow).padStart(4)} ${String(b.enemies).padStart(3)} ` +
|
|
`${b.density.toFixed(2).padStart(5)} ${b.tightness.toFixed(2).padStart(5)} ${b.hazard.toFixed(2).padStart(4)} ` +
|
|
`${b.pressure.toFixed(2).padStart(6)} ${bar(b.pressure, maxP, 10)} ${b.clearance.toFixed(2).padStart(5)} ` +
|
|
`${b.seconds.toFixed(0).padStart(4)}s ${mmss(b.cumulative).padStart(5)}`);
|
|
}
|
|
const totEn = p.beats.reduce((a, b) => a + b.enemies, 0);
|
|
console.log(` ${'TOTAL'.padEnd(26)} ${String(0).padStart(4)}-${String(p.total).padStart(4)} ${String(p.total).padStart(5)} ${'-'.padStart(4)} ${String(totEn).padStart(3)}`);
|
|
|
|
if (p.checkpoints.length) {
|
|
console.log('\n checkpoint gaps (death cost at par throttle; law: <= 30s)');
|
|
for (const g of p.checkpoints) {
|
|
const flag = g.seconds > 30 ? ' <-- OVER' : '';
|
|
console.log(` ${String(g.from).padStart(4)} -> ${String(g.to).padStart(4)} ${String(g.units).padStart(4)}u ${g.seconds.toFixed(1).padStart(5)}s ${g.name}${flag}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isNode && (process.argv.includes('--selfcheck') || process.argv.includes('--sim'))) {
|
|
if (process.argv.includes('--sim')) {
|
|
const i = process.argv.indexOf('--sim');
|
|
await cliSim(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);
|
|
}
|
|
if (process.argv.includes('--selfcheck')) {
|
|
const ok = await cliSelfcheck();
|
|
process.exit(ok ? 0 : 1);
|
|
}
|
|
}
|