From b9d9d816adf31eba4d2a644c980a55cdfa161107 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 26 Jul 2026 19:45:29 +1000 Subject: [PATCH] [lane B+E] Stop the game lying: the death sting at living players, the stuck hiss, and a crew naming the wrong organ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/js/combat/boss.js | 24 +++++++++++++++++++----- web/js/flight/player.js | 6 +++++- web/js/ui/comms.js | 31 +++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/web/js/combat/boss.js b/web/js/combat/boss.js index 8891409..a58f078 100644 --- a/web/js/combat/boss.js +++ b/web/js/combat/boss.js @@ -202,7 +202,10 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n 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 + // 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) { @@ -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 // 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 `death` cue is a ~12 s noise-table sting, not a stinger — playing it at a LIVING player + // 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 @@ -289,7 +303,7 @@ export function createBoss({ bus, rng, player, enemies, flags = {}, getKarma = n 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' }); + bus.emit('audio:cue', { name: 'enemy_hit' }); } if (f.tier === 1 && frac <= f.def.ana.at) { 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); if (bare) bare.armor = true; 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: // C put a checkpoint at 2260 precisely so a wipe costs seconds, and that promise only holds if diff --git a/web/js/flight/player.js b/web/js/flight/player.js index a172568..b3dbdb3 100644 --- a/web/js/flight/player.js +++ b/web/js/flight/player.js @@ -60,7 +60,11 @@ export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null st.coat -= toCoat; const leak = amount - toCoat; 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' }); if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); } return true; diff --git a/web/js/ui/comms.js b/web/js/ui/comms.js index 2586884..f1199e9 100644 --- a/web/js/ui/comms.js +++ b/web/js/ui/comms.js @@ -72,8 +72,30 @@ const LINES = { ['voss', 'we lost the feed. re-acquiring.'], ['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: [ + ['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.'], + ['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: [ ['voss', 'clear. next segment.'], @@ -184,13 +206,18 @@ export function createComms({ bus, flags = {}, mount = null } = {}) { const offs = []; offs.push(bus.on('level:event', (ev) => { 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) => { const 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('level:complete', () => say('complete'))); offs.push(bus.on('pickup', (e) => { if (e?.kind === 'biopsy_sample') say('sample'); }));