// 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'); }, }; }