From 1a581af211fbfcefd7789501fac68a8bd23bc84f Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 30 Sep 2026 16:57:52 -0600 Subject: [PATCH] Add faction crossfire: rivals shoot each other in war-zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bullets and seekers now carry their shooter's factionId so a faction's fire drops rival-faction craft but never its own wing (classic pack bolts/seekers are unaffected) - NPC kills via crossfire are silent: no kill count, XP, defeat comm, or screen shake — the war is the world's, not the player's ledger - Survivors of a neutral-to-player side peel off once their rivals are spent, clearing the sky for the director's cadence - Bump faction cooldown to 40–60 s and update data/encounters tests --- data/combat.json | 6 +-- dev/combat.test.mjs | 100 ++++++++++++++++++++++++++++++++++++ dev/encounters.test.mjs | 4 +- js/combat/CombatSystem.js | 99 ++++++++++++++++++++++++++++++++--- js/combat/EnemyAbilities.js | 4 ++ js/combat/Weapons.js | 4 ++ 6 files changed, 205 insertions(+), 12 deletions(-) diff --git a/data/combat.json b/data/combat.json index c1c7ca9..3f85fe7 100644 --- a/data/combat.json +++ b/data/combat.json @@ -1679,11 +1679,11 @@ ], "_tiers": "THE LEVEL-6 JUMP (design): 1–2 → 2–3 → 5–7 (a ~2× step in the pack) → 7–9 → 9–12 (the cap — past level 13 the count holds and the roster's heavier types are the pressure). roster = the band's weighted pirate MIX (js/combat/Encounters.js → compose draws it into `pirateTypes`; a band without one leaves the scene's random pick standing — tiers 0–5 predate the rosters): raiders stay the anchor (the filler), spitter/bomber land at the jump, stinger/gunner at 9+, hunter (missiles) and barrage (rings) at 13+ — the heavy hitters, never the early pack.", "faction": { - "_comment": "FACTION SPACE (the war-zones, js/combat/Encounters.js's compose context + GameScene._spawnFactionEncounter): in a faction-controlled system the ambient encounter is the LOCAL faction and one OPPOSING faction (js/combat/Encounters.js picks one of the other roster factions seeded; BOTH groups are sized off the `bands`' pirate counts, each drawn from its OWN combat.factions roster — no pirates/neutrals). DISPOSITION is per-faction, off the player's standing WITH each (js/reputation/Reputation.js → factionStanding, data/reputation.json): standing below `hostileBelow` → that faction targets the player and the player may return fire (the guns/skills' pools include it); at/above `hostileBelow` → it fights only the other faction, never the player (its bolts/seekers/rams can't touch the ship), and once its rival is spent it peels off (CombatSystem._peelOff) so the encounter ends on its own — the player observes. Factions ALWAYS fight each other while they're both up (CombatSystem.pickEnemyTarget). TIMERS: faction space rolls more often than neutral wild space (rollIntervalMin..Max, the director's roll schedule) with a shorter quiet after each encounter (cooldownMin..Max). The ships are the factions' OWN roster crafts (no new art).", + "_comment": "FACTION SPACE (the war-zones, js/combat/Encounters.js's compose context + GameScene._spawnFactionEncounter): in a faction-controlled system the ambient encounter is the LOCAL faction and one OPPOSING faction (js/combat/Encounters.js picks one of the other roster factions seeded; BOTH groups are sized off the `bands`' pirate counts, each drawn from its OWN combat.factions roster — no pirates/neutrals). DISPOSITION is per-faction, off the player's standing WITH each (js/reputation/Reputation.js → factionStanding, data/reputation.json): standing below `hostileBelow` → that faction targets the player and the player may return fire (the guns/skills' pools include it); at/above `hostileBelow` → it fights only the other faction, never the player (its bolts/seekers/rams can't touch the ship), and once its rival is spent it peels off (CombatSystem._peelOff) so the encounter ends on its own — the player observes. Factions ALWAYS fight each other while they're both up (CombatSystem.pickEnemyTarget). TIMERS: faction space rolls more often than neutral wild space (rollIntervalMin..Max, the director's roll schedule) with a longer quiet after each encounter (cooldownMin..Max, 40–60 s). The ships are the factions' OWN roster crafts (no new art).", "rollIntervalMin": 20, "rollIntervalMax": 40, - "cooldownMin": 20, - "cooldownMax": 40, + "cooldownMin": 40, + "cooldownMax": 60, "hostileBelow": -5 } }, diff --git a/dev/combat.test.mjs b/dev/combat.test.mjs index 96d838c..2aa46a2 100644 --- a/dev/combat.test.mjs +++ b/dev/combat.test.mjs @@ -1802,5 +1802,105 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d); // ------------------------------------------------------------------ done +// THE FACTION CROSSFIRE (the war-zones — both fleets shoot and kill each +// other, and the player's disposition is the only thing that decides who +// may fight the PLAYER): the crossfire collide passes (a faction's bolts / +// seekers drop a hostile-faction craft — never their own wing, never the +// classic pack), the SILENT npc kill (no kill count — the bounty is the +// player's, the war isn't), and the peel-off (the last survivor of a +// neutral-to-the-player side flies away once its rivals are spent, and the +// cull clears the sky for the director's cadence). +{ + const VW = 1280, VH = 720; + const scene = makeScene({ withSheet: true }); + scene.cameras = { main: { worldView: { x: 0, y: 0, width: VW, height: VH } } }; + const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 640, 360, '__ship', 0)); + ship.radius = 12; + ship.stop = () => {}; + const combat = new CombatSystem(scene, { ship, onKill: () => {} }); + const tag = (e, fid, hostileToPlayer) => { + e.factionId = fid; + e.hostileToPlayer = hostileToPlayer; + e.respawn = false; + e.nextFireAt = 999999999; // hold their own lances — the test drives the volleys + return e; + }; + const zen = tag(combat.spawnEnemy(500, 360, 0), 'zenith', false); + const apx = tag(combat.spawnEnemy(780, 360, 1), 'apex', false); + const zen2 = tag(combat.spawnEnemy(500, 300, 2), 'zenith', false); + const apx2 = tag(combat.spawnEnemy(780, 420, 3), 'apex', false); + + // TARGETING: each ship's target is the RIVAL faction — the player is not + // in the fight (both sides stand neutral to it). + check('crossfire: a faction ship targets the rival faction', + combat.pickEnemyTarget(zen, ship) === apx && combat.pickEnemyTarget(apx, ship) === zen); + check('crossfire: neutral-to-the-player — the player pool is empty (no auto-fire)', + combat._playerPool().length === 0 && combat.playerTargetable(zen) === false && + combat.playerTargetable(apx) === false); + + // CROSSFIRE SEEKERS: a faction's missile drops a hostile-faction craft + // (the same one-kill read; the seeker's own faction is the friendly-fire + // rule — and its neutral disposition keeps it off the player's hull). + // A minimal seeker stub, held still on the victim's line — the pass + // reads exactly this shape. + const seeker = { + x: 780 - 5, y: 360, radius: 12, dead: false, owner: 'enemy', + factionId: 'zenith', hostileToPlayer: false, targetRef: apx, + damage: 999, heading: 0, body: { velocity: { x: 0, y: 0 } }, + update: () => {}, // held on the line — the hit pass settles it + consume: () => { seeker.dead = true; return true; }, + }; + combat.missiles.push(seeker); + combat.update(1000, 16); + check('crossfire: a faction seeker drops the rival (and is spent)', + seeker.dead === true && apx.dead === true); + check('crossfire: the npc kill banks no kill count (not a player kill)', combat.kills === 0); + check('crossfire: the neutral seeker fizzles past the ship (no player threat)', ship.dead !== true); + + // CROSSFIRE BOLTS: zenith's lance drops the other apex hull. + combat.spawnBullet(780 - 18, 420, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16, factionId: 'zenith' }, 'enemy'); + combat.update(1016, 16); + check('crossfire: the rival hull takes the bolt and goes down', apx2.dead === true); + + // FRIENDLY FIRE: the same volley passes through the shooter's own wing. + combat.spawnBullet(500 - 18, 300, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16, factionId: 'zenith' }, 'enemy'); + combat.update(1032, 16); + check('crossfire: a faction volley never touches its own wing', zen2.dead === false); + + // CLASSIC PACK: a bolt with no faction stamp never shoots the pack + // (pirates don't fire on pirates — the seam holds). + combat.spawnBullet(500 - 18, 300, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'enemy'); + combat.update(1048, 16); + check('crossfire: a classic bolt passes through the pack', zen2.dead === false); + + // PEEL-OFF: the apex side is all spent — the neutral zenith survivors + // disengage (they steer AWAY from the ship, then the cull collects them + // off-screen — the sky clears for the director's cadence). + combat.update(1064, 16); + check("peel: the survivors' disengage arms (peeling)", zen.peeling === true && zen2.peeling === true); + check('peel: the velocity runs away from the player', + zen.body && zen.body.velocity.x < 0 && zen2.body && zen2.body.velocity.x < 0); + let frames = 0; + while ((combat.enemies.includes(zen) || combat.enemies.includes(zen2)) && frames < 240) { + if (zen.body) { zen.x += zen.body.velocity.x * 0.016; zen.y += zen.body.velocity.y * 0.016; } + if (zen2.body) { zen2.x += zen2.body.velocity.x * 0.016; zen2.y += zen2.body.velocity.y * 0.016; } + combat.update(1064 + (frames + 1) * 16, 16); + frames++; + } + check('peel: off-screen, the survivors are culled (the sky clears)', + frames < 240 && !combat.enemies.includes(zen) && !combat.enemies.includes(zen2) && + zen._peelGone === true && zen2._peelGone === true); + + // HOSTILE DISPOSITION: a faction ship the player HAS heat on keeps the + // classic fight — with no rival up, the player is its target (and the + // player's guns may engage it in return). + const hot = tag(combat.spawnEnemy(500, 360, 3), 'apex', true); + check('disposition: hostile-to-the-player — the player is the fallback target', + combat.pickEnemyTarget(hot, ship) === ship); + check('disposition: the player guns may engage a hostile faction (pool + lock)', + combat._playerPool().includes(hot) && combat.nearestEnemy(640, 360) === hot); + combat.destroy(); +} + console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`); process.exit(failures === 0 ? 0 : 1); diff --git a/dev/encounters.test.mjs b/dev/encounters.test.mjs index a6a1c0f..9d155ae 100644 --- a/dev/encounters.test.mjs +++ b/dev/encounters.test.mjs @@ -78,8 +78,8 @@ const fixed = { ); check('data: the post-encounter quiet is the ~30–60 s band', e.cooldownMin === 30 && e.cooldownMax === 60); check( - 'data: the FACTION schedule ships (tighter: 20–40 s rolls, 20–40 s quiet, the hostile floor)', - !!e.faction && e.faction.rollIntervalMin === 20 && e.faction.rollIntervalMax === 40 && e.faction.cooldownMin === 20 && e.faction.cooldownMax === 40 && e.faction.hostileBelow === -5, + 'data: the FACTION schedule ships (tighter rolls: 20–40 s, a 40–60 s quiet, the hostile floor)', + !!e.faction && e.faction.rollIntervalMin === 20 && e.faction.rollIntervalMax === 40 && e.faction.cooldownMin === 40 && e.faction.cooldownMax === 60 && e.faction.hostileBelow === -5, JSON.stringify(e.faction), ); check( diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 5c4143c..aaad8f3 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -1316,8 +1316,14 @@ export class CombatSystem { // The WAR-ZONE disposition (e.hostileToPlayer, see the spawner) rides // the volley — the bolts/seekers it spawns carry it, so a // neutral-to-the-player faction's fire fizzles past the ship (the - // collide passes + the missile's home rule). - return firePattern(this, e, player, { ...(e.wpn ?? {}), hostileToPlayer: e.hostileToPlayer !== false }); + // collide passes + the missile's home rule) — and the shooter's + // FACTION rides them too (the crossfire passes: a faction's fire + // hits the rival faction's craft, never its own wing). + return firePattern(this, e, player, { + ...(e.wpn ?? {}), + hostileToPlayer: e.hostileToPlayer !== false, + factionId: typeof e.factionId === 'string' ? e.factionId : null, + }); } /** @@ -1360,6 +1366,10 @@ export class CombatSystem { // neutral-to-the-player faction fizzles past the ship (collide's // lances×player pass). b.hostileToPlayer = typeof wpn.hostileToPlayer === 'boolean' ? wpn.hostileToPlayer : true; + // The shooter's FACTION (the crossfire passes' friendly-fire rule — + // a faction's bolt drops the rival faction's hulls, never its own + // wing; a classic bolt has none and never shoots itself). + b.factionId = typeof wpn.factionId === 'string' ? wpn.factionId : null; b.setDepth(20); this.bullets.push(b); return b; @@ -1461,6 +1471,36 @@ export class CombatSystem { } } } + // Their lances × the RIVAL FACTION's craft (the war-zones' crossfire + // — a faction's bolt drops a hostile-faction hull, exactly the way a + // pirate's can drop a neutral escort in the mixed fight). The bolt + // carries its shooter's faction (fireEnemy's stamp — spawnBullet): a + // faction's fire never touches its own wing (no friendly fire), and a + // classic pack (no faction stamp) never shoots itself. The kill is + // SILENT (killEnemy's npc flag — no XP, no defeat line, no shake: the + // bounty is the player's, the war isn't), and the downed side's + // survivors peel off (the update loop's gate). + for (let i = this.bullets.length - 1; i >= 0; i--) { + const b = this.bullets[i]; + if (b.dead || b.owner !== 'enemy') continue; + if (!b.factionId) continue; // a classic bolt: the pack never shoots itself + for (const e of this.enemies) { + if (e.dead) continue; + if (!e.factionId || e.factionId === b.factionId) continue; // own wing stands + if (!this.craftVisible(e)) continue; + if (this.hitsSwept(b, e, e.hitRadius, delta)) { + const preShields = e.shields; + const killed = e.damage(b.damage); + const kind = preShields > 0 ? 'shield' : 'hull'; + this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage); + this.fx.damage(b.x, b.y, b.damage, kind); + combatSfxEnemyHit(this.scene, preShields, b.damage); + if (killed) this.killEnemy(e, this._now(), true); + if (b.consume()) this.bullets.splice(i, 1); + break; + } + } + } // Their lances × the WRAITH'S DECOYS (data/skills.json → 'blink'): the // decoy is what drew the fire — a bolt that reaches it fizzles on the // shell (the hologram has no hull to break, only the pop). @@ -1521,6 +1561,9 @@ export class CombatSystem { // Their heat-seekers × your escorts (mixed fight: a pirate seeker // can drop a neutral ship). this.missilesHitAllies(delta); + // FACTION SEEKERS × the rival faction's craft (the war-zones' + // crossfire — a faction's seeker drops a hostile-faction hull). + this.missilesHitRivals(delta); // Your escorts' heat-seekers × the pack (the escorts' missile // patterns — one kill path, the XP still lands). this.missilesHitEnemies(delta); @@ -1569,6 +1612,43 @@ export class CombatSystem { } } + /** + * FACTION SEEKERS × the RIVAL FACTION's craft (the war-zones' crossfire + * — a faction's seeker drops a hostile-faction hull, exactly the way a + * pirate's can drop a neutral escort in the mixed fight; the seeker + * carries its shooter's faction — launchMissiles' stamp — so a + * faction's seekers never take their own wing, and a classic seeker + * (no faction) never shoots the pack). The kill is SILENT (killEnemy's + * npc flag — no XP, no defeat line: the bounty is the player's, the + * war isn't). + */ + missilesHitRivals(delta) { + const ms = this.missiles; + if (!ms || ms.length === 0 || this.enemies.length === 0) return; + for (let i = ms.length - 1; i >= 0; i--) { + const m = ms[i]; + if (m.dead) { ms.splice(i, 1); continue; } + if (m.owner !== 'enemy') continue; + if (!m.factionId) continue; // a classic seeker: the pack never shoots itself + for (const e of this.enemies) { + if (e.dead) continue; + if (!e.factionId || e.factionId === m.factionId) continue; // own wing stands + if (!this.craftVisible(e)) continue; + if (this.hitsSwept(m, e, e.hitRadius, delta)) { + const preShields = e.shields; + const killed = e.damage(m.damage); + const kind = preShields > 0 ? 'shield' : 'hull'; + this.fx.enemyHit(e, m.x, m.y, m.heading, kind, m.damage); + this.fx.damage(m.x, m.y, m.damage, kind); + combatSfxEnemyHit(this.scene, preShields, m.damage); + if (killed) this.killEnemy(e, this._now(), true); + if (m.consume()) ms.splice(i, 1); + break; + } + } + } + } + /** Escort seekers × the pack — the same hit read as the player's. */ missilesHitEnemies(delta) { const ms = this.missiles; @@ -1777,7 +1857,7 @@ export class CombatSystem { * / the type's override). Idempotent via _killFx — damage() flips * e.dead first, so the dead flag can't be the "already counted" guard. */ - killEnemy(e, time) { + killEnemy(e, time, npc = false) { if (e._killFx) return; e._killFx = true; // Settle any pending white hit-flash NOW — the craft dies in its @@ -1786,23 +1866,28 @@ export class CombatSystem { // AND settle any strung read (the kill supersedes the stun's dim and // discharge — no dimmed corpse, no orphaned bolts). this._stunRelease(e, false); - this.kills += 1; + // THE NPC KILL (the war-zones' crossfire — a faction dropped the + // craft, not the player): the read stands (the wreck, the beat), + // but the kill's BOUNTY doesn't — no XP, no kill count, no defeat + // line, no screen kick (the war is the world's, the player's ledger + // is the player's). + if (!npc) this.kills += 1; // COMBAT XP — the kill's reward (the hostile's own type's value — // combat.enemy.xp / the type's override; the curve is // data/progression.json). The +N floater lands on the wreckage, and a // crossed level's beat rides the manager's 'levelup' event (the armor // bump, the burst, the feed's line) — the kill path stays one grant. - if (this.xp) { + if (!npc && this.xp) { const gained = this.xp.grant(Number(e.xp) || 0, 'kill'); if (gained.gained > 0) this.fx.xp(e.x, e.y, gained.gained); } this.fx.explode(e.x, e.y, '#ff5d6f'); - combatShake(this.scene, 'kill'); // the world kick (the bar stays still) + if (!npc) 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); + if (!npc) this._commDefeatRoll(e); e.setVisible(false); if (e.body) { e.body.velocity.x = 0; diff --git a/js/combat/EnemyAbilities.js b/js/combat/EnemyAbilities.js index 594f177..89e8dfc 100644 --- a/js/combat/EnemyAbilities.js +++ b/js/combat/EnemyAbilities.js @@ -538,6 +538,10 @@ const HANDLERS = { // neutral-to-the-player faction's wing seekers never re-home // onto the ship when their victim falls (Weapons.js's rule). m.hostileToPlayer = e.hostileToPlayer === true; + // The shooter's FACTION (the crossfire pass's friendly-fire + // rule — a faction's wing seekers drop the rival faction's + // craft, never its own wing). + m.factionId = typeof e.factionId === 'string' ? e.factionId : null; ctx.missiles.push(m); } catch { /* stub scene — the volley's beat still plays */ } } diff --git a/js/combat/Weapons.js b/js/combat/Weapons.js index a0cacf4..ff4bd16 100644 --- a/js/combat/Weapons.js +++ b/js/combat/Weapons.js @@ -241,6 +241,10 @@ export function launchMissiles(ctx, ship, player, wpn) { // onto the weapon): the seeker's fizzle rule + the no-rehome rule // above read it off the missile. m.hostileToPlayer = typeof wpn.hostileToPlayer === 'boolean' ? wpn.hostileToPlayer : true; + // The shooter's FACTION (the crossfire pass's friendly-fire rule — + // a faction's seeker drops the rival faction's craft, never its own + // wing; a classic seeker has none and the pass stands it off). + m.factionId = typeof wpn.factionId === 'string' ? wpn.factionId : null; ctx.missiles.push(m); } combatSfx(scene, 'missile_fire'); // the rocket voice (distinct from the laser)