[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:
parent
9482b8a2bf
commit
975e1a4ec3
@ -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);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user