guts/web/js/combat/boss.js
type-two a3784c0371 [lane B+C] L5 Large Intestine: the dark, the gas, and The Blockage — the campaign is complete
The last level. 6 segments, 3200 units, 51 events, and the end of the game: `next: null`.

THE GAS. GDD: "flammable gas pockets: shooting inside one detonates it (hurts everyone —
risk/reward)". Built literally, as an entity whose kill() throws a blast:
  - one cannon pellet sets it off (hp 10) — the hazard has to be hair-trigger or "don't shoot
    near it" is not a constraint;
  - the blast reaches 16 units, FALLS OFF with distance, wipes foes, hurts YOU, and chains
    through other pockets, so a corridor of them is a fuse;
  - foe:false, so the pocket itself scores nothing — you are paid for what the blast KILLS,
    which makes the reward self-balancing.
Measured: one pellet into a line of four detonated all four; a blast took 4 virions and paid
240; the player takes 30.3 coat point-blank, 15.4 at mid range and 0 at 20 units. "Detonate
from max range" — which C's Blockage spec calls optimal play — is a real, measurable skill.

And the cruelty that makes the level: fog 0.075 is the densest in the game and there are no
real-time lights in GUTS, so THE POCKETS ARE THE LIGHTING. Clearing a room safely also clears
the lights. No new rendering path was added for the dark; the biome's fog and the emissives
already did it.

THE BLOCKAGE (LANE_C_NOTES §Boss specs 3) — 5 armoured plates -> 4 tumbling chunks -> the core
under a 30s doom timer, with gas pockets seeded into every phase, so the boss's own room is its
worst weakness. First cut ran 25.7s, shorter than the mid-game Guardian, which is the wrong
shape for a finale — its windows are continuous by spec, so hp went 120/130/150 -> 220/230/280.
Now: 49.7s brute-forcing the plates, 30.2s using the gas. The room is the weapon, and the
difference is 40%.

The reputation loop lands here as designed: two biofilm gates, the second sixty units from the
final boss. Five levels of deciding whether to shoot the cyan resolve into three phage escorts
and a full coat, or nothing at all, in the dark, at the door.

Registering L5 also closes two dangling threads — L4's ileocecal valve and LS_appendix's exit
both pointed at it and now resolve.

C's tooling: 6/6 levels valid, sim par 540 vs par-pace 505 (reachable, with room for the boss),
pressure 1.33/4.09/3.21/5.75/4.12, min clearance 8.30 vs the 2.5 law. par.score corrected
7000 -> 4000 and a missing checkpoint added at 3060 (the 30s law caught a 34.8s gap I had left
between the boss and the gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:35:28 +10:00

388 lines
20 KiB
JavaScript

// combat/boss.js (Lane B) — the boss framework, and its first boss: the Pyloric Guardian.
//
// Built to LANE_C_NOTES §Boss specs, which is the authority on phases/telegraphs/windows.
// C's design pillar for this fight, quoted so nobody tunes it away: "stomach coat-drain is
// continuous, so this is a RACE AGAINST YOUR COAT, not its hp". Every number below serves that
// - the windows are short so the fight is long, and the fight is long so the drain is the clock.
//
// WHY THIS IS SMALL. A boss here is not a new entity system: it is a SCHEDULE over the enemy
// pool that already exists.
// - nodes and vents are ordinary enemies (`mucosal_node`, `turret`), so Lane B's weapons hit
// them, the InstancedMesh draws them and enemy:die reports them, all for free;
// - the fight's identity lives in WHEN they can be hurt, which is two flags on the entity
// (`invuln`, `armor`) that enemies.hurt() honours;
// - so this file is a state machine and nothing else. The second and third bosses should be
// able to reuse it without touching enemies.js again.
//
// The arena churn (C: "pushes you at the walls", x1.5 in P2, reverses as P3's tell) is NOT here.
// It wants a force on the player that only arena mode can express, and it is the one part of the
// spec that is a feel change rather than a schedule. Noted in NOTES for the round that tunes it.
import { BALANCE as B } from './balance.js';
// C's table, verbatim: phase -> { open window, cycle period, node count, armored count }.
// `windowScale` (difficulty.window) multiplies every window; C's scaling law.
// `hp` is Lane B's to set — C's spec says so ("numbers are design intent, not measured; they
// need B's framework to tune against"). Sized against the cannon: 11 shots/s x 12 damage = 132
// dps, so a 2.5 s window is ~330 damage of PERFECT uptime. At 150 hp a rim node survives its
// first window, which is what makes the fight a fight — the ring outlives the opening, so you
// come back for it, and coming back is what lets the stomach's coat-drain be the real clock.
// P3's core is 60 = exactly the "x5 hits" C specifies.
//
// THE REGISTRY. A boss is a list of phases plus a few knobs; everything mechanical is shared.
// Adding the Sovereign below required no change to enemies.js at all, which is the claim the
// Guardian's header made and this is the test of it.
// open/period the vulnerability window and its cycle. open >= period = always shootable.
// nodes/hp the ring spawned for that phase; `armored` of them need a torpedo first.
// 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 = {
pyloric_guardian: {
telegraph: 2.0, // C: "a deep gulp 2 s before every open"
node: 'mucosal_node',
rho: 0.82, // the iris rim
phases: [
{ id: 1, open: 2.5, period: 8.0, nodes: 4, hp: 150, armored: 0, vents: 3, ventEvery: 3.0 },
{ id: 2, open: 1.8, period: 5.0, nodes: 4, hp: 150, armored: 2, vents: 3, ventEvery: 2.4 },
{ id: 3, open: 1.2, period: 4.0, nodes: 1, hp: 60, armored: 0, vents: 3, ventEvery: 2.0, doom: 25 },
],
},
// LANE_C_NOTES §3. The final boss: hull attrition, inverting the Guardian. C's spec makes the
// gas pockets the fight — "shoot one and it detonates, destroying adjacent plates outright, and
// hurting you inside ~15 units. Optimal play = detonate 2-3 from max range." That needs no
// special-casing here: pockets are entities whose kill() already chains a blast through
// everything nearby, so seeding the ring with them via `bloom` makes them the boss's own worst
// weakness. The player's problem is that they are also the only light in the room.
the_blockage: {
telegraph: 1.5, // C: "a full-canal groan 1.5 s before each contraction"
node: 'mucosal_node', // the armoured plates
rho: 0.72,
phases: [
// P1 The Face — 5 plates, continuous (C: "the pockets are the timer", not an iris).
// hp is high because the windows are NOT: C's spec makes P1 continuous ("the pockets are
// the timer"), so unlike the Guardian nothing gates your dps but your own aim. First cut at
// 120 gave a 25.7s finale — shorter than the mid-game boss, which is the wrong shape for a
// campaign. The pockets are the intended shortcut: a detonation does 90 to every plate in
// range, so using the room is worth roughly a third of a plate per blast.
{ id: 1, open: 9, period: 9, nodes: 5, hp: 220, armored: 0, vents: 3, ventEvery: 2.8,
bloom: { every: 4.0, kinds: ['gas_pocket'], cap: 6, rearmAt: 99 } },
// P2 Fragmentation — the mass breaks into 4 tumbling chunks; the core opens intermittently.
{ id: 2, open: 2.0, period: 6.0, nodes: 4, hp: 230, armored: 1, vents: 3, ventEvery: 2.2,
bloom: { every: 3.4, kinds: ['gas_pocket', 'virion'], cap: 8, rearmAt: 99 } },
// P3 The Push — the colon contracts and drives it toward the exit with you behind it.
// 30s doom: if it plugs the rectum, you lose.
{ id: 3, open: 1.6, period: 5.0, nodes: 1, hp: 280, armored: 0, vents: 4, ventEvery: 1.8,
doom: 30, bloom: { every: 2.6, kinds: ['gas_pocket'], cap: 5, rearmAt: 99 } },
],
},
// LANE_C_NOTES §5. The restraint boss, and the one that is not shaped like a boss at all —
// hence `kind: 'sentinel'` and its own update path. There is no ring, no window and no hp bar
// you are meant to empty: there is a cell on patrol, and a question. Damage is the FAILURE
// state, so every number here describes a consequence rather than a fight.
mast_cell: {
kind: 'sentinel',
telegraph: 0,
node: 'mast_cell',
hp: 400, // the threshold the entity spawns with (balance.js agrees)
passS: 70, // travel this far past it and the encounter resolves, either way
// P2 Degranulation — triggered by ANY damage. Histamine: the cavity fills from the walls
// inward (droplets pinned at high rho ARE the swelling, expressed in entities) and the coat
// drain doubles. Note it never chases and never shoots: the level got worse, not the cell.
// drain is NET-of-regen or it is nothing: the player regenerates coat at 8/s (tuning.js
// coat.regen) and drain() does not reset that timer, so the first cut at 5/s meant getting
// histamine actually HEALED you (measured: coat 80 -> 100 during a degranulation). 14 leaves
// a real -6/s bleed; the cascade has to be felt or the lesson has no teeth.
degran: { every: 1.7, cap: 10, rho: 0.9, drain: 14 },
// P3 Anaphylaxis — past half. The airway is closing, and now there is a clock.
ana: { at: 0.5, doom: 25, every: 0.9, cap: 18, drain: 22 }, // -14/s net: the airway closing
},
// LANE_C_NOTES §4. The reputation boss. `standing` gates the whole encounter on entry, and
// keeps mattering: every commensal you kill in the lair re-armours a plate (see onHarm).
appendix_sovereign: {
telegraph: 1.5, // C: "a rising chorus 1.5 s before a quorum bloom"
node: 'mucosal_node', // the matrix plates / feeding channels reuse the node body
rho: 0.7,
standing: { spare: 70, soften: 30 }, // >= spare: no fight at all. >= soften: P1 only.
phases: [
// P1 — the matrix. ALL plates armoured, so the cannon genuinely cannot start this fight:
// only the antacid torpedo disperses matrix. Always "open": a biofilm has no iris, its
// defence is chemistry, and the gate on progress is AMMO.
{ id: 1, open: 9, period: 9, nodes: 4, hp: 90, armored: 4, vents: 2, ventEvery: 3.2 },
// P2 — quorum bloom. Channels exposed; it calls for defenders on a timer and re-armours
// when the room gets crowded. The gate on progress is HEAT.
{ id: 2, open: 7, period: 7, nodes: 3, hp: 110, armored: 0, vents: 2, ventEvery: 2.6,
bloom: { every: 3.2, kinds: ['virion', 'virion', 'spore_pod'], cap: 14, rearmAt: 10 } },
// P3 — the reseed. It runs for the colon; 20 s to stop it or it lives and the secret is
// lost. Short exposures while it swims.
{ id: 3, open: 2.0, period: 5.0, nodes: 1, hp: 80, armored: 0, vents: 2, ventEvery: 2.2, doom: 20 },
],
},
};
export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = null }) {
const rand = rng ? rng('boss') : () => 0.5;
const windowScale = Number(flags.window) > 0 ? Number(flags.window) : 1;
let active = null; // null | the running fight
const offs = [];
function spawnRing(f, phase) {
const out = [];
for (let i = 0; i < phase.nodes; i++) {
// Evenly spaced around the cross-section, rotated a little each phase so a player who
// memorised one safe approach has to re-read the ring.
const theta = (i / phase.nodes) * Math.PI * 2 + rand() * 0.5;
const e = enemies.spawn(f.def.node, { s: f.s, theta, rho: f.def.rho });
if (!e) continue;
e.hp = phase.hp; // per-phase: rim nodes are tanky, the core is 5 hits
e.invuln = true; // the iris starts shut
e.armor = i < phase.armored; // C's P2: two of the four are armoured
out.push(e);
}
return out;
}
function spawnVents(s, count) {
const out = [];
for (let i = 0; i < count; i++) {
// C: "3 fixed vents spit acid". A turret already fires a telegraphed homing projectile on
// a cooldown, which is what a vent is - so a vent IS a turret, at a fixed arc, and the
// acid spout costs zero new code.
const e = enemies.spawn('turret', { s: s - 6, theta: (i / count) * Math.PI * 2 + 0.4, rho: 0.98 });
if (e) out.push(e);
}
return out;
}
function setPhase(f, n) {
f.phase = f.def.phases[n];
f.phaseIdx = n;
for (const e of f.nodes) if (e.alive) e.alive = false; // old ring is gone
f.nodes = spawnRing(f, f.phase);
f.open = false;
f.t = 0;
f.bloomT = 0;
f.doomT = f.phase.doom ?? 0;
for (const v of f.vents) if (v.alive) v.cfg = { ...v.cfg, fireEvery: f.phase.ventEvery };
bus.emit('boss:phase', { id: f.id, phase: f.phase.id, nodes: f.nodes.length });
bus.emit('audio:cue', { name: 'overheat' }); // the ring changes gear
}
function start(ev) {
if (active) return;
const def = BOSSES[ev.id] ?? BOSSES.pyloric_guardian;
const f = {
id: ev.id ?? 'pyloric_guardian', def, s: ev.s ?? 0,
// `phases` is absent on a sentinel (it has no rings and no windows) — optional all the
// way down rather than a fake one-entry table, so the sentinel path stays honestly separate.
nodes: [], vents: [], phase: def.phases?.[0] ?? null, phaseIdx: 0,
open: false, t: 0, bloomT: 0, doomT: 0, warned: false, done: false,
};
// The sentinel spawns one cell and no vents, because it is not attacking anybody.
if (def.kind === 'sentinel') {
f.tier = 0; f.spawnT = 0; f.doomT = 0;
f.cell = enemies.spawn(def.node, { s: f.s, rho: 0.25 });
if (f.cell) f.cell.hp = def.hp;
active = f;
bus.emit('boss:start', { id: f.id, s: f.s, phases: 3, tier: 'patrol' });
return f;
}
// LANE_C_NOTES §4: the Sovereign is the one boss whose existence is decided before you
// arrive. KARMA (the unspendable conduct ledger, not the spendable standing meter) is read
// ONCE, on entry — a verdict on how you played the campaign, not on how recently you cashed
// your reputation in for an ally.
if (def.standing) {
const st = getKarma ? getKarma() : 0;
f.standing = st;
if (st >= def.standing.spare) {
// The Communion. The reward for five levels of restraint is being spared a boss.
active = f;
bus.emit('boss:start', { id: f.id, s: f.s, phases: 0, tier: 'communion', standing: st });
player?.refill?.({ coat: 999, hull: 40 });
for (let i = 0; i < 3; i++) enemies.spawn('phage', { s: f.s + 6 + i * 4 });
finish(f, 'communion');
return f;
}
// The Toll: it does not know you, so it shows you the matrix and nothing worse.
f.maxPhase = st >= def.standing.soften ? 1 : def.phases.length;
}
f.vents = spawnVents(f.s, def.phases[0].vents);
active = f;
setPhase(f, 0);
bus.emit('boss:start', {
id: f.id, s: f.s, phases: f.maxPhase ?? def.phases.length,
tier: f.maxPhase === 1 ? 'toll' : 'full', standing: f.standing,
});
return f;
}
function finish(f, outcome) {
if (f.done) return;
f.done = true;
for (const e of [...f.nodes, ...f.vents]) if (e.alive) e.alive = false;
active = null;
bus.emit('boss:end', { id: f.id, outcome });
// `spared` and `communion` are WINS that involve no killing, and the instrument has to agree
// with the design or it teaches the opposite lesson: a triumphant cue for walking away is
// most of how L1 tells the player that walking away was the answer.
const good = outcome === 'win' || outcome === 'spared' || outcome === 'communion';
bus.emit('audio:cue', { name: good ? 'gate_pass' : 'death' });
}
// The sentinel: L1's Mast Cell. Its entire state is "has the player hurt it yet", and the
// player's own restraint is the only input. Nothing here attacks — the escalation is the LEVEL
// getting worse, which is the point C's spec makes: the punishment is not damage, it is
// consequence, and it is authored by the person holding the trigger.
function updateSentinel(f, dt) {
const e = f.cell;
const ps = player?.state;
if (!e || !ps) { finish(f, 'lost'); return; }
const frac = e.alive ? e.hp / f.def.hp : 0; // killed outright = maximum cascade
if (frac < 0.999 && f.tier === 0) {
f.tier = 1; // DEGRANULATION — the first hit does this
bus.emit('boss:phase', { id: f.id, phase: 2, reason: 'degranulation' });
bus.emit('audio:cue', { name: 'overheat' });
}
if (f.tier === 1 && frac <= f.def.ana.at) {
f.tier = 2; // ANAPHYLAXIS — and now a clock
f.doomT = f.def.ana.doom;
bus.emit('boss:phase', { id: f.id, phase: 3, reason: 'anaphylaxis' });
bus.emit('audio:cue', { name: 'death' });
}
if (f.tier > 0) {
const cfg = f.tier === 2 ? f.def.ana : f.def.degran;
player.drain?.(cfg.drain * dt); // histamine: the coat goes, continuously
f.spawnT -= dt;
if (f.spawnT <= 0) {
f.spawnT = cfg.every;
const crowd = enemies.live.filter((x) => x.alive && x.type === 'floater').length;
// Pinned near the wall and closing in: this IS "the cavity swells shut", built from
// entities because a runtime tube radius is not in Lane A's contract. Noted in NOTES.
if (crowd < cfg.cap) enemies.spawn('floater', { s: ps.s + 20 + rand() * 40, rho: f.def.degran.rho });
}
if (f.doomT > 0) {
f.doomT -= dt;
if (f.doomT <= 0) { player.kill?.('anaphylaxis'); finish(f, 'anaphylaxis'); return; }
}
}
// Resolution: you always leave. The only question is what state you leave in.
if (ps.s > f.s + f.def.passS) {
if (f.tier === 0) {
// SPARED. The best outcome in the encounter is the one where nothing happened, and the
// game has to say so out loud or the lesson does not land.
player.refill?.({ coat: 40 });
bus.emit('flora:tended', { s: f.s, gain: 25 }); // it IS the biome; conduct is credited
finish(f, 'spared');
} else {
finish(f, f.tier === 2 ? 'anaphylaxis-survived' : 'degranulated');
}
}
}
function update(dt) {
const f = active;
if (!f || f.done) return;
if (f.def.kind === 'sentinel') { updateSentinel(f, dt); return; }
const live = f.nodes.filter((e) => e.alive);
// Ring cleared -> next phase, or the fight is over. `maxPhase` is the Sovereign's Toll tier:
// a neutral biome shows you the matrix and, once you have cleared it, decides that will do.
const lastPhase = f.maxPhase ?? f.def.phases.length;
if (!live.length) {
if (f.phaseIdx + 1 < lastPhase) setPhase(f, f.phaseIdx + 1);
else finish(f, 'win');
return;
}
// Quorum bloom (Sovereign P2): it calls for defenders on a timer, and when the room gets
// crowded it re-armours a channel — so letting the population run is how you lose ground.
// Population is the clock here, which is why this phase reads as a heat-management fight.
const bl = f.phase.bloom;
if (bl) {
f.bloomT -= dt;
if (f.bloomT <= 0) {
f.bloomT = bl.every;
const crowd = enemies.live.filter((e) => e.alive && bl.kinds.includes(e.type)).length;
if (crowd < bl.cap) {
const kind = bl.kinds[(f.bloomN = (f.bloomN ?? 0) + 1) % bl.kinds.length];
enemies.spawn(kind, { s: f.s + (rand() - 0.5) * 20 });
bus.emit('audio:cue', { name: 'enemy_hit' });
}
if (crowd >= bl.rearmAt) { // above quorum: the matrix comes back
const bare = live.find((e) => !e.armor);
if (bare) { bare.armor = true; bus.emit('boss:rearm', { id: f.id, reason: 'quorum' }); }
}
}
}
// P3's doom timer: it stops opening and tries to seal permanently. Run it down whatever the
// iris is doing - the only escape is killing the core, which is why P3's ring is one node.
if (f.doomT > 0) {
f.doomT -= dt;
if (f.doomT <= 0) { player?.kill?.('sealed'); finish(f, 'sealed'); return; }
}
// The cycle. `open` is the whole fight: nodes are only shootable while the sphincter relaxes.
const openFor = f.phase.open * windowScale;
f.t += dt;
if (f.t >= f.phase.period) f.t -= f.phase.period;
const wasOpen = f.open;
f.open = f.t < openFor;
// Telegraph: the deep gulp (or the Sovereign's rising chorus) before the window returns.
const untilOpen = f.t < openFor ? 0 : f.phase.period - f.t;
if (!f.open && untilOpen <= f.def.telegraph && !f.warned) {
f.warned = true;
bus.emit('boss:telegraph', { id: f.id, phase: f.phase.id, eta: untilOpen });
bus.emit('audio:cue', { name: 'warn_reflux_surge' });
}
if (f.open) f.warned = false;
if (f.open !== wasOpen) {
for (const e of live) e.invuln = !f.open;
bus.emit('boss:window', { id: f.id, open: f.open, phase: f.phase.id });
bus.emit('audio:cue', { name: f.open ? 'surge_start' : 'surge_end' });
}
}
offs.push(bus.on('level:event', (ev) => { if (ev?.type === 'boss') start(ev); }));
// LANE_C_NOTES §4, the mechanic that makes the Sovereign this boss and not another: the lair
// is seeded with your own flora, and the Sovereign FEEDS ON DYSBIOSIS. Every commensal your
// stray fire kills re-armours a plate — the fight gets harder by your hand, in real time,
// while a boss is in your face. It is the only encounter where the right move is regularly to
// stop shooting. (Gated on `standing` so only reputation bosses care.)
offs.push(bus.on('flora:harmed', () => {
const f = active;
if (!f || f.done || !f.def.standing) return;
const bare = f.nodes.find((e) => e.alive && !e.armor);
if (bare) bare.armor = true;
bus.emit('boss:rearm', { id: f.id, reason: 'dysbiosis' });
bus.emit('audio:cue', { name: 'overheat' });
}));
// A death mid-fight resets the encounter rather than leaving a half-killed ring in the arena:
// C put a checkpoint at 2260 precisely so a wipe costs seconds, and that promise only holds if
// the boss re-arms with it.
offs.push(bus.on('player:death', () => { if (active) finish(active, 'wipe'); }));
return {
update,
get active() { return active; },
get phase() { return active?.phase?.id ?? active?.tier ?? 0; },
get open() { return !!active?.open; },
reset() { if (active) finish(active, 'reset'); },
dispose() { for (const off of offs) off(); if (active) finish(active, 'dispose'); },
};
}