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>
68 lines
3.1 KiB
JavaScript
68 lines
3.1 KiB
JavaScript
// levels/difficulty.js (Lane C) — the single difficulty table. GDD §Difficulty & assists.
|
|
//
|
|
// LAW (charter): no per-level hand-tuned forks. A level is authored once, at `normal`, and
|
|
// every difficulty is this table applied to that one authored level. If you ever find
|
|
// yourself wanting "but on hard, level 4 should...", the answer is a new event in the level,
|
|
// not a branch in here.
|
|
//
|
|
// Pure data + pure functions, no imports, node-runnable.
|
|
|
|
/**
|
|
* coatDrain x ambient mucus-coat drain rate (the pH pressure)
|
|
* density x enemy counts (rounded; deterministic)
|
|
* window x boss/hazard timing windows — the one that actually moves the skill floor
|
|
* surgeSpeed x chase-hazard speed (reflux surges)
|
|
* contact x contact/hull damage taken
|
|
* assists defaults for the assist toggles (player can always override — GDD)
|
|
*/
|
|
export const DIFFICULTIES = Object.freeze({
|
|
easy: {
|
|
label: 'Endoscopy', coatDrain: 0.7, density: 0.75, window: 1.3, surgeSpeed: 0.92, contact: 0.8,
|
|
assists: { flowAutoCenter: true, aimMagnetism: true },
|
|
note: 'For players here to see the body, not to beat it. Windows are the big lever: 1.3x turns the hiatus ring gate from a skill check into a rhythm.',
|
|
},
|
|
normal: {
|
|
label: 'Clinical', coatDrain: 1.0, density: 1.0, window: 1.0, surgeSpeed: 1.0, contact: 1.0,
|
|
assists: { flowAutoCenter: false, aimMagnetism: true },
|
|
note: 'The authored experience. Every number in every level JSON is a normal-mode number.',
|
|
},
|
|
hard: {
|
|
label: 'Terminal', coatDrain: 1.35, density: 1.25, window: 0.8, surgeSpeed: 1.1, contact: 1.25,
|
|
assists: { flowAutoCenter: false, aimMagnetism: false },
|
|
note: 'surgeSpeed 1.1 puts the L2 finale at 23.1 u/s against a 20 u/s current: the chase stops being a sprint and becomes a route-planning problem (antacid or die).',
|
|
},
|
|
});
|
|
|
|
export const DEFAULT_DIFFICULTY = 'normal';
|
|
|
|
export function getDifficulty(id = DEFAULT_DIFFICULTY) {
|
|
return DIFFICULTIES[id] ?? DIFFICULTIES[DEFAULT_DIFFICULTY];
|
|
}
|
|
|
|
/**
|
|
* → a NEW level object with difficulty applied. Never mutates. Deterministic (same input =>
|
|
* same output, no RNG), so it is safe to run before world generation.
|
|
* Only scales what the table says it scales; everything else passes through untouched.
|
|
*/
|
|
export function applyDifficulty(level, id = DEFAULT_DIFFICULTY) {
|
|
const d = getDifficulty(id);
|
|
if (id === 'normal') return level;
|
|
const events = level.events.map((e) => {
|
|
if (e.type === 'spawn' && typeof e.count === 'number') {
|
|
const count = Math.max(1, Math.round(e.count * d.density));
|
|
// wall enemies carry one theta per instance — keep the arrays consistent.
|
|
if (Array.isArray(e.theta)) {
|
|
const theta = [];
|
|
for (let i = 0; i < count; i++) theta.push(e.theta[i % e.theta.length]);
|
|
return { ...e, count, theta };
|
|
}
|
|
return { ...e, count };
|
|
}
|
|
if (e.type === 'hazard' && e.kind === 'reflux_surge' && typeof e.speed === 'number') {
|
|
return { ...e, speed: Math.round(e.speed * d.surgeSpeed * 10) / 10 };
|
|
}
|
|
return e;
|
|
});
|
|
return { ...level, events, difficulty: id };
|
|
}
|