// 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 §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, nodes: [], vents: [], phase: def.phases[0], phaseIdx: 0, open: false, t: 0, bloomT: 0, doomT: 0, warned: false, done: false, }; // 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 }); bus.emit('audio:cue', { name: outcome === 'win' ? 'gate_pass' : 'death' }); } function update(dt) { const f = active; if (!f || f.done) 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 ?? 0; }, get open() { return !!active?.open; }, reset() { if (active) finish(active, 'reset'); }, dispose() { for (const off of offs) off(); if (active) finish(active, 'dispose'); }, }; }