diff --git a/web/js/combat/balance.js b/web/js/combat/balance.js index 594d9af..1119290 100644 --- a/web/js/combat/balance.js +++ b/web/js/combat/balance.js @@ -199,5 +199,20 @@ export const BALANCE = { // Chained kills feed the combo meter (GDD: score × style multiplier; E's music layer // listens). Window is generous enough to cross a fold, tight enough to mean something. - combo: { window: 2.5 }, + // + // THE MULTIPLIER IS REAL NOW. For two rounds this was a window and nothing else: the HUD + // printed "×7 chain", comms shouted about it, the music added a layer, and the score went up + // by exactly the base value every time. Every par.score in every level was authored against + // a multiplier that did not exist, which is why they all sit at 76–97% of the no-multiplier + // ceiling — par was not "good play", it was "miss nothing". + // step 0.12, cap 2.5: a 5-chain pays 1.48x, a 10-chain 2.08x, and 14 is the ceiling. Chosen + // so the cap is reachable in the game's densest room (L4's ileum, 27 spawn events) and + // nowhere else — a cap you hit every fight is not a skill expression. + combo: { window: 2.5, step: 0.12, cap: 2.5 }, }; + +/** Chain length → score multiplier. n<=1 is 1x: the first kill of a chain is not a chain. */ +export function comboMult(n) { + if (!(n > 1)) return 1; + return Math.min(BALANCE.combo.cap, 1 + (n - 1) * BALANCE.combo.step); +} diff --git a/web/js/combat/boss.js b/web/js/combat/boss.js index a58f078..fed9571 100644 --- a/web/js/combat/boss.js +++ b/web/js/combat/boss.js @@ -39,7 +39,11 @@ import { BALANCE as B } from './balance.js'; // vents turrets at fixed arcs (their homing dart IS an acid spout / a spore jet). // bloom {every, kinds, cap} — adds spawned during the phase (quorum sensing). // doom seconds before the phase kills you if you have not finished it. -const BOSSES = { +// Exported so Lane C's validator can price a boss into a level's score budget. It used to +// count spawn events only, which meant every level with a boss looked poorer than it is — +// four of six levels carried a "par.score exceeds the kill budget" warning that was simply +// the validator not knowing bosses drop bodies. +export const BOSSES = { pyloric_guardian: { telegraph: 2.0, // C: "a deep gulp 2 s before every open" node: 'mucosal_node', diff --git a/web/js/combat/enemies.js b/web/js/combat/enemies.js index 01deed6..fc5ab40 100644 --- a/web/js/combat/enemies.js +++ b/web/js/combat/enemies.js @@ -13,7 +13,7 @@ import * as THREE from 'three'; import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; -import { BALANCE as B } from './balance.js'; +import { BALANCE as B, comboMult } from './balance.js'; import { emissiveMat, EMISSIVE } from '../flight/emissive.js'; const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3(); @@ -319,7 +319,11 @@ export function createEnemies({ scene, world, bus, rng, assets = null, flags = { // own fire (not 'spent') signals the BIOME STANDING meter — "don't shoot the biome", made // mechanical. A phage just bursts ('spent') or is popped, quietly. if (e.type === 'flora' && kind !== 'spent') bus.emit('flora:harmed', { s: e.s }); - bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: 0, kind }); + // `foe: false` is not decoration. combat/index.js counted EVERY enemy:die as a kill, so + // shooting a lactobacillus reef — the one act this game's reputation loop exists to + // punish — also ticked your PATHOGENS score up by four. The record said you were doing + // well at the exact moment the biome decided you were not. + bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: 0, foe: false, kind }); bus.emit('audio:cue', { name: 'enemy_die' }); onDeath?.(e); return; @@ -329,7 +333,12 @@ export function createEnemies({ scene, world, bus, rng, assets = null, flags = { if (sp) { sp.x = e.x; sp.y = e.y; } // at the death spot, not a random arc } comboN++; comboT = B.combo.window; - bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: e.cfg.score, kind }); + // The chain PAYS now. `score` is what the player is actually awarded; `base` and `mult` ride + // along so a readout can show the multiplier doing its work rather than an unexplained + // bigger number. + const mult = comboMult(comboN); + const award = Math.round(e.cfg.score * mult); + bus.emit('enemy:die', { id: e.id, type: e.type, s: e.s, score: award, base: e.cfg.score, mult, foe: true, kind }); bus.emit('combo', { n: comboN }); bus.emit('audio:cue', { name: 'enemy_die' }); onDeath?.(e); diff --git a/web/js/combat/index.js b/web/js/combat/index.js index e155f17..a28aa60 100644 --- a/web/js/combat/index.js +++ b/web/js/combat/index.js @@ -66,7 +66,10 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null const offDie = bus.on('enemy:die', (e) => { score.value += e.score ?? 0; - score.kills++; + // Allies do not count. This was an unconditional ++, so flora and spent phages — which + // score 0 precisely because they are not pathogens — still raised the PATHOGENS tally on + // the pause card and the operative record. + if (e.foe !== false) score.kills++; }); // pickups.js applies the effect (refill/ammo) and announces; the score lives here so diff --git a/web/js/levels/L1_mouth.json b/web/js/levels/L1_mouth.json index d0d6100..4108c76 100644 --- a/web/js/levels/L1_mouth.json +++ b/web/js/levels/L1_mouth.json @@ -4,7 +4,7 @@ "name": "Oral Cavity", "tagline": "Learn to swim", "seed": 20260701, - "par": { "time": 300, "score": 2500, "samples": 3 }, + "par": { "time": 300, "score": 1900, "samples": 3 }, "next": "L2_esophagus", "design": { "owner": "Lane C", diff --git a/web/js/levels/index.js b/web/js/levels/index.js index be6d911..db113fa 100644 --- a/web/js/levels/index.js +++ b/web/js/levels/index.js @@ -236,12 +236,19 @@ const isSkeleton = (level) => /^SKELETON/.test(level.design?.status ?? ''); * reads it rather than restating it. */ let BAL = null; +let BOSS = null; // combat/boss.js BOSSES, for pricing a boss into scoreBudget 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; } + if (m.BALANCE?.enemies) { + BAL = m.BALANCE; + // Same lane, same optionality: boss.js imports only balance.js (no THREE), so it loads + // headless. A missing boss table just means bosses price at zero, as before. + BOSS = await import('../combat/boss.js').then((b) => b.BOSSES ?? null).catch(() => null); + return BAL; + } } catch { /* Lane B hasn't landed yet */ } BAL = false; return BAL; @@ -254,15 +261,37 @@ export function scoreFor(enemyId) { return BAL.enemies?.[spec.archetype]?.score ?? null; } -/** → total points available from kills, or null. Used to sanity-check par.score. */ +/** + * → total points available in a level, or null if Lane B's balance isn't loaded. + * + * Counts everything that scores: spawned enemies, scoring pickups, and the bodies a boss puts + * in the room. It counted spawn events ALONE, which is why four of six levels shipped with a + * "par.score exceeds the kill budget" warning — the budget was ignoring 500 points a biopsy + * sample and several thousand points of boss. A warning that is wrong two thirds of the time + * is a warning everyone learns to scroll past, which is worse than no warning. + * + * Deliberately does NOT model the combo multiplier: that is upside, so a par under this number + * is reachable on a chain of one, which is the property the check wants. + * + * Boss nodes are counted ONCE PER PHASE, a floor rather than a ceiling — a phase re-rings on + * its `period` for as long as the fight lasts, so the true figure is unbounded and no honest + * ceiling exists. A floor can only ever make this check stricter than reality. + */ 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); + if (e.type === 'spawn') { + const s = scoreFor(e.enemy); + if (s == null) return null; + total += s * (e.count ?? 1); + } else if (e.type === 'pickup') { + total += (BAL.pickups?.[e.kind]?.score ?? 0) * (e.count ?? 1); + } else if (e.type === 'boss') { + const b = BOSS?.[e.id]; + const node = BAL.enemies?.[b?.node]?.score ?? 0; + for (const ph of b?.phases ?? []) total += node * (ph.nodes ?? 0); + } } return total; } @@ -608,7 +637,13 @@ export async function validate(level, { expectId = null } = {}) { 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`); + E(`par.score ${level.par.score} exceeds everything in the level (${budget}) — unachievable`); + // A par you can only hit by collecting literally everything is not a par, it is a + // completion requirement wearing par's clothes. L1 sat at 94% — miss ONE biopsy sample in + // the tutorial and the medal was gone — and nothing said so, because the only check asked + // whether par was over the ceiling. 85% leaves room for one missed pickup on every level. + else if (budget > 0 && level.par.score > budget * 0.85) + W(`par.score ${level.par.score} is ${((level.par.score / budget) * 100) | 0}% of everything in the level (${budget}) — that is a clear-sheet requirement, not a par`); } // gate wiring