[lane B+E] Stop the game lying: the death sting at living players, the stuck hiss, and a crew naming the wrong organ

Four audio/comms cues wired to the wrong condition. All four are written content that has been
in the repo for rounds, firing at the wrong moment or never firing at all.

THE DEATH STING PLAYED AT LIVING PLAYERS. finish() mapped every non-win outcome to `death` — and
`death` is not a stinger, it is a ~12 s noise table (synth.js). So surviving L1's Mast Cell
("degranulated"), outrunning the Tapeworm ("escaped") and clearing the Sovereign's Toll all told
an alive player they had died, over the top of the next twelve seconds of play. Outcomes are
classified now: won/spared/communion triumph, alive-but-it-went-badly gets a downbeat, actually
dead gets the sting, and housekeeping (reset/dispose) is silent because nothing happened to the
player at all. Verified: surviving the Mast Cell no longer emits `death`; dying still does.

THE PHASE TELL WAS A SUSTAINING HISS. boss.js used `overheat` as its phase-change cue, but that
layer sustains until the engine sends `overheat_clear` on a combat:state.overheated falling edge
— which a boss never produces. Every phase change leaked a ~12 s steam hiss and ducked the mix
under it for the rest of the fight. Three sites, now short cues.

THE CREW HAS NEVER MENTIONED HULL DAMAGE. comms.js gates its two hull lines on
`kind === 'hull_hit'`, but `kind` is the damage SOURCE ('dart'|'acid'|'gas'…), so that branch has
never once been true in the game's life and every hit read as a coat graze. damage() already
computes `leak > 0` for its own audio cue — the bus event now carries it as `hull`.

AND IT NAMED THE WRONG ORGAN. There was exactly ONE boss line — "that is the pylorus. it does not
open for you." — fired for all five bosses, so the tutorial's Mast Cell and the campaign's final
boss were both announced as a stomach valve. Per-boss lines now, off boss:start (which is the only
place the id is known, and for the Sovereign whether there is a fight at all). The Mast Cell's is
doing real work: "wait — wait. that is a mast cell. it is not hunting you." / "do NOT shoot it."
The crew is the game's only tutorialisation channel, and that is the lesson L1 exists to teach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 19:45:29 +10:00
parent 9bd42dccc5
commit b9d9d816ad
3 changed files with 53 additions and 8 deletions

View File

@ -202,7 +202,10 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n
f.doomT = f.phase.doom ?? 0; f.doomT = f.phase.doom ?? 0;
for (const v of f.vents) if (v.alive) v.cfg = { ...v.cfg, fireEvery: f.phase.ventEvery }; 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('boss:phase', { id: f.id, phase: f.phase.id, nodes: f.nodes.length });
bus.emit('audio:cue', { name: 'overheat' }); // the ring changes gear // NOT 'overheat': that cue SUSTAINS until the engine sends overheat_clear on a
// combat:state.overheated falling edge, which a boss never produces — so every phase
// change leaked a ~12 s steam hiss and ducked the mix under it for the whole fight.
bus.emit('audio:cue', { name: 'gate_hit' }); // the ring changes gear
} }
function start(ev) { function start(ev) {
@ -272,8 +275,19 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n
// `spared` and `communion` are WINS that involve no killing, and the instrument has to agree // `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 // 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. // most of how L1 tells the player that walking away was the answer.
const good = outcome === 'win' || outcome === 'spared' || outcome === 'communion'; // The `death` cue is a ~12 s noise-table sting, not a stinger — playing it at a LIVING player
bus.emit('audio:cue', { name: good ? 'gate_pass' : 'death' }); // is the one lie an audio layer must never tell, and it was firing for every non-win. So:
// won / spared / communion -> a triumph,
// still alive but it went badly (degranulated, escaped, survived the cascade) -> a downbeat
// "that did not go well", NOT a death,
// actually dead (sealed by the Guardian, anaphylaxis, wiped) -> the death sting, correctly,
// housekeeping (reset/dispose) -> silence, because nothing happened to the player at all.
const WON = new Set(['win', 'spared', 'communion']);
const DIED = new Set(['sealed', 'anaphylaxis', 'wipe']);
const SILENT = new Set(['reset', 'dispose', 'lost']);
if (!SILENT.has(outcome)) {
bus.emit('audio:cue', { name: WON.has(outcome) ? 'gate_pass' : DIED.has(outcome) ? 'death' : 'surge_end' });
}
} }
// The sentinel: L1's Mast Cell. Its entire state is "has the player hurt it yet", and the // The sentinel: L1's Mast Cell. Its entire state is "has the player hurt it yet", and the
@ -289,7 +303,7 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n
if (frac < 0.999 && f.tier === 0) { if (frac < 0.999 && f.tier === 0) {
f.tier = 1; // DEGRANULATION — the first hit does this f.tier = 1; // DEGRANULATION — the first hit does this
bus.emit('boss:phase', { id: f.id, phase: 2, reason: 'degranulation' }); bus.emit('boss:phase', { id: f.id, phase: 2, reason: 'degranulation' });
bus.emit('audio:cue', { name: 'overheat' }); bus.emit('audio:cue', { name: 'enemy_hit' });
} }
if (f.tier === 1 && frac <= f.def.ana.at) { if (f.tier === 1 && frac <= f.def.ana.at) {
f.tier = 2; // ANAPHYLAXIS — and now a clock f.tier = 2; // ANAPHYLAXIS — and now a clock
@ -431,7 +445,7 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n
const bare = f.nodes.find((e) => e.alive && !e.armor); const bare = f.nodes.find((e) => e.alive && !e.armor);
if (bare) bare.armor = true; if (bare) bare.armor = true;
bus.emit('boss:rearm', { id: f.id, reason: 'dysbiosis' }); bus.emit('boss:rearm', { id: f.id, reason: 'dysbiosis' });
bus.emit('audio:cue', { name: 'overheat' }); bus.emit('audio:cue', { name: 'enemy_hit' });
})); }));
// A death mid-fight resets the encounter rather than leaving a half-killed ring in the arena: // 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 // C put a checkpoint at 2260 precisely so a wipe costs seconds, and that promise only holds if

View File

@ -60,7 +60,11 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null
st.coat -= toCoat; st.coat -= toCoat;
const leak = amount - toCoat; const leak = amount - toCoat;
if (leak > 0) st.hull = Math.max(0, st.hull - leak); if (leak > 0) st.hull = Math.max(0, st.hull - leak);
bus.emit('player:damage', { amount, kind }); // `hull` is the fact the audio cue below has always had and the bus event never carried:
// comms.js has two hull lines gated on `kind === 'hull_hit'`, but `kind` is the damage
// SOURCE ('dart'|'acid'|'gas'…), so that branch has never once been true and the crew has
// never mentioned hull damage in the game's life.
bus.emit('player:damage', { amount, kind, hull: leak > 0 });
bus.emit('audio:cue', { name: leak > 0 ? 'hull_hit' : 'coat_hit' }); bus.emit('audio:cue', { name: leak > 0 ? 'hull_hit' : 'coat_hit' });
if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); } if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); }
return true; return true;

View File

@ -72,8 +72,30 @@ const LINES = {
['voss', 'we lost the feed. re-acquiring.'], ['voss', 'we lost the feed. re-acquiring.'],
['adeyemi', 'that was the immune system. i am sorry. they think you are a parasite.'], ['adeyemi', 'that was the immune system. i am sorry. they think you are a parasite.'],
], ],
// One line per boss. There used to be exactly one — "that is the pylorus" — fired for all five,
// so the tutorial's mast cell and the campaign's final boss were both announced as a stomach
// valve. The crew is the game's only tutorialisation channel; naming the wrong organ in it is
// worse than saying nothing.
boss: [ boss: [
['voss', 'contact. hold position and read it.'],
],
boss_mast_cell: [
['adeyemi', 'wait — wait. that is a mast cell. it is not hunting you.'],
['park', 'do NOT shoot it. whatever you do.'],
],
boss_pyloric_guardian: [
['voss', 'that is the pylorus. it does not open for you.'], ['voss', 'that is the pylorus. it does not open for you.'],
['park', 'the ring only relaxes to pass chyme. hit the nodes when it does.'],
],
boss_tapeworm: [
['park', 'taenia. metres of it, and it is running — do not lose it.'],
],
boss_the_blockage: [
['voss', 'there it is. the whole lumen, impacted.'],
['adeyemi', 'the gas pockets are load-bearing. use them, from range.'],
],
boss_appendix_sovereign: [
['adeyemi', 'the reservoir. everything the gut kept safe is in there.'],
], ],
complete: [ complete: [
['voss', 'clear. next segment.'], ['voss', 'clear. next segment.'],
@ -184,13 +206,18 @@ export function createComms({ bus, flags = {}, mount = null } = {}) {
const offs = []; const offs = [];
offs.push(bus.on('level:event', (ev) => { offs.push(bus.on('level:event', (ev) => {
if (ev.type === 'checkpoint' && ev.name) say('checkpoint', ev.name); if (ev.type === 'checkpoint' && ev.name) say('checkpoint', ev.name);
else if (ev.type === 'boss') say('boss'); }));
// boss:start rather than the raw level event, because only the boss knows its own id — and for
// the Sovereign, whether there is a fight at all (a symbiotic player gets the Communion).
offs.push(bus.on('boss:start', (e) => {
const k = `boss_${e?.id}`;
say(LINES[k] ? k : 'boss');
})); }));
offs.push(bus.on('hazard:warn', (e) => { offs.push(bus.on('hazard:warn', (e) => {
const k = `warn_${e?.kind}`; const k = `warn_${e?.kind}`;
say(LINES[k] ? k : 'warn', e?.kind); say(LINES[k] ? k : 'warn', e?.kind);
})); }));
offs.push(bus.on('player:damage', (e) => say(e?.kind === 'hull_hit' ? 'hull_hit' : 'coat_hit'))); offs.push(bus.on('player:damage', (e) => say(e?.hull ? 'hull_hit' : 'coat_hit')));
offs.push(bus.on('player:death', () => say('death'))); offs.push(bus.on('player:death', () => say('death')));
offs.push(bus.on('level:complete', () => say('complete'))); offs.push(bus.on('level:complete', () => say('complete')));
offs.push(bus.on('pickup', (e) => { if (e?.kind === 'biopsy_sample') say('sample'); })); offs.push(bus.on('pickup', (e) => { if (e?.kind === 'biopsy_sample') say('sample'); }));