[lane B+C] The combo meter starts paying, allies stop counting as kills, and par stops

meaning "miss nothing"

Audit item 13 — the systems the levels were authored against, none of which worked.

THE COMBO MULTIPLIER DID NOT MULTIPLY. balance.js's own comment cites the GDD line "score x style
multiplier"; what shipped was a 2.5 s window and a counter. The HUD printed "x7 chain",
comms shouted about it at 5, the music added a layer at 3 — and combat/index.js added the flat
base score every time. Every par.score in every level was authored against a lever that did not
exist, which is exactly why they all sat at 75-94% of the un-multiplied ceiling. Now step 0.12,
cap 2.5 at a 14-chain: reachable in L4's ileum and nowhere else, because a cap you hit every
fight is not a skill expression. Verified in the running game: six chained floaters pay
100/112/124/136/148/160 where they used to pay 600 flat.

ALLIES COUNTED AS KILLS. combat/index.js did an unconditional score.kills++ on enemy:die, and
the ally branch of enemies.js emits enemy:die with score 0 — so shooting a lactobacillus reef,
the one act this game's whole reputation loop exists to punish, ticked your PATHOGENS tally up
by four. The pause card and the operative record told you that you were doing well at the exact
moment the biome decided otherwise. enemy:die carries `foe` now. Verified: seven deaths, six
kills.

THE PAR CHECK WAS COUNTING A THIRD OF THE GAME. scoreBudget() summed spawn events and nothing
else — not the 500 points a biopsy sample is worth, not the several thousand a boss puts in the
room. Four of six levels therefore shipped a permanent "par.score exceeds the kill budget"
warning, and a warning that is wrong two thirds of the time is one everybody learns to scroll
past. It counts pickups and boss rings now (nodes once per phase — a floor, since a phase
re-rings for as long as the fight lasts and no honest ceiling exists). Six levels, zero
warnings, and the ratios that were 94/75/52/59/44/32% of a fictional budget are now real.

AND THE CHECK THAT SHOULD HAVE CAUGHT L1. The only par test asked whether par exceeded the
ceiling. L1 sat at 94% of it: par the tutorial and you had to kill all eight foes, take all
seven orbs and all three samples — miss ONE sample and the medal was gone. That is a
clear-sheet requirement wearing par's clothes, in the level that exists to teach. par.score is
1900 now (71%), and the validator warns above 85% so the next one gets caught by a machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 22:19:34 +10:00
parent 9482b8a2bf
commit 975e1a4ec3
6 changed files with 80 additions and 14 deletions

View File

@ -199,5 +199,20 @@ export const BALANCE = {
// Chained kills feed the combo meter (GDD: score × style multiplier; E's music layer // 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. // 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 7697% 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);
}

View File

@ -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). // 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). // bloom {every, kinds, cap} — adds spawned during the phase (quorum sensing).
// doom seconds before the phase kills you if you have not finished it. // 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: { pyloric_guardian: {
telegraph: 2.0, // C: "a deep gulp 2 s before every open" telegraph: 2.0, // C: "a deep gulp 2 s before every open"
node: 'mucosal_node', node: 'mucosal_node',

View File

@ -13,7 +13,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; 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'; import { emissiveMat, EMISSIVE } from '../flight/emissive.js';
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _scale = new THREE.Vector3(); 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 // 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. // mechanical. A phage just bursts ('spent') or is popped, quietly.
if (e.type === 'flora' && kind !== 'spent') bus.emit('flora:harmed', { s: e.s }); 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' }); bus.emit('audio:cue', { name: 'enemy_die' });
onDeath?.(e); onDeath?.(e);
return; 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 if (sp) { sp.x = e.x; sp.y = e.y; } // at the death spot, not a random arc
} }
comboN++; comboT = B.combo.window; 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('combo', { n: comboN });
bus.emit('audio:cue', { name: 'enemy_die' }); bus.emit('audio:cue', { name: 'enemy_die' });
onDeath?.(e); onDeath?.(e);

View File

@ -66,7 +66,10 @@ export function createCombat({ scene, world, bus, rng, flags = {}, assets = null
const offDie = bus.on('enemy:die', (e) => { const offDie = bus.on('enemy:die', (e) => {
score.value += e.score ?? 0; 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 // pickups.js applies the effect (refill/ammo) and announces; the score lives here so

View File

@ -4,7 +4,7 @@
"name": "Oral Cavity", "name": "Oral Cavity",
"tagline": "Learn to swim", "tagline": "Learn to swim",
"seed": 20260701, "seed": 20260701,
"par": { "time": 300, "score": 2500, "samples": 3 }, "par": { "time": 300, "score": 1900, "samples": 3 },
"next": "L2_esophagus", "next": "L2_esophagus",
"design": { "design": {
"owner": "Lane C", "owner": "Lane C",

View File

@ -236,12 +236,19 @@ const isSkeleton = (level) => /^SKELETON/.test(level.design?.status ?? '');
* reads it rather than restating it. * reads it rather than restating it.
*/ */
let BAL = null; let BAL = null;
let BOSS = null; // combat/boss.js BOSSES, for pricing a boss into scoreBudget
async function combatBalance() { async function combatBalance() {
if (BAL !== null) return BAL; if (BAL !== null) return BAL;
try { try {
const m = await import('../combat/balance.js'); // Lane B's, when it lands 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 */ } } catch { /* Lane B hasn't landed yet */ }
BAL = false; BAL = false;
return BAL; return BAL;
@ -254,15 +261,37 @@ export function scoreFor(enemyId) {
return BAL.enemies?.[spec.archetype]?.score ?? null; 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) { export function scoreBudget(level) {
if (!BAL) return null; if (!BAL) return null;
let total = 0; let total = 0;
for (const e of level.events ?? []) { for (const e of level.events ?? []) {
if (e.type !== 'spawn') continue; if (e.type === 'spawn') {
const s = scoreFor(e.enemy); const s = scoreFor(e.enemy);
if (s == null) return null; if (s == null) return null;
total += s * (e.count ?? 1); 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; 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')) { if (level.par?.score > 0 && (level.events ?? []).some((e) => e.type === 'spawn')) {
const budget = scoreBudget(level); const budget = scoreBudget(level);
if (budget != null && level.par.score > budget) 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 // gate wiring