Add pirate comms: attack/defeat/bounty voice lines with speech and video

- Raiders now roll an attack comm on-screen (50%) or on rolling-wave arrival (20%), and a defeat comm on kill (20%), driven by per-type config in combat.json
- New bounty-attack comm fires when the wanted leader's fight stands up in GameScene._fireBountyEngagements
- Ship 12 pirate speech clips, 3 enemy video clips, and register three new senders (pirate, pirate_defeat, pirate_bounty) in comms.json
- Wire onComm seam through CombatSystem so the scene can arm CommsHub triggers; rolls are no-ops when the seam is absent
- Add combat, bounty-hunt, and comms test coverage for the new roll logic, trigger arming, asset presence, and roster count
This commit is contained in:
Brian Fertig 2026-09-20 21:34:05 -06:00
parent 18d65ea267
commit 5e799bbd5a
22 changed files with 469 additions and 5 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -84,7 +84,19 @@
"enemyTypes": {
"_comment": "The hostile roster a WAVE (combat.waves.scripts) can mix — js/combat/EnemyTypes.js deep-merges a type's overrides onto the base `enemy` above, so a type declares only what differs. `raider` is the base craft under a named id (frame 0). Frames 1–7 are the SEVEN new hostiles, each a distinct craft with a distinct WEAPON PATTERN (weapon.pattern) and a MODERATE difficulty step-up: hull/shields (armor to soak) climb monotonically bomber → warden, so each is a little harder to defeat than the last. PATTERNS (js/combat/Weapons.js): 'lance' (the default — one aimed bolt, the base behavior), 'spray' (a FAN of `bolts` spread over `fan` rad), 'burst' (a TIGHT `bolts`-shot stinger — staggered speeds so it fans over distance), 'rapid' (single bolts on a short cooldown — a machine gun), 'ring' (`bolts` fired RADIAL around the craft — break the orbit), 'missile' (`missiles` HEAT-SEEKING shots that home with a CAPPED turn-rate (missileTurn rad/s) so the player can out-turn them), 'combo' (a heavy lance + a missile, the elite). Add a craft here (a new `frame` or its own `texture` + stat overrides) and name it in a wave's roster; EnemyTypes.enemySheetList() is the preload seam for its textures. Each type's `xp` = what a kill of it is worth (the rule of thumb is (hull+shields)/5, rounded, so a tougher craft is worth more of the XP bank).",
"raider": {
"frame": 0
"$comment": "The PIRATE (data/quests.json → pirate_problems — the ambush wave combat.waves.scripts.pirate_ambush is all raiders). comms = the pirate's combat VOICE (the rolls live in js/combat/CombatSystem.js; the comms themselves are data/comms.json's pirate-attack / pirate-defeat — the box's clip + one random speech clip + the message). attack.onScreenChance = the chance a pirate shows an ATTACK comm the first time it flies ON SCREEN (0.5 = 50%); attack.spawnChance = the chance a NEW pirate sent into a rolling wave (the opening burst, each kill's replacement) ATTACK-comms on arrival (0.2 = 20%) — a hit marks the ship, so its later on-screen roll doesn't stack (a pirate never double-comms); defeat.chance = the chance a downed pirate shows a DEFEAT comm (0.2 = 20%).",
"frame": 0,
"comms": {
"attack": {
"onScreenChance": 0.5,
"spawnChance": 0.2,
"trigger": "pirateAttack"
},
"defeat": {
"chance": 0.2,
"trigger": "pirateDefeat"
}
}
},
"bomber": {
"_comment": "frame 1 — the LONG LANCE: slow, sits back, one heavy bolt on a long fuse. The easy end of the new ramp.",

File diff suppressed because one or more lines are too long

View File

@ -601,6 +601,70 @@ section('NO SPOILERS — nothing the player reads says where the mark stands');
check('the hunt’s beacon style comes from the data with no contact color', /_drawBountyMarkers/.test(sceneSrc) && !/bounty\.marker\.contactColor/.test(sceneSrc));
}
// ==========================================================================
section('THE MARK\'S VOICE — the bounty attack comm (data/comms.json → pirate-bounty-attack)');
{
// The mark's fight stands up ONLY in the bounty branch of
// GameScene._fireBountyEngagements (the picket skirmish throws no mark),
// and it is there that the 'pirateBountyAttack' trigger is armed — the
// box shows assets/videos/enemies/pirate-bounty.mp4 with one of the four
// bounty voices. Run headless over a stub `this` (the jobboard pattern):
// the REAL prototype function, its internal `this.*` seams on stubs.
const makeHuntScene = (shipPos) => {
const state = { started: [], triggered: [], toasts: [], sfx: [] };
const scene = {
combat: {
waveRun: null,
startWaves: (script, opts) => state.started.push({ script, opts }),
},
ship: { x: shipPos[0], y: shipPos[1] },
comms: { trigger: (t) => state.triggered.push(t) },
playSfx: (n) => state.sfx.push(n),
_huntToast: (line) => state.toasts.push(String(line)),
_huntMarkName: GameScene.prototype._huntMarkName,
_fireBountyEngagements: GameScene.prototype._fireBountyEngagements,
state,
};
return scene;
};
const markPlan = {
down: false,
bountyAt: 0,
pickets: [false],
skirmishCleared: [],
raiders: 8,
eliteType: 'gunner',
mark: { name: 'Vex Korrath', epithet: 'the Wrecked' },
};
// THE MARK — its leg is logged and the ship stands on it: the fight
// stands up (the elite deploys with the opening group) and the bounty
// attack comm is armed in the same breath.
const markRoute = { kind: 'bounty', targetSystemId: 'sys', visited: [0], waypoints: [[100, 100]], bounty: markPlan };
const scene = makeHuntScene([100, 100]);
const fired = scene._fireBountyEngagements({ bountySkirmishCleared: () => {} }, 'sq-bounty-0', markRoute, 420);
check('the mark branch stands up the fight (one rolling wave through startWaves)', fired === true && scene.state.started.length === 1);
check('the mark deploys inside the wave (the elite block is the dossier frame)', !!scene.state.started[0]?.script?.waves?.[0]?.elite);
check('the bounty attack comm is armed the moment the mark stands up', scene.state.triggered.includes('pirateBountyAttack'));
check('…exactly once per engagement that stands', scene.state.triggered.filter((t) => t === 'pirateBountyAttack').length === 1);
// THE PICKET WAVE — a pre-bounty leg's skirmish throws no mark (the mark
// is the fight): its fight stands up WITHOUT the bounty attack comm.
const picketRoute = { kind: 'bounty', targetSystemId: 'sys', visited: [0], waypoints: [[100, 100], [900, 900]], bounty: { ...markPlan, bountyAt: 1, pickets: [true] } };
const sceneP = makeHuntScene([100, 100]);
const firedP = sceneP._fireBountyEngagements({ bountySkirmishCleared: () => {} }, 'sq-bounty-0', picketRoute, 420);
check('the picket branch stands up its skirmish (startWaves)', firedP === true && sceneP.state.started.length === 1);
check('the picket skirmish carries no elite (the mark is the fight)', !sceneP.state.started[0]?.script?.waves?.[0]?.elite);
check('…and it does NOT arm the bounty attack comm', !sceneP.state.triggered.includes('pirateBountyAttack'));
// THE COMMS ITSELF — the trigger arms exactly the bounty comm (the box's
// clip is the bounty clip, the voice one of the four bounty clips, the
// line the bounty line — pinned in dev/comms.test.mjs's content checks).
const armed = (configData.comms?.comms ?? []).filter((c) => c.trigger === 'pirateBountyAttack');
check('the trigger arms exactly one comm (pirate-bounty-attack, the bounty face)',
armed.length === 1 && armed[0].id === 'pirate-bounty-attack' && armed[0].from === 'pirate_bounty');
}
// ==========================================================================
if (failures > 0) {
console.error(`\n${failures} bounty hunt check(s) FAILED`);

View File

@ -1349,6 +1349,98 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
check('waves/system: the demo fires 7 starts + 7 clears', events.start === 7 && events.clear === 7);
}
// -------------------------------------------------------------- comm rolls
// The comming type's VOICE (data/combat.json → enemyTypes.raider.comms; the
// comms themselves are data/comms.json's pirate-attack / pirate-defeat —
// the clip + the random voice + the line): the 50% ON-SCREEN attack roll
// (once per pirate, on its first fly-in), the 20% ROLLING-ARRIVAL attack
// roll (a new pirate sent into a rolling wave), and the 20% DEFEAT roll
// on the kill. The dice are CombatSystem's, the seam is o.onComm (the
// scene's CommsHub trigger). The rolls ride Math.random, so the checks
// stub it (hit = always < chance, miss = always >= chance) and restore it.
{
const scene = makeScene({ withSheet: true });
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
ship.radius = 12;
ship.stop = () => {};
const comms = [];
const combat = new CombatSystem(scene, { ship, onComm: (trigger) => comms.push(trigger) });
const realRandom = Math.random;
const rollHit = () => { Math.random = () => 0; }; // every roll succeeds (0 < chance)
const rollMiss = () => { Math.random = () => 0.999; }; // every roll fails (>= chance)
const count = (name) => comms.filter((c) => c === name).length;
try {
// The config rides the TYPE (data/combat.json → enemyTypes): the raider
// (the pirate) carries the block; a type without one rides commless.
const pirate = combat.spawnEnemy(100, 100, 0, 'raider');
const bomber = combat.spawnEnemy(200, 200, 1, 'bomber');
check('comms: the raider carries its comm rolls (the pirate)',
pirate.comms?.attack?.trigger === 'pirateAttack' && pirate.comms?.defeat?.trigger === 'pirateDefeat');
check('comms: a type without a comms block rides commless (the rolls no-op)',
bomber.comms === null);
// ON-SCREEN (50%): headless has no camera, so inView is a no-op — the
// craft is "on screen" from its first frame, and the first update is
// its fly-in. A forced hit arms the attack trigger…
rollHit();
combat.update(1000, 16);
check('comms: the on-screen roll arms the attack comm (the 50% hit)',
count('pirateAttack') === 1);
// …and a pirate rolls it ONCE (the edge is spent — no second line).
combat.update(1016, 16);
check('comms: the on-screen roll fires once per pirate', count('pirateAttack') === 1);
// …while a forced miss arms nothing.
rollMiss();
const raider2 = combat.spawnEnemy(300, 300, 2, 'raider');
combat.update(1032, 16);
check('comms: the on-screen roll\'s miss arms nothing (the 50% miss)',
count('pirateAttack') === 1 && raider2._commedAttack === false);
// DEFEAT (20%): a downed pirate\'s line — the forced hit arms the defeat
// trigger; the commless bomber\'s death stays quiet.
rollHit();
pirate.damage(9999);
combat.killEnemy(pirate, 2000);
check('comms: the defeat roll arms the defeat comm (the 20% hit)',
count('pirateDefeat') === 1);
rollMiss();
bomber.damage(9999);
combat.killEnemy(bomber, 2016);
check('comms: a commless type\'s death arms no defeat comm', count('pirateDefeat') === 1);
// ROLLING ARRIVAL (20%): a NEW pirate sent into a rolling wave (the
// opening burst, then each kill\'s replacement) rolls its own attack
// comm on spawn — a hit marks the ship, so the on-screen roll (which
// the next update would otherwise run) can\'t stack on top.
const atkBefore = count('pirateAttack');
const defBefore = count('pirateDefeat');
rollHit();
combat.startWaves(
{ waves: [{ kind: 'rolling', roster: { raider: 1 }, total: 2, maxConcurrent: 1, replacePerKill: 1 }], lead: 0, gap: 0 },
{},
);
combat.update(3000, 16);
const live = combat.enemies.filter((e) => e.wave && !e.dead);
check('comms: the rolling opening burst comms on arrival (the 20% hit)',
live.length === 1 && count('pirateAttack') === atkBefore + 1);
check('comms: a hit arrival marks the ship (the on-screen roll can\'t stack)',
live.length === 1 && live[0]._commedAttack === true);
live[0].damage(9999);
combat.killEnemy(live[0], 3016);
combat.update(3032, 16);
const replaced = combat.enemies.filter((e) => e.wave && !e.dead);
check('comms: the rolling replacement deploys + comms on arrival',
replaced.length === 1 && replaced[0]._commedAttack === true &&
count('pirateAttack') === atkBefore + 2);
check('comms: the arrival pirate\'s defeat rides its 20% roll (hit)',
count('pirateDefeat') === defBefore + 1);
combat.cancelWaves(true);
} finally {
Math.random = realRandom;
}
}
// ------------------------------------------------------------------ done
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);

View File

@ -89,14 +89,25 @@ const carol = charEntry(cfg, 'carol');
check('Carol is registered (label + role + her own clip)', !!carol && carol.label === 'CAROL' && !!carol.role && carol.video === 'assets/videos/characters/carol.mp4');
check('Carol\'s space clip ships on disk', existsSync(join(root, 'assets/videos/characters/carol.mp4')));
const pirate = charEntry(cfg, 'pirate');
check('the pirate is registered (label + role + the attack clip)', !!pirate && pirate.label === 'PIRATE' && !!pirate.role && pirate.video === 'assets/videos/enemies/pirate.mp4');
check('the pirate\'s clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate.mp4')));
const pirateDefeat = charEntry(cfg, 'pirate_defeat');
check('the pirate\'s defeat face is registered (the dying clip)', !!pirateDefeat && pirateDefeat.label === 'PIRATE' && pirateDefeat.video === 'assets/videos/enemies/pirate-defeat.mp4');
check('the defeat clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate-defeat.mp4')));
const pirateBounty = charEntry(cfg, 'pirate_bounty');
check('the bounty mark is registered (label + role + the bounty clip)', !!pirateBounty && pirateBounty.label === 'PIRATE' && !!pirateBounty.role && pirateBounty.video === 'assets/videos/enemies/pirate-bounty.mp4');
check('the bounty clip ships on disk', existsSync(join(root, 'assets/videos/enemies/pirate-bounty.mp4')));
const roster = listComms(cfg);
// The shipped roster count (the data pins it: a stray unvalidated comm
// landing in data/comms.json changes the number and fails this line —
// 25 pre-'Pirate Problems' + the trestleQ4 warning trio (trestle-q4-1/
// 2/3, data/comms.json) + the payoff pair (trestle-q4-4/5, the
// quest-complete sign-off) + the combat pair (oac-enemies-detected /
// oac-enemies-defeated) = 32).
check('the roster holds exactly the shipped comms', roster.length === 32);
// oac-enemies-defeated) + the pirate trio (pirate-attack / pirate-defeat /
// pirate-bounty-attack — the comming type's own voice, the mark's too) = 35).
check('the roster holds exactly the shipped comms', roster.length === 35);
const byId = Object.fromEntries(roster.map((c) => [c.id, c]));
const intro = byId['oac-intro'];
check(
@ -374,6 +385,113 @@ check(
defAssets.audio?.key === audioKeyFor(defAssets.audio.url),
);
// The PIRATE'S OWN VOICE (data/combat.json → enemyTypes.raider.comms rolls
// the pirate-attack / pirate-defeat triggers; GameScene's bounty hunt arms
// pirate-bounty-attack) — the box shows the pirate's clip with the voice
// rolling one of the four clips per arm, all riding over the live fight.
const atk = byId['pirate-attack'];
check(
'pirate-attack: the on-screen/arrival call (visual, pirateAttack, pirate, once:false)',
!!atk && atk.from === 'pirate' && atk.type === 'visual' && atk.trigger === 'pirateAttack' && atk.once === false,
);
const atkPool = atk?.audioPool ?? [];
check(
'pirate-attack: the four attack clips (audioPool, no fixed audio)',
atk?.audio === null &&
JSON.stringify(atkPool) ===
JSON.stringify([
'assets/speech/pirates/attack-01.mp3',
'assets/speech/pirates/attack-02.mp3',
'assets/speech/pirates/attack-03.mp3',
'assets/speech/pirates/attack-04.mp3',
]),
);
check('pirate-attack: every pool clip ships on disk', atkPool.every((f) => existsSync(join(root, f))));
check('pirate-attack: fires AT the fight (delayMs === 0)', (atk?.delayMs ?? -1) === 0);
check(
'pirate-attack: a played call RE-arms (once:false — every roll that hits)',
commsForTrigger(cfg, 'pirateAttack', new Set(['pirate-attack'])).length === 1,
);
const atkAssets = commAssets(cfg, atk);
check(
'pirate-attack assets: the attack clip + the pool, keyed',
atkAssets.video?.key === 'comms_vid_pirate' &&
atkAssets.video.url === 'assets/videos/enemies/pirate.mp4' &&
atkPool.includes(atkAssets.audio?.url) &&
atkAssets.audio?.key === audioKeyFor(atkAssets.audio.url),
);
const dft = byId['pirate-defeat'];
check(
'pirate-defeat: the kill sign-off (visual, pirateDefeat, pirate_defeat, once:false)',
!!dft && dft.from === 'pirate_defeat' && dft.type === 'visual' && dft.trigger === 'pirateDefeat' && dft.once === false,
);
const dftPool = dft?.audioPool ?? [];
check(
'pirate-defeat: the four defeat clips (audioPool, no fixed audio)',
dft?.audio === null &&
JSON.stringify(dftPool) ===
JSON.stringify([
'assets/speech/pirates/defeat-01.mp3',
'assets/speech/pirates/defeat-02.mp3',
'assets/speech/pirates/defeat-03.mp3',
'assets/speech/pirates/defeat-04.mp3',
]),
);
check('pirate-defeat: every pool clip ships on disk', dftPool.every((f) => existsSync(join(root, f))));
check('pirate-defeat: fires AT the kill (delayMs === 0)', (dft?.delayMs ?? -1) === 0);
check(
'pirate-defeat: a played sign-off RE-arms (once:false — every roll that hits)',
commsForTrigger(cfg, 'pirateDefeat', new Set(['pirate-defeat'])).length === 1,
);
const dftAssets = commAssets(cfg, dft);
check(
'pirate-defeat assets: the dying clip + the pool, keyed',
dftAssets.video?.key === 'comms_vid_pirate_defeat' &&
dftAssets.video.url === 'assets/videos/enemies/pirate-defeat.mp4' &&
dftPool.includes(dftAssets.audio?.url) &&
dftAssets.audio?.key === audioKeyFor(dftAssets.audio.url),
);
const bty = byId['pirate-bounty-attack'];
check(
'pirate-bounty-attack: the mark\'s call (visual, pirateBountyAttack, pirate_bounty, once:false)',
!!bty && bty.from === 'pirate_bounty' && bty.type === 'visual' && bty.trigger === 'pirateBountyAttack' && bty.once === false,
);
const btyPool = bty?.audioPool ?? [];
check(
'pirate-bounty-attack: the four bounty clips (audioPool, no fixed audio)',
bty?.audio === null &&
JSON.stringify(btyPool) ===
JSON.stringify([
'assets/speech/pirates/bounty-01.mp3',
'assets/speech/pirates/bounty-02.mp3',
'assets/speech/pirates/bounty-03.mp3',
'assets/speech/pirates/bounty-04.mp3',
]),
);
check('pirate-bounty-attack: every pool clip ships on disk', btyPool.every((f) => existsSync(join(root, f))));
check('pirate-bounty-attack: fires AT the engagement (delayMs === 0)', (bty?.delayMs ?? -1) === 0);
check(
'pirate-bounty-attack: a played call RE-arms (once:false — every engagement that stands)',
commsForTrigger(cfg, 'pirateBountyAttack', new Set(['pirate-bounty-attack'])).length === 1,
);
const btyAssets = commAssets(cfg, bty);
check(
'pirate-bounty-attack assets: the bounty clip + the pool, keyed',
btyAssets.video?.key === 'comms_vid_pirate_bounty' &&
btyAssets.video.url === 'assets/videos/enemies/pirate-bounty.mp4' &&
btyPool.includes(btyAssets.audio?.url) &&
btyAssets.audio?.key === audioKeyFor(btyAssets.audio.url),
);
// The scene seam — the mark's fight stands up ONLY in the bounty branch of
// GameScene._fireBountyEngagements (the picket skirmish throws no mark):
// the trigger is armed there, and nowhere else.
const bountySrc = (await import('node:fs')).readFileSync(join(root, 'js/scenes/GameScene.js'), 'utf8');
check(
'GameScene arms pirateBountyAttack in the mark branch (and nowhere else)',
bountySrc.split("trigger('pirateBountyAttack')").length - 1 === 1 &&
bountySrc.indexOf("trigger('pirateBountyAttack')") > bountySrc.indexOf('_fireBountyEngagements'),
);
// The 'Leaving the Nest' beats — Trestle's THREE-BRIEF sequence (the
// home gates go online; ALL THREE in the run's comms played-ledger are
// the quest's 'Talk to Trestle' witness — GameScene._trestleCheck) and

View File

@ -47,6 +47,15 @@ const TAU = Math.PI * 2;
* the kills feed the rolling replacements. The QUEST
* HOOK THAT FIRES startWaves LANDS LATER; until then
* the console drives it (GameScene → __ORBIT_COMBAT__.waves))
* - the PIRATE COMMS (the comming types' own voice — the chances are the
* type's, data/combat.json → enemyTypes.<type>.comms):
* a 50% roll the first time a pirate flies on-screen,
* a 20% roll when a new pirate is sent into a rolling
* wave (the opening burst, each kill's replacement), a
* 20% defeat roll on the kill. The comms themselves
* (data/comms.json's pirate-attack / pirate-defeat —
* the clip + the random speech clip + the line) are
* armed through o.onComm (the scene's CommsHub)
*
* The scene supplies the player ship (a Ship, or anything with
* .x/.y/.radius/.body/.stop()/.setVisible()) and calls
@ -73,6 +82,10 @@ export class CombatSystem {
* onKill (enemy) => {} — a hostile went down
* onLevelUp (level) => {} — a combat level landed (the feed's line;
* the system's own FX/SFX/armor fire on the event)
* onComm (trigger) => {} — a comming hostile rolled one of its
* comm lines (the pirate's attack/defeat — the scene
* arms its CommsHub's trigger; js/comms; a context
* without one simply omits the seam)
*/
constructor(scene, o = {}) {
this.scene = scene;
@ -83,6 +96,7 @@ export class CombatSystem {
this.onPlayerUp = typeof o.onPlayerUp === 'function' ? o.onPlayerUp : null;
this.onKill = typeof o.onKill === 'function' ? o.onKill : null;
this.onLevelUp = typeof o.onLevelUp === 'function' ? o.onLevelUp : null;
this.onComm = typeof o.onComm === 'function' ? o.onComm : null;
this.enemies = [];
this.bullets = [];
@ -256,7 +270,14 @@ export class CombatSystem {
* @param {string|null} [type] the hostile type (a combat.enemyTypes id) — null = the base
*/
spawnEnemy(x, y, index, type = null) {
const e = new EnemyShip(this.scene, x, y, index, enemyTypeConfig(type));
const cfg = enemyTypeConfig(type);
const e = new EnemyShip(this.scene, x, y, index, cfg);
// The comm rolls' config (data/combat.json → enemyTypes.<type>.comms —
// the pirate's lines; null for a type without one, then every roll is
// a no-op). The roll's marks ride the ship (the comm section below).
e.comms = cfg.comms && typeof cfg.comms === 'object' ? cfg.comms : null;
e._commedAttack = false; // an attack line already fired (the rolls don't stack)
e._wasOnScreen = false; // the on-screen roll's edge (fires once, on entry)
e.setDepth(10);
const px = this.ship ? this.ship.x : x;
const py = this.ship ? this.ship.y : y;
@ -391,6 +412,11 @@ export class CombatSystem {
const e = this.spawnEnemy(px + Math.cos(a) * r, py + Math.sin(a) * r, this._spawnSerial++, typeId);
e.wave = true; // owned by the run: no self-respawn, the kill feeds the runner
if (elite) e.applyEliteBuff(elite);
// THE ROLLING ARRIVAL (data/combat.json → comms.attack.spawnChance):
// a NEW pirate sent into a rolling wave (the opening burst, each
// kill's replacement) rolls its own attack comm on arrival (20%) — a
// hit marks the ship, so its later on-screen roll doesn't stack on top.
if (e.comms?.attack) this._commAttackRoll(e, e.comms.attack.spawnChance);
return e;
}
@ -544,6 +570,15 @@ export class CombatSystem {
}
const wantsFire = e.update(time, delta, player);
if (wantsFire && player && this.craftVisible(e)) this.fireEnemy(e, player, time);
// THE ATTACK COMM (the type's comms.attack — data/combat.json): the
// ON-SCREEN roll (50%) fires once the craft first flies into view —
// unless its arrival roll already commed it (a pirate never
// double-comms; the marks ride the ship).
if (e.comms?.attack && !e._commedAttack) {
const vis = this.craftVisible(e);
if (vis && !e._wasOnScreen) this._commAttackRoll(e, e.comms.attack.onScreenChance);
e._wasOnScreen = vis;
}
}
// 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire).
@ -952,6 +987,10 @@ export class CombatSystem {
this.fx.explode(e.x, e.y, '#ff5d6f');
combatShake(this.scene, 'kill'); // the world kick (the bar stays still)
combatSfx(this.scene, 'kill');
// THE DEFEAT COMM (data/combat.json → comms.defeat): a downed pirate
// rolls its defeat line (20%) — the comm's beat rides the same kill
// (once per death — killEnemy is idempotent via _killFx).
this._commDefeatRoll(e);
e.setVisible(false);
if (e.body) {
e.body.velocity.x = 0;
@ -985,9 +1024,69 @@ export class CombatSystem {
e.revive();
e.nextFireAt = time + 900;
e.setVisible(true);
// A new life: the comm rolls re-arm (the pirate comes back with its
// voice — the on-screen roll's edge resets with it).
e._commedAttack = false;
e._wasOnScreen = false;
this._setActive();
}
// ------------------------------------------------------ the comm rolls
//
// The comming type's VOICE (the comm's CONTENT — the clip, the random
// speech, the line — is data/comms.json's, keyed by the roll's trigger
// name: 'pirateAttack' / 'pirateDefeat', the senders 'pirate' /
// 'pirate_defeat'). The chances are the type's (data/combat.json →
// enemyTypes.<type>.comms); this system only rolls the dice and hands
// the trigger to o.onComm (the scene arms its CommsHub's trigger —
// js/comms/CommsHub.js; a context without one simply doesn't pass the
// seam, and the rolls stay no-ops). The marks ride the ship
// (e._commedAttack: an attack line already fired, so the other attack
// roll doesn't stack; e._wasOnScreen: the on-screen roll's edge), so a
// pirate comms at most once per life — the arrival (20%) OR the
// on-screen (50%) roll, never both.
/**
* The ATTACK roll (`chance` in [0, 1]): a hit arms the type's trigger.
* @param {object} e the hostile (its comms block + the roll's marks)
* @param {number} chance comms.attack.onScreenChance / .spawnChance
* @returns {boolean} true when the comm was armed
*/
_commAttackRoll(e, chance) {
const atk = e?.comms?.attack;
if (!atk || e._commedAttack || !this.onComm) return false;
const trigger = String(atk.trigger ?? '').trim();
const p = Math.min(1, Math.max(0, Number(chance) || 0));
if (p === 0 || trigger === '' || Math.random() >= p) return false;
e._commedAttack = true; // committed — the other attack roll is spent
try {
this.onComm(trigger);
} catch (err) {
if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err);
}
return true;
}
/**
* The DEFEAT roll (comms.defeat — once per death: killEnemy is
* idempotent via _killFx, so a downed pirate gets at most one line).
* @param {object} e the downed hostile
* @returns {boolean} true when the comm was armed
*/
_commDefeatRoll(e) {
const def = e?.comms?.defeat;
if (!def || !this.onComm) return false;
const trigger = String(def.trigger ?? '').trim();
const p = Math.min(1, Math.max(0, Number(def.chance) || 0));
if (p === 0 || trigger === '' || Math.random() >= p) return false;
try {
this.onComm(trigger);
} catch (err) {
if (typeof console !== 'undefined') console.warn('[combat] onComm hook failed', err);
}
return true;
}
// ------------------------------------------------------------- the bar
/**

View File

@ -1052,6 +1052,13 @@ export class GameScene extends Phaser.Scene {
this.combatHud?.kill('HOSTILE DESTROYED');
this.handleCombatKill(e);
},
// THE PIRATE'S COMM LINES (data/combat.json → enemyTypes.raider.comms
// rolls in CombatSystem — 50% on-screen / 20% rolling-arrival for the
// attack, 20% for the defeat; the comms themselves are data/comms.json's
// pirate-attack / pirate-defeat — the clip + the random voice + the
// line): arm the hub's trigger (js/comms — the box plays the clip
// muted with the speech and the message).
onComm: (trigger) => this.comms?.trigger(trigger),
// A combat level landed (the system fires its own burst + voice +
// the armor bump on the manager's 'levelup' event — the skill
// layer will subscribe to that same event later): the feed's line.
@ -5498,6 +5505,12 @@ export class GameScene extends Phaser.Scene {
epithet: String(plan.mark?.epithet ?? '').toUpperCase(),
n: String(total),
}), '\u2620');
// THE MARK'S OWN VOICE — the bounty attack comm (data/comms.json →
// pirate-bounty-attack: the bounty clip, assets/videos/enemies/
// pirate-bounty.mp4, + one of the four bounty voice clips + the line):
// armed the moment the wanted leader's fight stands up — every time a
// bounty ship enters combat (a jump out mid-fight re-arms it on return).
this.comms?.trigger('pirateBountyAttack');
this.combat.startWaves(script, {}); // NO onComplete witness — the kill is the witness (handleCombatKill)
return true;
}