guts/web/js/combat/balance.js
type-two 975e1a4ec3 [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>
2026-07-26 22:19:34 +10:00

219 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// combat/balance.js (Lane B) — every combat number. Sibling of flight/tuning.js: that file
// decides how ENDO-1 handles, this one decides what the fight is worth.
//
// Sized against the stub esophagus (flow 14 u/s, playable disc radius ~7..10 u) and against
// the player's own numbers: disc top speed 13 u/s, coat 100 with 8/s regen after 2.5 s.
export const BALANCE = {
// Lysozyme cannon: infinite ammo, heat-limited (GDD). Heat is the whole design — the gun
// is free, so the cost has to be time. Sustained fire nets +25 heat/s (11 shots/s × 5.5,
// less 30/s cooling) => ~4 s of held fire before lockout, then ~2 s to recover to resumeAt.
// Burst and you never see the meter; hold and you're disarmed exactly when it's busiest.
cannon: {
rof: 11, // shots/s
speed: 95, // u/s in world space (closing speed ~75 with player flow)
life: 1.15, // s => ~109 u range, comfortably past the fog
damage: 12,
radius: 0.35,
heatPerShot: 5.5,
coolRate: 30, // /s
overheatAt: 100,
resumeAt: 40, // hysteresis — no stutter-firing on the edge of lockout
spread: 0.004, // rad, seeded — a hair of life, not a handicap
pool: 96, // rof × life = ~13 in flight; 96 covers spikes with room
},
// Antacid torpedo: the dual-use secondary (GDD). Also neutralizes an acid zone for ~10 s
// via the level:neutralize bus event — A/C consume it. Ammo-limited, pickup-fed (round 2).
torpedo: {
rof: 0.8, speed: 42, life: 3.2, damage: 45, splash: 9, radius: 0.7,
ammo: 6, ammoMax: 6,
neutralizeRadius: 14, neutralizeDuration: 10,
pool: 8,
},
enemies: {
// amylase droplet — slow floater with a corrosive contact aura. A soft obstacle: it
// doesn't chase, it taxes the lane it sits in. Aura ticks discretely (see enemies.js).
floater: { hp: 24, radius: 2.2, score: 100, drift: 1.6, bob: 0.5,
auraRadius: 4.2, auraDps: 18, auraTick: 0.2, pool: 24 },
// pepsin — small fast seeker, dissolves on contact. Chases in spline space (s,x,y), so
// a pursuit costs three floats. speed 19 > player flow 14: it WILL catch you from
// behind, and the answer is to shoot it or boost, not to outrun it at throttle 1.4.
seeker: { hp: 14, radius: 1.0, score: 150, speed: 19, turn: 2.4, contact: 16,
aggroS: 60, pool: 32 },
// antibody turret (Peyer's patch) — wall-mounted, fires homing darts. Dart turn radius
// is speed/turn = 24/1.8 = 13.3 u, wider than the tube itself: it can never corner you,
// only punish a straight line. Dodgeable by design (charter).
turret: { hp: 40, radius: 1.6, score: 250, fireEvery: 1.9, rangeS: 55,
dartSpeed: 24, dartTurn: 1.8, dartLife: 4.5, dartDamage: 14, pool: 12 },
// flora — the first FRIEND. A commensal (Lactobacillus &c) that drifts like a floater but
// HELPS: brush its aura and it sheds `coat` mucus per tick instead of taking it. score 0 and
// no combo (enemies.js kill()): it sits in the enemy pool so it is shootable, but shooting
// it is the reputation trap, never a reward. `coat` kept small + no parking in a flow tube =
// a trickle, not a fountain. hp low: a stray pellet shouldn't wipe a colony you meant to spare.
// No `gain` => flora:tended defaults to the small +5 standing in combat/index.js.
flora: { hp: 18, radius: 2.0, score: 0, drift: 1.2, bob: 0.4,
auraRadius: 3.6, coat: 6, auraTick: 0.3, pool: 24 },
// mucus_grazer (Akkermansia) — a commensal that eats your mucin. Brushing it COSTS `coatCost`
// per tick but signals gut health hardest (`gain` 12 vs flora's 5): the "spend coat for
// faster allies" trade. Shares flora's drift+aura branch, sign-flipped by data. score 0,
// shootable (harming it is still flora:harmed). The clever risk/reward of the reputation loop.
mucus_grazer: { hp: 20, radius: 2.2, score: 0, drift: 1.0, bob: 0.35,
auraRadius: 4.0, coatCost: 4, gain: 12, auraTick: 0.3, pool: 16 },
// spore_pod — a foe budding-yeast/Candida cluster. Mechanically the floater (drift + corrosive
// aura) with its OWN pool so its blobby mesh reads distinct from tumbling food debris. The
// first entity authored to be skinned by a Trellis mesh (assets/models/spore_pod.glb).
spore_pod: { hp: 30, radius: 2.4, score: 120, drift: 1.4, bob: 0.5,
auraRadius: 4.4, auraDps: 20, auraTick: 0.2, pool: 16 },
// phage — the ALLY (bacteriophage). A friendly seeker that hunts the nearest FOE and rams
// it (`punch`), bursting after `bursts` hits (a phage lyses its host and dies). FAST (26 >
// every foe) so it actually catches things; FRAGILE (hp 8) so a stray pellet or a foe pops
// it. score 0 — an ally never scores. Budded by high BIOME STANDING (combat/index.js), the
// payoff for tending flora. escort* = where it loiters when no foe is in `huntS` range.
phage: { hp: 8, radius: 1.1, score: 0, speed: 26, turn: 3.2, punch: 20, bursts: 2,
huntS: 70, escortLead: 10, escortR: 5, pool: 8 },
// cdiff (C. difficile) — a foe floater (drift + corrosive aura) whose corpse ISN'T clean:
// on death it leaves a spore_cloud (enemies.js kill()). Killing it costs you the ground it
// died on. A colon (L5) enemy; registered now, placed when L5 gets its encounter pass.
cdiff: { hp: 26, radius: 2.3, score: 140, drift: 1.2, bob: 0.45,
auraRadius: 4.0, auraDps: 14, auraTick: 0.2, pool: 12 },
// spore_cloud — C. diff's parting gift. A STATIC corrosive zone (acid-green, readability law)
// that damages on contact and expires after `life`s. Not scored (foe:false), but shootable:
// low hp so you CAN clear it with the cannon — spend heat to disperse it, or fly around.
spore_cloud: { hp: 14, radius: 3.2, score: 0, auraRadius: 4.5, auraDps: 22, auraTick: 0.25,
life: 5, pool: 12 },
// latcher (Giardia) — a foe that CHASES like a seeker, then CLAMPS onto your hull (its
// ventral suction disc) and drains `drain` coat/tick while it rides. The new verb is
// "shake it off": a boost's i-frames throw it, else it lets go after `attachMax`s, then
// can't re-grab for `reattachCd`s. speed 20 > flow so it catches you; the counterplay is
// timing a boost, not out-running it.
latcher: { hp: 16, radius: 1.4, score: 160, speed: 20, turn: 2.6, aggroS: 55,
drain: 8, drainTick: 0.3, attachMax: 3.5, reattachCd: 1.5, pool: 12 },
// virion (norovirus) — the ARITHMETIC swarm: tiny, fast, individually trivial (hp 6, one
// pellet), lethal only in numbers. Shares the seeker behaviour (chase + dissolve on contact)
// but its own tiny-radius pool of 40, so a norovirus cloud reads as a swarm, not a squad.
virion: { hp: 6, radius: 0.7, score: 60, speed: 22, turn: 2.8, contact: 8, aggroS: 55, pool: 40 },
// driller (H. pylori) — the one microbe that thrives in the stomach. A TANKY corkscrewer:
// chases like a seeker but spirals (spin/corkscrew) so it's hard to pin, and it does NOT
// dissolve on contact — it grinds you and keeps coming. hp 50 = you must commit real fire.
driller: { hp: 50, radius: 1.6, score: 220, speed: 16, turn: 2.0, contact: 18, aggroS: 60,
spin: 4, corkscrew: 6, pool: 8 },
// mucosal_node — the Pyloric Guardian's rim nodes (LANE_C_NOTES §Boss specs). Fixed: no
// movement branch in enemies.update, which is exactly right for a node bolted to an iris.
// It is combat/boss.js that makes them a boss — the phase cycle flips `invuln` so they are
// only shootable while the sphincter relaxes. This `hp` is only the default — boss.js sets
// it per phase (150 for the rim rings, 60 for P3's core, which is C's "x5 hits").
// pool 12, not 9: a fight spawns 4 + 4 + 1 rings, and the retired ones only leave the pool
// when enemies.reap() next runs. Sizing to the exact total would make phase 3 depend on
// reap timing, and a boss that silently spawns no core is the worst bug in the file.
mucosal_node: { hp: 150, radius: 2.6, score: 400, pool: 12 },
// gas_pocket — L5's signature (GDD: "flammable gas pockets: shooting inside one detonates it
// (hurts everyone — risk/reward)"). Static, and hp 10 means ONE cannon pellet sets it off:
// the danger has to be a hair-trigger or "don't shoot near it" is not a real constraint.
// foe:false, so it scores nothing itself and allies ignore it — you get paid for what the
// blast KILLS, not for the barrel, which makes the reward self-balancing. It is also the
// level's light source (ART_BIBLE: darkness is the medium, the pockets glow), so a room full
// of them is a room you can see, and clearing them is clearing the lights.
gas_pocket: { hp: 10, radius: 3.6, score: 0, blast: 16, blastPlayer: 34, blastEnemy: 90, pool: 20 },
// mast_cell — L1's sentinel (LANE_C_NOTES §Boss specs 5). A patrolling immune cell with NO
// attack: auraDps 0 means it shares the floater's drift branch and its aura does nothing at
// all. hp 400 is not a health bar, it is a THRESHOLD — high enough that killing it takes
// deliberate sustained fire, so the cascade is never something a player did by accident with
// one stray pellet. score 0 and foe:false: there is no reward here, in any sense.
mast_cell: { hp: 400, radius: 3.2, score: 0, drift: 1.1, bob: 0.3,
auraRadius: 4.0, auraDps: 0, auraTick: 0.5, pool: 2 },
// beacon (Faecalibacterium) — the reward-state commensal. The most anti-inflammatory bug in
// you; a screen of these IS a healthy gut. Shares the commensal branch: heals AND gives the
// biggest standing (gain 25) with no coat cost. Rare (pool 10) — the pure payoff node.
beacon: { hp: 22, radius: 2.0, score: 0, drift: 0.9, bob: 0.5,
auraRadius: 4.2, coat: 8, gain: 25, auraTick: 0.3, pool: 10 },
},
darts: { pool: 48, radius: 0.45 },
// --- hazards (round 2) -------------------------------------------------------------
// C authors WHERE and HOW MUCH in the level JSON (speed/period/amplitude/span/warn); these
// are the mechanical constants those params drive. C's params always win where they overlap.
hazards: {
// reflux_surge — the rear chaser. C's finale: speed 21 vs flow 20, spawning 40 u behind
// and chasing 600 u. Lethal on contact by C's authoring (`lethal: true`); the s=1700
// teaching burp is `lethal: false` and only grazes. Antacid stalls it (`neutralizable`).
surge: {
graze: 26, // coat damage/tick for the NON-lethal teaching version
grazeTick: 0.35, // s between graze ticks while inside it
stallFactor: 0.0, // speed multiplier while neutralised — 0 = fully parked.
// C's design leans on this: "fired backward it stalls the surge
// ~10 s" (their finale maths assumes a real stop, not a slow).
proximityRange: 220, // u within which we emit hazard:proximity (E's rear indicator)
catchRadius: 3.0, // u of s-overlap that counts as being caught
},
// aortic_squeeze — DIRECTIONAL by design (C): the arch presses from `theta` only and the
// opposite arc is the answer. Non-lethal, and C's teach-then-test ledger depends on that
// staying true (LANE_C_NOTES: "if B makes squeeze contact lethal, this breaks — tell me").
squeeze: {
graze: 14, // coat damage per contact tick
grazeTick: 0.3,
shove: 9, // u/s pushed off the bulging wall — the hazard MOVES you
arc: 1.9, // rad of the cross-section the bulge occupies (~110°, one side)
},
// ring_gate — a POINT, therefore timeable: the player modulates throttle to arrive on an
// open beat. Closed contact is expensive but never lethal (C: it is a skill check, and
// a flow-locked player cannot stop to wait).
gate: {
hit: 30, // coat damage for arriving on a closed beat
shove: 6, // u/s shove toward the centreline (the iris squeezes you through)
irisMin: 0.12, // fraction of the lumen still open when fully "sealed" — never a
// true wall: a flow-locked player physically cannot stop, so a
// real seal would be an unavoidable toll rather than a skill check
},
},
// --- pickups (round 2) -------------------------------------------------------------
// C owns placement + counts (level JSON); B owns what each one is WORTH. C's round-2 task
// is to hand over an economy — until it lands these are B's proposal, documented in NOTES.
pickups: {
radius: 1.5, // generous collect radius: pickups are a reward, not a dexterity test
spin: 1.4, // rad/s idle rotation
nutrient_orb: { score: 50, boost: 0.6, note: 'score + boost recharge (shaves cooldown)' },
mucin_glob: { coat: 35 },
b12_cell: { hull: 30 },
antacid_ammo: { ammo: 1 },
biopsy_sample: { score: 500, sample: 1 },
},
// 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.
//
// 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);
}