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