Two boss specs written to C's format (LANE_C_NOTES §Boss specs, round 3 additions) and the
first of them built. Both extend the roster along C's own law — one boss, one pressure — onto
the two axes the game had systems for and no boss pointed at: reputation (§4 Sovereign) and
restraint (§5 Mast Cell, specced only).
THE SOVEREIGN. The appendix is not vestigial: it is a biofilm reservoir where the gut's flora
shelter and re-seed the colon after a purge, and the whole fight is that fact. It is the only
boss whose existence is decided before you arrive — read once on entry:
karma >= 70 THE COMMUNION — no fight at all. Full coat, a permanent phage escort. The
reward for five levels of restraint is being spared a boss.
karma >= 30 THE TOLL — P1 only.
below THE SOVEREIGN — three phases. P1's matrix is immune to the cannon and only the
antacid torpedo disperses it (a mature biofilm's whole defining trait); P2 is a
quorum bloom that re-armours when the room gets crowded; P3 is a 20s reseed run.
And the mechanic that makes it this boss: the lair is seeded with YOUR OWN FLORA, and it feeds
on dysbiosis — every commensal your stray fire kills re-armours a plate, live, mid-fight.
KARMA vs STANDING — a real bug found in testing, and worth the extra number. `standing` is a
SPENDABLE meter: a full bar is consumed to bud an ally, dropping to a 50 floor. That meant a
perfect player could never HOLD >= 70, so the Communion was almost unreachable — verified: a
symbiotic pilot showed standing 50 and was demoted to "toll". `karma` is the same inputs with
nothing spending it. The Sovereign's tier and gates' `secretTo` branches read karma; E's bar
still reads standing.
Also: boss.js generalised to a registry, which was the claim its header made — the Sovereign
needed zero changes to enemies.js. LS_appendix (3 segments, 800 units, 16 events), reached from
L4's ileocecal valve via `secretTo`/`secretNeed` — anatomically exact, the appendix hangs off
the cecum right past that valve. The secret is hidden behind conduct, not behind a wall.
C's selfcheck caught par.time as a free medal and the fix improved the design: par 160 sits
under the timid traversal, so only a Communion run (which has no fight) can beat it. The time
medal rewards the same conduct the level is about.
Verified: all three tiers branch correctly (communion = 0 nodes + 4 phages; toll = 1 phase;
full = 3 phases, 4 armoured); killing a commensal mid-fight re-armours a plate (reason
"dysbiosis"); the valve routes karma 100 -> LS_appendix and karma 0 -> L5. Levels 5/5 valid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
267 lines
13 KiB
JavaScript
267 lines
13 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 §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'); },
|
|
};
|
|
}
|