[lane C] Round 2 WIP (session cut off): schema v2, surf-aware pacing, chase analysis, --probe
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>
This commit is contained in:
parent
4ed3433e55
commit
2e85ec0622
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"schema": 2,
|
||||||
"id": "L1_mouth",
|
"id": "L1_mouth",
|
||||||
"name": "Oral Cavity",
|
"name": "Oral Cavity",
|
||||||
"tagline": "Learn to swim",
|
"tagline": "Learn to swim",
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"schema": 2,
|
||||||
"id": "L2_esophagus",
|
"id": "L2_esophagus",
|
||||||
"name": "Esophagus",
|
"name": "Esophagus",
|
||||||
"tagline": "Ride the swallow",
|
"tagline": "Ride the swallow",
|
||||||
"seed": 20260716,
|
"seed": 20260716,
|
||||||
"par": { "time": 210, "score": 9000, "samples": 3 },
|
"par": { "time": 180, "score": 9000, "samples": 3 },
|
||||||
"next": "L3_stomach",
|
"next": "L3_stomach",
|
||||||
"design": {
|
"design": {
|
||||||
"owner": "Lane C",
|
"owner": "Lane C",
|
||||||
@ -12,6 +12,7 @@
|
|||||||
"anatomy": "Real esophagus, real order: upper esophageal sphincter -> cervical -> the broncho-aortic constriction -> thoracic -> diaphragmatic hiatus -> abdominal esophagus -> cardiac sphincter (LES) into the stomach. The three narrowings are the three real anatomical constrictions and they are this level's three setpieces.",
|
"anatomy": "Real esophagus, real order: upper esophageal sphincter -> cervical -> the broncho-aortic constriction -> thoracic -> diaphragmatic hiatus -> abdominal esophagus -> cardiac sphincter (LES) into the stomach. The three narrowings are the three real anatomical constrictions and they are this level's three setpieces.",
|
||||||
"scale": "Cross-sections are ANATOMICALLY TRUE (radius 10-12 units = ~2cm lumen at 1 unit : 1 mm). Lengths are DILATED ~14x (real esophagus is 25cm; this level is 3600 units). The canal is proportioned, not measured. See LANE_C_NOTES.md.",
|
"scale": "Cross-sections are ANATOMICALLY TRUE (radius 10-12 units = ~2cm lumen at 1 unit : 1 mm). Lengths are DILATED ~14x (real esophagus is 25cm; this level is 3600 units). The canal is proportioned, not measured. See LANE_C_NOTES.md.",
|
||||||
"curve": "breather -> pressure -> SPIKE -> breather -> pressure -> SPIKE -> lethal finale. Pressure indices measured in LANE_C_NOTES.md §L2 pacing.",
|
"curve": "breather -> pressure -> SPIKE -> breather -> pressure -> SPIKE -> lethal finale. Pressure indices measured in LANE_C_NOTES.md §L2 pacing.",
|
||||||
|
"par_rationale": "180s (3:00), retuned in round 2 for the CREST 1.6 surf ruling. Measured: a perfect crest ride is 2:26, throttle-mashing is 2:47, par-pace throttle (1.15x) is 3:19, timid is 4:22. So par 3:00 sits BELOW throttle-only competent play and ABOVE a surf run: you cannot reach it by holding the stick forward, and you can reach it comfortably by learning to surf. That is deliberate - L2's signature mechanic is riding peristalsis crests, and the time medal is the thing that teaches it. Par is a medal threshold, not a pass condition: missing it costs a medal, not the level.",
|
||||||
"teach_then_test": "flow (b1) / bolus (b1 solo -> b3 in the pinch) / candida (b2 solo, clear sightline -> b3 in the squeeze) / eosinophil (b2 open stretch -> b6 in the hiatus) / ring_gate (b5 open 2.4s -> b6 open 1.2s) / reflux (b4 non-lethal burp -> b7 lethal finale)."
|
"teach_then_test": "flow (b1) / bolus (b1 solo -> b3 in the pinch) / candida (b2 solo, clear sightline -> b3 in the squeeze) / eosinophil (b2 open stretch -> b6 in the hiatus) / ring_gate (b5 open 2.4s -> b6 open 1.2s) / reflux (b4 non-lethal burp -> b7 lethal finale)."
|
||||||
},
|
},
|
||||||
"segments": [
|
"segments": [
|
||||||
@ -64,10 +65,11 @@
|
|||||||
"biome": "esophagus",
|
"biome": "esophagus",
|
||||||
"name": "Diaphragmatic Hiatus",
|
"name": "Diaphragmatic Hiatus",
|
||||||
"length": 400,
|
"length": 400,
|
||||||
"radius": { "base": 6.8, "wobble": 0.15 },
|
"radius": { "base": 6.5, "wobble": 0.15 },
|
||||||
"curviness": 0.3,
|
"curviness": 0.3,
|
||||||
"flow": 18,
|
"flow": 18,
|
||||||
"note": "BEAT 6 - SPIKE. The tightest hole in the level: the esophagus threads the diaphragm. Free radius ~2.7 units - the narrowest thing Lane C will author, and only 0.2 above the MIN_CLEARANCE law. Base was 6.5 until Lane A measured the esophagus peristalsis at amp 1.4 (up from the stub's 0.9); the wave eats the difference, so the hole had to widen to stay a challenge instead of a toll. Straightened out on purpose - the challenge is the ring gate's timing, and a writhing tube would make that unreadable."
|
"wave": { "amp": 0.6 },
|
||||||
|
"note": "BEAT 6 - SPIKE, and the level's precision test. The tightest hole in the game: the esophagus threads the diaphragm. TIGHT AND CALM is the whole design - schema v2's wave override (amp 0.6 vs the esophagus's 1.4) exists for this segment. Anatomically the hiatus is a fixed muscular ring held by the diaphragm crura and does NOT have big peristaltic waves; mechanically, a big wave in the smallest gap turned a precision setpiece into a lottery you cannot stop to time. Calm waves let the tube go back to base 6.5 (it was widened to 6.8 in round 1 purely to survive the biome-wide amp) and still leave 3.3 units of steady clearance. Steady-tight is a skill; pulsing-tight is a toll. Straightened out (curviness 0.3) for the same reason: the challenge is the ring gate's timing, and a writhing tube would make that unreadable."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"biome": "esophagus",
|
"biome": "esophagus",
|
||||||
@ -127,7 +129,7 @@
|
|||||||
{ "s": 2960, "type": "pickup", "kind": "mucin_glob", "count": 2, "spread": 20, "note": "Last top-up in the level. Take it or regret it for 600 units." },
|
{ "s": 2960, "type": "pickup", "kind": "mucin_glob", "count": 2, "spread": 20, "note": "Last top-up in the level. Take it or regret it for 600 units." },
|
||||||
|
|
||||||
{ "s": 2980, "type": "checkpoint", "name": "Cardia Approach", "note": "The finale's checkpoint. Sits 20 units before the surge trigger: a death in the chase costs you the chase, not the hiatus." },
|
{ "s": 2980, "type": "checkpoint", "name": "Cardia Approach", "note": "The finale's checkpoint. Sits 20 units before the surge trigger: a death in the chase costs you the chase, not the hiatus." },
|
||||||
{ "s": 3000, "type": "hazard", "kind": "reflux_surge", "speed": 21, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true, "note": "THE FINALE. Acid wall, 21 u/s, spawning 40 units behind you and chasing for 600 units. Flow here is 20, so throttle 1.05+ outruns it and throttle 0.6 (12 u/s) kills you: the level's last instruction is 'do not dawdle'. Par pace (~26 u/s) finishes ~5s clear. Antacid fired backward buys ~10s." },
|
{ "s": 3000, "type": "hazard", "kind": "reflux_surge", "speed": 24, "from": -40, "span": 600, "warn": 2.0, "lethal": true, "neutralizable": true, "note": "THE FINALE. Acid wall, 24 u/s, spawning 40 units behind you and chasing for 600 units. Retuned in round 2 for the CREST 1.6 surf ruling: at the old 21 u/s, throttle-max (28) escaped by 7 u/s and surfing (32) was merely a bigger cushion, so the level's signature mechanic was decoration in its own finale. At 24 against flow 20 the required throttle is 1.20x - so the finale reads: dawdling at 12 u/s dies, pushing hard (28) escapes by a white-knuckle ~4.5s, and SURFING (32) escapes clean by ~6s. Surf is now the stylish answer and the level's last exam is its signature verb. Hard mode (surgeSpeed x1.1 = 26.4) needs throttle 1.32+ or a crest; easy (x0.92 = 22.1) forgives at 1.11+. Antacid fired backward still buys ~10s - now the safety net for players who cannot surf rather than the clever trick, which is a better job for it." },
|
||||||
{ "s": 3080, "type": "pickup", "kind": "antacid_ammo", "count": 1, "note": "On the racing line, mid-chase: the one pickup in the level you get for free at speed." },
|
{ "s": 3080, "type": "pickup", "kind": "antacid_ammo", "count": 1, "note": "On the racing line, mid-chase: the one pickup in the level you get for free at speed." },
|
||||||
{ "s": 3120, "type": "spawn", "enemy": "bolus_chunk", "count": 5, "spread": 200, "note": "Traffic, not enemies. They drift 3.5 u/s slower than the current, so at chase pace you close on them fast." },
|
{ "s": 3120, "type": "spawn", "enemy": "bolus_chunk", "count": 5, "spread": 200, "note": "Traffic, not enemies. They drift 3.5 u/s slower than the current, so at chase pace you close on them fast." },
|
||||||
{ "s": 3200, "type": "pickup", "kind": "biopsy_sample", "count": 1, "theta": 4.7124, "rho": 0.85, "note": "SAMPLE 3/3. Cost = the chase itself. Off the racing line by ~1.5s with an acid wall 5s behind you. The cruellest of the three and the only one that is a real decision at full speed." },
|
{ "s": 3200, "type": "pickup", "kind": "biopsy_sample", "count": 1, "theta": 4.7124, "rho": 0.85, "note": "SAMPLE 3/3. Cost = the chase itself. Off the racing line by ~1.5s with an acid wall 5s behind you. The cruellest of the three and the only one that is a real decision at full speed." },
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"schema": 2,
|
||||||
"id": "L3_stomach",
|
"id": "L3_stomach",
|
||||||
"name": "Stomach",
|
"name": "Stomach",
|
||||||
"tagline": "The acid sea",
|
"tagline": "The acid sea",
|
||||||
|
|||||||
@ -19,8 +19,42 @@ export { applyDifficulty, getDifficulty, DIFFICULTIES, DEFAULT_DIFFICULTY } from
|
|||||||
|
|
||||||
const isNode = typeof process !== 'undefined' && !!process.versions?.node;
|
const isNode = typeof process !== 'undefined' && !!process.versions?.node;
|
||||||
|
|
||||||
/** Schema version this module speaks. Levels declare `"schema": N`. */
|
/**
|
||||||
export const SCHEMA = 1;
|
* 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. */
|
/** 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'];
|
export const CAMPAIGN = ['L1_mouth', 'L2_esophagus', 'L3_stomach'];
|
||||||
@ -59,12 +93,38 @@ export const BIOMES_EXPECTED = [
|
|||||||
'oral', 'esophagus', 'stomach', 'small_intestine', 'large_intestine', 'appendix',
|
'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 });
|
* 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. */
|
/** A lethal chase forces the player's hand — no one dawdles in front of an acid wall. */
|
||||||
const CHASE_THROTTLE = 1.35;
|
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
|
// loading
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
@ -122,17 +182,18 @@ async function biomeRegistry() {
|
|||||||
if (m.BIOMES && Object.keys(m.BIOMES).length) {
|
if (m.BIOMES && Object.keys(m.BIOMES).length) {
|
||||||
REG = {
|
REG = {
|
||||||
ids: new Set(Object.keys(m.BIOMES)),
|
ids: new Set(Object.keys(m.BIOMES)),
|
||||||
// Mirror A's waveMaxAt() exactly: amp + breathe, not amp alone.
|
ampFor: (id) => m.BIOMES[id]?.wave?.amp ?? PLAY.PERISTALSIS_AMP,
|
||||||
marginFor: (id) => {
|
breatheFor: (id) => m.BIOMES[id]?.wave?.breathe ?? 0,
|
||||||
const w = m.BIOMES[id]?.wave;
|
|
||||||
return w ? w.amp + (w.breathe ?? 0) : PLAY.PERISTALSIS_AMP;
|
|
||||||
},
|
|
||||||
authoritative: true,
|
authoritative: true,
|
||||||
};
|
};
|
||||||
return REG;
|
return REG;
|
||||||
}
|
}
|
||||||
} catch { /* Lane A hasn't landed yet — fall back to C's expectation */ }
|
} 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 };
|
REG = {
|
||||||
|
ids: new Set(BIOMES_EXPECTED),
|
||||||
|
ampFor: () => PLAY.PERISTALSIS_AMP, breatheFor: () => 0,
|
||||||
|
authoritative: false,
|
||||||
|
};
|
||||||
return REG;
|
return REG;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,9 +255,25 @@ export function scoreBudget(level) {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the wall eats into the lumen — Lane A's per-biome (amp + breathe) when available. */
|
/**
|
||||||
|
* 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) {
|
function waveMargin(seg) {
|
||||||
return REG?.marginFor ? REG.marginFor(seg.biome) : PLAY.PERISTALSIS_AMP;
|
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. */
|
/** Static free-flight radius of a segment at its narrowest wobble trough. */
|
||||||
@ -264,7 +341,7 @@ export function travelTime(level, a, b, k) {
|
|||||||
* hazard = 1 + a per-kind term (see hazardWeight)
|
* hazard = 1 + a per-kind term (see hazardWeight)
|
||||||
*/
|
*/
|
||||||
export function pace(level, { profile = 'par' } = {}) {
|
export function pace(level, { profile = 'par' } = {}) {
|
||||||
const k = PROFILES[profile] ?? PROFILES.par;
|
const k = profileK(profile);
|
||||||
const total = totalLength(level);
|
const total = totalLength(level);
|
||||||
const widest = Math.max(...level.segments.map((s) => s.radius.base));
|
const widest = Math.max(...level.segments.map((s) => s.radius.base));
|
||||||
|
|
||||||
@ -301,6 +378,38 @@ export function pace(level, { profile = 'par' } = {}) {
|
|||||||
return { id: level.id, profile, total, beats, checkpoints: gaps, seconds: tAcc };
|
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) {
|
function hazardWeight(h) {
|
||||||
switch (h.kind) {
|
switch (h.kind) {
|
||||||
case 'aortic_squeeze': return 1 + (h.amplitude ?? 0);
|
case 'aortic_squeeze': return 1 + (h.amplitude ?? 0);
|
||||||
@ -330,7 +439,7 @@ export async function validate(level, { expectId = null } = {}) {
|
|||||||
const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m));
|
const C = (m) => (skel ? W(`${m} [skeleton: downgraded]`) : E(m));
|
||||||
|
|
||||||
// --- structural -----------------------------------------------------------------
|
// --- structural -----------------------------------------------------------------
|
||||||
if (level.schema !== SCHEMA) E(`schema is ${level.schema}, this module speaks ${SCHEMA}`);
|
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 (typeof level.id !== 'string' || !level.id) E('missing id');
|
||||||
if (expectId && level.id !== expectId) E(`id "${level.id}" does not match filename "${expectId}"`);
|
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 (!Number.isInteger(level.seed)) E(`seed must be an integer (got ${JSON.stringify(level.seed)})`);
|
||||||
@ -344,6 +453,7 @@ export async function validate(level, { expectId = null } = {}) {
|
|||||||
if (!authoritative) W(`Lane A's world/biomes.js not present — biome ids checked against Lane C's expected list (${BIOMES_EXPECTED.length} ids)`);
|
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();
|
const bal = await combatBalance();
|
||||||
if (!bal) W("Lane B's combat/balance.js not present — archetype rows and score budget unchecked");
|
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 -------------------------------------------------------------------
|
// --- segments -------------------------------------------------------------------
|
||||||
let s0 = 0;
|
let s0 = 0;
|
||||||
@ -357,6 +467,16 @@ export async function validate(level, { expectId = null } = {}) {
|
|||||||
if (seg.curviness < 0 || seg.curviness > 1) E(`${at}: curviness must be in [0,1] (got ${seg.curviness})`);
|
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`);
|
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".
|
// C's playability invariant — the check that catches "I made a cool tube you can't fly".
|
||||||
const clear = hazardClearance(level, seg, s0);
|
const clear = hazardClearance(level, seg, s0);
|
||||||
if (clear < PLAY.MIN_CLEARANCE) {
|
if (clear < PLAY.MIN_CLEARANCE) {
|
||||||
@ -435,15 +555,28 @@ export async function validate(level, { expectId = null } = {}) {
|
|||||||
if (secs > 30) C(`checkpoint gap ${cp.s}..${next} ("${cp.name ?? ''}") = ${secs.toFixed(1)}s at par throttle — over the 30s death-cost law`);
|
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')
|
const samples = (level.events ?? []).filter((e) => e.type === 'pickup' && e.kind === 'biopsy_sample')
|
||||||
.reduce((a, e) => a + (e.count ?? 1), 0);
|
.reduce((a, e) => a + (e.count ?? 1), 0);
|
||||||
if (samples !== 3) C(`${samples} biopsy samples — the GDD says exactly 3 per level`);
|
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.
|
// par sanity: an unbeatable par is a bug, a free par is a waste.
|
||||||
if (level.par?.time > 0) {
|
if (level.par?.time > 0) {
|
||||||
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
const fast = pace(level, { profile: 'surf' }).seconds;
|
||||||
const slow = pace(level, { profile: 'cautious' }).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`);
|
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 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');
|
} else C('no par.time');
|
||||||
|
|
||||||
@ -495,12 +628,15 @@ async function cliSelfcheck() {
|
|||||||
async function cliSim(only) {
|
async function cliSim(only) {
|
||||||
const reg = await biomeRegistry(); // so clearance uses Lane A's real wave amplitudes
|
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 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(`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(`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) {
|
for (const id of CAMPAIGN) {
|
||||||
if (only && id !== only) continue;
|
if (only && id !== only) continue;
|
||||||
const level = await loadJSON(`./${id}.json`);
|
const level = await loadJSON(`./${id}.json`);
|
||||||
const p = pace(level, { profile: 'par' });
|
const p = pace(level, { profile: 'par' });
|
||||||
|
const surf = pace(level, { profile: 'surf' }).seconds;
|
||||||
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
const fast = pace(level, { profile: 'speedrun' }).seconds;
|
||||||
const slow = pace(level, { profile: 'cautious' }).seconds;
|
const slow = pace(level, { profile: 'cautious' }).seconds;
|
||||||
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
|
||||||
@ -509,7 +645,12 @@ async function cliSim(only) {
|
|||||||
const budget = scoreBudget(level);
|
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 ?? '?'}` +
|
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}%)` : ''));
|
(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)`);
|
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);
|
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');
|
console.log('\n beat s-range len flow en dens tight haz PRESSURE clear t cum');
|
||||||
for (const b of p.beats) {
|
for (const b of p.beats) {
|
||||||
@ -523,6 +664,23 @@ async function cliSim(only) {
|
|||||||
const totEn = p.beats.reduce((a, b) => a + b.enemies, 0);
|
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)}`);
|
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) {
|
if (p.checkpoints.length) {
|
||||||
console.log('\n checkpoint gaps (death cost at par throttle; law: <= 30s)');
|
console.log('\n checkpoint gaps (death cost at par throttle; law: <= 30s)');
|
||||||
for (const g of p.checkpoints) {
|
for (const g of p.checkpoints) {
|
||||||
@ -533,7 +691,90 @@ async function cliSim(only) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNode && (process.argv.includes('--selfcheck') || process.argv.includes('--sim'))) {
|
/**
|
||||||
|
* `--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')) {
|
if (process.argv.includes('--sim')) {
|
||||||
const i = process.argv.indexOf('--sim');
|
const i = process.argv.indexOf('--sim');
|
||||||
await cliSim(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);
|
await cliSim(process.argv[i + 1]?.startsWith('--') ? null : process.argv[i + 1]);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user