guts/web/js/levels/difficulty.js
jing 1b5ec767cd [lane C] Round 1: schema v1, levels registry + selfcheck + pacing sim, L2 vertical slice
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>
2026-07-16 08:53:34 +10:00

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 };
}