From 89fac9ef78f629622659da345189d8184244606f Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 30 Sep 2026 16:28:56 -0600 Subject: [PATCH] Rework faction encounters into two-faction crossfire with per-player dis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Faction space now deploys the local faction AND one rival as hostile groups (previously only an opposing faction attacked, local was allies), with both sized off the level band. - Each ship's hostility toward the player is stamped from reputation standing vs `encounters.faction.hostileBelow`; neutral factions are observe-only — untargetable by guns/skills/escort fire, their bolts/seekers/rams fizzle past the ship, and they peel off once rivals are spent. - Add faction-space encounter timers (tighter roll interval + cooldown) in `encounters.faction`, applied via `ctx.faction` in the director. - Route all player-targeting paths through a new `_playerPool()`/ `playerTargetable()` gate; add `_peelOff` disengage and rival-live check. - Update tests for the two-group compose, disposition, and faction timers. --- data/combat.json | 11 +- dev/encounters.test.mjs | 83 ++++++++++--- js/combat/CombatSystem.js | 233 ++++++++++++++++++++++++++++++++---- js/combat/Encounters.js | 123 ++++++++++++------- js/combat/EnemyAbilities.js | 4 + js/combat/Weapons.js | 10 ++ js/scenes/GameScene.js | 127 +++++++++++--------- 7 files changed, 440 insertions(+), 151 deletions(-) diff --git a/data/combat.json b/data/combat.json index 64fba34..c1c7ca9 100644 --- a/data/combat.json +++ b/data/combat.json @@ -1542,7 +1542,7 @@ "respawn": false }, "encounters": { - "_comment": "PERIODIC RANDOM ENCOUNTERS (js/combat/Encounters.js → EncounterDirector; GameScene spawns on a hit) — every NEUTRAL non-home system, while flying, no combat live, no wave run in flight, one at a time. The director rolls on the interval schedule (rollIntervalMin–rollIntervalMax s; each attempt fires at chanceMin–chanceMax probability), and after an encounter ENDS it stays quiet for cooldownMin–cooldownMax s before the next roll. KIND rolls at `weights` (shipped even — ~1/3 each). SIZE: the player's combat level (js/combat/XpManager — a fresh run starts at 0) picks a band; mixed keeps both kinds present. Encounter hostiles are one-shots (no respawn); escorts carry the neutral.escortMin/escortMax window and break off on their own (js/combat/NeutralShip.js). Seconds + 0–1 probabilities — no code constants.", + "_comment": "PERIODIC RANDOM ENCOUNTERS (js/combat/Encounters.js → EncounterDirector; GameScene spawns on a hit) — NEUTRAL non-home space: pirates / neutrals / mixed; FACTION space (the war-zones — `encounters.faction` below): the local faction and one opposing faction, disposition off the player's standing with each (see that section's comment). The director rolls on the interval schedule (rollIntervalMin–rollIntervalMax s — `faction.rollIntervalMin..Max` in faction space; each attempt fires at chanceMin–chanceMax probability), and after an encounter ENDS it stays quiet for cooldownMin–cooldownMax s (or `faction.cooldownMin..Max` in faction space) before the next roll. KIND rolls at `weights` (shipped even — ~1/3 each; bypassed in faction space). SIZE: the player's combat level (js/combat/XpManager — a fresh run starts at 0) picks a band; mixed keeps both kinds present. Encounter hostiles are one-shots (no respawn); escorts carry the neutral.escortMin/escortMax window and break off on their own (js/combat/NeutralShip.js). Seconds + 0–1 probabilities — no code constants.", "enabled": true, "rollIntervalMin": 45, "rollIntervalMax": 90, @@ -1679,9 +1679,12 @@ ], "_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 roll is ALWAYS an opposing faction (never the controller, never pirates/neutrals — js/combat/Encounters.js picks one of the other roster factions seeded, sized off the `bands`' pirate counts, the rival's types drawn from its own combat.factions roster). The LOCAL faction's fighters join the player as allies (NeutralShip's 'side: player' seam — they engage the hostiles, never the player, and the player's guns never target them): `allyCount` fighters, each on an `allyWindowMs` escort window (they stand for the fight and peel off when it lapses — data/combat.json → encounters.faction). The ships are the factions' OWN roster crafts (no new art).", - "allyCount": 2, - "allyWindowMs": 90000 + "_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).", + "rollIntervalMin": 20, + "rollIntervalMax": 40, + "cooldownMin": 20, + "cooldownMax": 40, + "hostileBelow": -5 } }, "waves": { diff --git a/dev/encounters.test.mjs b/dev/encounters.test.mjs index 3457d3e..a6a1c0f 100644 --- a/dev/encounters.test.mjs +++ b/dev/encounters.test.mjs @@ -77,6 +77,11 @@ const fixed = { e.chanceMin >= 0.5 && e.chanceMax <= 0.6 && e.chanceMin <= e.chanceMax, ); 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, + JSON.stringify(e.faction), + ); check( 'data: the kind weights are EVEN (~1/3 each)', e.weights.pirates === 1 && e.weights.neutrals === 1 && e.weights.mixed === 1, @@ -308,44 +313,84 @@ const fixed = { // ------------------------------------------------------------- faction space { const e = cfg(); + const roster = { apex: ['ax_a', 'ax_b'], mindweave: ['mw_a', 'mw_b'], zenith: ['zn_a'] }; const ctx = (faction) => ({ mode: 'faction', faction, + local: { id: faction, types: roster[faction] }, enemies: [ { id: 'mindweave', types: ['mw_a', 'mw_b'] }, { id: 'zenith', types: ['zn_a'] }, ].filter((x) => x.id !== faction), }); const c1 = compose(e, 1, () => 0, ctx('apex')); - check('faction: the roll is ALWAYS a faction kind (never pirates/neutrals)', c1.kind === 'faction' && c1.neutrals === 0); - check('faction: the enemy is an OPPOSING faction (never the controller)', c1.faction !== 'apex' && ['mindweave', 'zenith'].includes(c1.faction)); - check('faction: the types come from the ENEMY faction\u2019s roster', c1.types.every((t) => (c1.faction === 'mindweave' ? ['mw_a', 'mw_b'] : ['zn_a']).includes(t))); - check('faction: the count sits in the level band (level 1: 1\u20132)', compose(e, 1, () => 0, ctx('apex')).count === 1 && compose(e, 1, () => 0.999, ctx('apex')).count === 2); - check('faction: level 8 sizes off the band\u2019s pirate count (5\u20137)', compose(e, 8, () => 0, ctx('apex')).count === 5 && compose(e, 8, () => 0.999, ctx('apex')).count === 7); - // Both rivals are reachable (the roll walks the enemy list). - const seen = new Set(Array.from({ length: 50 }, (_, i) => compose(e, 1, () => i / 50, ctx('apex')).faction)); - check('faction: BOTH opposing factions are reachable', seen.has('mindweave') && seen.has('zenith'), [...seen].join(',')); - // Determinism: the same seed walks the same enemy + the same types. + check('faction: the roll is ALWAYS a faction kind (never pirates/neutrals)', c1.kind === 'faction' && c1.neutrals === 0 && Array.isArray(c1.groups), JSON.stringify(c1)); + check('faction: TWO groups deploy — the LOCAL faction + one RIVAL (never the controller)', + c1.groups.length === 2 && c1.groups[0].id === 'apex' && c1.groups[1].id !== 'apex' && ['mindweave', 'zenith'].includes(c1.groups[1].id), JSON.stringify(c1)); + check('faction: each group draws from ITS OWN faction\u2019s roster', + c1.groups.every((g) => g.types.every((t) => roster[g.id].includes(t)))); + check('faction: each group\u2019s count sits in the level band (level 1: 1\u20132)', + c1.groups.every((g) => g.count === 1) && compose(e, 1, () => 0.999, ctx('apex')).groups.every((g) => g.count === 2)); + check('faction: level 8 sizes BOTH groups off the band\u2019s pirate count (5\u20137)', + compose(e, 8, () => 0, ctx('apex')).groups.every((g) => g.count === 5) && compose(e, 8, () => 0.999, ctx('apex')).groups.every((g) => g.count === 7)); + // Both rivals are reachable (the roll walks the rival list). + const seen = new Set(Array.from({ length: 50 }, (_, i) => compose(e, 1, () => i / 50, ctx('apex')).groups[1]?.id)); + check('faction: BOTH opposing factions are reachable (the rival slot)', seen.has('mindweave') && seen.has('zenith'), [...seen].join(',')); + // Determinism: the same seed deploys the same rival + groups. const d1 = compose(e, 3, () => 0.37, ctx('zenith')); const d2 = compose(e, 3, () => 0.37, ctx('zenith')); - check('faction: a fixed seed reproduces the roll (enemy + types + count)', d1.faction === d2.faction && d1.count === d2.count && JSON.stringify(d1.types) === JSON.stringify(d2.types)); + check('faction: a fixed seed reproduces the roll (rival + groups)', JSON.stringify(d1) === JSON.stringify(d2)); // A rival without a roster is skipped (never deployed). - const ctxNoRoster = { mode: 'faction', faction: 'apex', enemies: [{ id: 'mindweave', types: [] }, { id: 'zenith', types: ['zn_a'] }] }; + const ctxNoRoster = { mode: 'faction', faction: 'apex', local: ctx('apex').local, enemies: [{ id: 'mindweave', types: [] }, { id: 'zenith', types: ['zn_a'] }] }; const c3 = compose(e, 1, () => 0, ctxNoRoster); - check('faction: a roster-less rival is skipped (the rostered one stands)', c3.faction === 'zenith'); - // No enemies at all (a roster-less world) degrades to the neutral roll. - const c4 = compose(e, 1, () => 0, { mode: 'faction', faction: 'apex', enemies: [] }); - check('faction: no enemies \u2192 the neutral roll stands (defensive)', c4.kind !== 'faction'); + check('faction: a roster-less rival is skipped (the rostered one stands)', c3.groups[1].id === 'zenith', JSON.stringify(c3)); + // A roster-less LOCAL faction (defensive) degrades to the neutral roll. + const c4 = compose(e, 1, () => 0, { mode: 'faction', faction: 'apex', local: { id: 'apex', types: [] }, enemies: ctx('apex').enemies }); + check('faction: a roster-less LOCAL faction \u2192 the neutral roll stands (defensive)', c4.kind !== 'faction'); + // No rivals at all (a one-faction world) degrades to the neutral roll. + const c5 = compose(e, 1, () => 0, { mode: 'faction', faction: 'apex', local: ctx('apex').local, enemies: [] }); + check('faction: no rival rosters \u2192 the neutral roll stands (defensive)', c5.kind !== 'faction'); // The director composes the faction roll when the context rides the ctx. const dd = new EncounterDirector(fixed, { rng: () => 0 }); - dd.update(0, { allowed: true, live: false, xpLevel: 1, context: ctx('apex') }); - const s = dd.update(50000, { allowed: true, live: false, xpLevel: 1, context: ctx('apex') }); - check('faction (director): the hit deploys a faction enemy (not pirates/neutrals)', !!s && s.kind === 'faction' && s.faction !== 'apex', JSON.stringify(s)); - // Neutral space is UNCHANGED: no context \u2192 the classic roll. + dd.update(0, { allowed: true, live: false, xpLevel: 1, context: ctx('apex'), faction: true }); + const s = dd.update(50000, { allowed: true, live: false, xpLevel: 1, context: ctx('apex'), faction: true }); + check('faction (director): the hit deploys the local + rival groups', !!s && s.kind === 'faction' && s.groups[0].id === 'apex' && s.groups[1].id !== 'apex', JSON.stringify(s)); + // NEUTRAL SPACE IS UNCHANGED: no context \u2192 the classic roll. const n1 = compose(e, 1, () => 0); check('neutral: without a context the classic roll stands (unchanged)', n1.kind !== 'faction'); } +// ------------------------------------------------------------- faction timers +// The war-zones roll on the TIGHTER cadence (data/combat.json → +// encounters.faction): ctx.faction takes the faction schedule (the +// degenerate 20–40s roll / 20s quiet below) for BOTH the roll and the +// post-fight cooldown, NOT the neutral-space one (the degenerate +// 50s / 30s). A context WITHOUT ctx.faction (the escort director's +// shape) stays on the neutral schedule. +{ + const eF = { ...fixed, faction: { rollIntervalMin: 20, rollIntervalMax: 40, cooldownMin: 20, cooldownMax: 20 } }; + const ctx = { mode: 'faction', faction: 'apex', local: { id: 'apex', types: ['ax_a'] }, enemies: [{ id: 'zenith', types: ['zn_a'] }] }; + // Roll schedule: faction space's next roll sits at the faction interval + // (20–40s → 30000 at rng 0.5), not the neutral one (50s). + const dFac = new EncounterDirector(eF, { rng: () => 0.5 }); + dFac.update(0, { allowed: true, live: false, xpLevel: 1, context: ctx, faction: true }); // the first schedule + check('faction timers: the next roll takes the FACTION schedule (20–40s → 30000 at rng 0.5), not the neutral one (50s)', dFac.nextRollAt === 30000, String(dFac.nextRollAt)); + const s = dFac.update(30000, { allowed: true, live: false, xpLevel: 1, context: ctx, faction: true }); + check('faction timers: the scheduled roll deploys a faction fight (local + rival)', !!s && s.kind === 'faction' && s.groups.length === 2, JSON.stringify(s)); + // Post-fight quiet: the cooldown is the faction schedule (20s), not + // the neutral one (30s). + const dCd = new EncounterDirector(eF, { rng: () => 0.5 }); + dCd.update(0, { allowed: true, live: false, xpLevel: 1 }); // the first schedule (neutral: 50s) + dCd.update(50000, { allowed: true, live: false, xpLevel: 1, context: ctx, faction: true }); // the spawn + dCd.update(51000, { allowed: true, live: false, xpLevel: 1, context: ctx, faction: true }); // the fight ends → the quiet + check('faction timers: the post-fight wait takes the FACTION cooldown (20s → 71000), not the neutral one (30s → 81000)', dCd.quietUntil === 51000 + 20000, String(dCd.quietUntil)); + // A context WITHOUT ctx.faction (the escort director's shape) stays on + // the neutral schedule. + const dNo = new EncounterDirector(eF, { rng: () => 0.5 }); + dNo.update(0, { allowed: true, live: false, xpLevel: 1, context: ctx }); + check('faction timers: without ctx.faction the neutral schedule stands (50s)', dNo.nextRollAt === 50000, String(dNo.nextRollAt)); +} + // --------------------------------------------------------------------- done if (failures) { console.log(`\n✘ ${failures} ENCOUNTER-DIRECTOR CHECK(S) FAILED`); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index bf33365..5c4143c 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -282,7 +282,7 @@ export class CombatSystem { const run = this.waveRun; if (run && run.state !== 'done' && run.state !== 'cancelled') return true; return ( - this.enemies.some((e) => !e.dead || (e._respawnPending && e.respawnAt > 0)) || + this.enemies.some((e) => (!e.dead && !e.gone) || (e._respawnPending && e.respawnAt > 0)) || // A live escort keeps the fight up too (its lances are live even // when the pack is spent — the encounter ends when it's OFF). A // PERSISTENT ally (the escort contract's transport — @@ -780,6 +780,15 @@ export class CombatSystem { if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time); continue; } + // THE FACTION DISPERSE (the war-zones): a faction's ship whose rival + // group is all spent and who bears no heat on the player peels away + // and flies off-screen — the encounter ends on its own (the player + // observed it; the sky has to clear for the director's cadence). + // Ships that DO bear on the player keep the classic orbit instead. + if (e.factionId && e.hostileToPlayer !== true && !this._rivalLive(e.factionId)) { + this._peelOff(e, delta); + continue; + } // THE SNARE (data/skills.json → 'boleadoras'): a strung hostile is // DISABLED until its stun clock runs out — no orbit AI, no lances (the // craft stands on the line where the cords caught it) — and it SHOWS @@ -820,10 +829,12 @@ export class CombatSystem { // break AI on their own clocks. The on-screen rule gates their // lances exactly like the pack's (an off-screen escort holds its // fire until it flies into view), and their target is always a - // pirate — the player is never aimed at. + // pirate — the player is never aimed at (in a war-zone the target + // list is the player's OWN pool — a neutral-to-the-player faction + // never takes escort fire either). for (const a of this.allies) { if (a.dead) continue; - const r = a.update(time, delta, player, this.enemies); + const r = a.update(time, delta, player, this._playerPool()); if (r.wantsFire && player && r.target && r.target.dead !== true && this.craftVisible(a)) { this.fireAlly(a, r.target, time); } @@ -872,6 +883,19 @@ export class CombatSystem { this._setActive(); } + // 1c) PEELERS (the war-zones' disengage): a faction ship fully out of + // view after its rivals were spent is collected — the roster + // drops, the director's cadence resumes. + if (this.enemies.some((e) => e && e._peelGone)) { + for (const e of this.enemies) { + if (!e || !e._peelGone) continue; + this.fx.releaseCraft(e); // settle any flash before the craft goes + if (e.destroy && !e.destroyed) e.destroy(); + } + this.enemies = this.enemies.filter((e) => !(e && e._peelGone)); + this._setActive(); + } + // 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire). // The mouse steers the ship; the cannon tracks on its own — but // only at what's on screen (off-screen hostiles can't be fired @@ -962,9 +986,9 @@ export class CombatSystem { this._waveTick(time); } - /** The nearest live hostile to (x, y), or null (the shared pickTarget rule). */ + /** The nearest live targetable hostile to (x, y), or null (the shared pickTarget rule). */ nearestEnemy(x, y) { - return pickTarget(this.enemies, x, y); + return pickTarget(this._playerPool(), x, y); } /** @@ -986,14 +1010,40 @@ export class CombatSystem { } /** - * THE CANNON'S TARGET POOL — the live hostiles PLUS the live MINDWEAVE - * ILLUSIONS (js/combat/EnemyAbilities.js): a phantom that stands in - * the pool is what the auto-fire engages (the phantoms absorb the - * tracers — collide pops them; the real hull stands elsewhere). + * Can the PLAYER'S guns / skills engage this hostile? Everything may, + * EXCEPT a FACTION ENCOUNTER ship whose own faction stands neutral to + * the player (the war-zone disposition — data/combat.json → + * encounters.faction.hostileBelow; the spawner stamps `factionId` + + * `hostileToPlayer` on the ship): the player observes that side of the + * fight — the auto-fire's pool, the target lock, and the skills' + * nearest/radius picks all run through this. + * @param {object|null} e + * @returns {boolean} + */ + playerTargetable(e) { + return !!e && !(e.factionId && e.hostileToPlayer !== true); + } + + /** The pack narrowed to what the player may engage (see playerTargetable). */ + _playerPool() { + return this.enemies.filter((e) => this.playerTargetable(e)); + } + + /** + * THE CANNON'S TARGET POOL — the live hostiles the player may engage + * (the war-zone disposition filters, see playerTargetable) PLUS the + * live MINDWEAVE ILLUSIONS (js/combat/EnemyAbilities.js): a phantom + * that stands in the pool is what the auto-fire engages (the phantoms + * absorb the tracers — collide pops them; the real hull stands + * elsewhere). In a war-zone where EVERY hostile is neutral to the + * player the pool stays EMPTY (a phantom of a faction you're not + * fighting is not something you shoot at either). * @returns {Array} the pool (pickTarget skips the dead) */ _firePool() { - return [...this.enemies, ...(Array.isArray(this.illusions) ? this.illusions : [])]; + const own = this._playerPool(); + if (own.length === 0) return []; + return [...own, ...(Array.isArray(this.illusions) ? this.illusions : [])]; } /** @@ -1032,10 +1082,20 @@ export class CombatSystem { * (a neutral ship draws aggro when the pirate can't reach the * player); else the player (the classic orbit — the fire gate keeps * it honest until in range). + * + * FACTION ENCOUNTERS (the war-zones): a tagged ship (`e.factionId`) + * ALWAYS fights the OTHER faction present — the nearest live rival-faction + * craft in weapon range, chasing it when out of range — and the player + * only when the ship's disposition is hostile (`e.hostileToPlayer`, + * stamped by the spawner off the player's standing with the faction — + * data/combat.json → encounters.faction.hostileBelow). With no rival up + * and no heat on the player the ship stands targetless (the caller's + * fire gate holds its lances) and the update loop peels it off (the + * sky has to clear for the director's cadence). * @returns {object|null} the craft to orbit + fire at (null = player down) */ pickEnemyTarget(e, player) { - if (!player) return null; + if (!player && !e.factionId) return null; // THE WRAITH'S DECOY (data/skills.json → 'blink') carries ALL the aggro // while it stands: the nearest live decoy is what every pirate orbits // and lances (bolts that reach it fizzle — collide's decoy pass), and @@ -1048,6 +1108,38 @@ export class CombatSystem { if (d2 < decoyD) { decoyD = d2; decoy = d; } } if (decoy) return decoy.sprite; + // THE FACTION WAR (see above): rivals first, the player only when + // the disposition is hostile. + if (e.factionId) { + const range = Number((e.wpn && e.wpn.range) || 0); + const inRange = (o) => + range > 0 ? (o.x - e.x) ** 2 + (o.y - e.y) ** 2 <= range * range : true; + let inBest = null; + let inBestD = Infinity; + let outBest = null; + let outBestD = Infinity; + const consider = (o) => { + const d2 = (o.x - e.x) ** 2 + (o.y - e.y) ** 2; + if (inRange(o)) { + if (d2 < inBestD) { inBestD = d2; inBest = o; } + } else if (d2 < outBestD) { + outBestD = d2; outBest = o; + } + }; + for (const s of this.enemies) { + if (s === e || s.dead || s.gone || s.cloaked) continue; + if (!s.factionId || s.factionId === e.factionId) continue; + consider(s); + } + if (e.hostileToPlayer === true && player) consider(player); + if (inBest) return inBest; + if (outBest) return outBest; // the rival is out of range — chase it + // Nothing to chase and no heat on the player: targetless (the + // caller's fire gate holds the lances; the update loop peels the + // ship off once its rivals are spent). + return null; + } + if (!player) return null; const range = Number((e.wpn && e.wpn.range) || 0); const inRange = (o) => range > 0 ? (o.x - e.x) ** 2 + (o.y - e.y) ** 2 <= range * range : true; @@ -1062,6 +1154,71 @@ export class CombatSystem { return best ?? player; } + /** + * A live enemy of ANOTHER faction (the war-zones' cross-faction fight — + * the peeling gate: when the last one is spent, a ship that doesn't + * bear on the player peels off). + * @param {string} factionId + * @returns {boolean} + */ + _rivalLive(factionId) { + return this.enemies.some( + (s) => + !!s && + !!s.factionId && + s.factionId !== factionId && + !s.dead && + !s.gone && + !s._peelGone + ); + } + + /** + * PEEL OFF (the war-zones' disengage): a faction ship whose rival group + * is spent and who bears no heat on the player steers AWAY from the + * player at full speed and, once fully off the camera's visible rect, + * is gone (the update loop collects it) — the encounter's live count + * drops and the director's cadence resumes (the player only observed; + * no kill read — the craft isn't a bounty). Mirrors NeutralShip's + * 'break' state (the player's own escorts peel the same way). + * @param {object} e the peeling hostile + * @param {number} delta frame delta (ms) + */ + _peelOff(e, delta) { + e.peeling = true; + if (e.gone) return; // already off-screen — the cull collects it + const dt = Math.min(delta, 64) / 1000; + const px = this.ship ? this.ship.x : (e.lastPx ?? e.x); + const py = this.ship ? this.ship.y : (e.lastPy ?? e.y); + const dx = e.x - px; + const dy = e.y - py; + const d = Math.hypot(dx, dy) || 1; + const dirx = dx / d; + const diry = dy / d; + const sp = Math.max(240, (Number(e.speed) || 0) * 1.6); + const k = 1 - Math.exp(-2.5 * dt); + const body = e.body; + if (body) { + body.velocity.x += (dirx * sp - body.velocity.x) * k; + body.velocity.y += (diry * sp - body.velocity.y) * k; + } + const want = body && body.velocity.length() > 4 + ? Math.atan2(body.velocity.y, body.velocity.x) + : Math.atan2(diry, dirx); + e.rotateToward(want + (e.artOffset || 0), dt); + const view = this.visibleView(); + if (view && !inView(e.x, e.y, Math.max(e.hitRadius || 0, (e.size || 0) / 2), view)) { + e.gone = true; // out of the fight — the `active` gate and the cull take it from here + e.setVisible(false); + if (body) { + body.velocity.x = 0; + body.velocity.y = 0; + } + e._peelGone = true; + this._setActive(); + } + } + /** * RE-ANCHOR THE PACK AROUND THE SHIP — in the main game the ship's * start position settles AFTER create() (the tether barrier / a save @@ -1156,7 +1313,11 @@ export class CombatSystem { * enemyTypes, weapon.pattern). */ fireEnemy(e, player, time) { - return firePattern(this, e, player, e.wpn ?? {}); + // 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 }); } /** @@ -1177,7 +1338,7 @@ export class CombatSystem { * fuse ends it). */ allyMissileTarget(m) { - return pickTarget(this.enemies, m.x, m.y); + return pickTarget(this._playerPool(), m.x, m.y); } spawnBullet(x, y, angle, wpn, owner) { @@ -1194,6 +1355,11 @@ export class CombatSystem { boltWidth: Number(wpn.boltWidth) || 0, owner, }); + // The WAR-ZONE disposition rides the bolt (the shooter's stamp — + // fireEnemy merges it onto the weapon): an 'enemy' bolt from a + // neutral-to-the-player faction fizzles past the ship (collide's + // lances×player pass). + b.hostileToPlayer = typeof wpn.hostileToPlayer === 'boolean' ? wpn.hostileToPlayer : true; b.setDepth(20); this.bullets.push(b); return b; @@ -1241,13 +1407,15 @@ export class CombatSystem { } } } - // Your tracers × hostiles. + // Your tracers × hostiles (the war-zone disposition: a neutral-to- + // the-player faction's craft is untargetable — the pool's rule). if (player) { for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'player') continue; for (const e of this.enemies) { if (e.dead) continue; + if (!this.playerTargetable(e)) continue; // observe-only: the ship stands if (e.cloaked) continue; // the cloak holds: unhittable while it phases if (!this.craftVisible(e)) continue; // off-screen: can't be hit if (this.hitsSwept(b, e, e.hitRadius, delta)) { @@ -1270,12 +1438,15 @@ export class CombatSystem { } // Your escorts' tracers × the pack (the neutral ships' patterns — // the same hit read as your own; the kill still banks the pirate's - // XP through the one grant). + // XP through the one grant). In a war-zone the pack is narrowed to + // the player's own pool — a neutral-to-the-player faction never + // takes escort fire (see playerTargetable). for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'ally') continue; for (const e of this.enemies) { if (e.dead) continue; + if (!this.playerTargetable(e)) continue; if (!this.craftVisible(e)) continue; if (this.hitsSwept(b, e, e.hitRadius, delta)) { const preShields = e.shields; @@ -1307,11 +1478,14 @@ export class CombatSystem { } } } - // Their lances × you. + // Their lances × you (the war-zone disposition: a neutral-to-the- + // player faction's bolts fizzle by — they never aimed at you, and the + // stray ones pass through the ship). if (player) { for (let i = this.bullets.length - 1; i >= 0; i--) { const b = this.bullets[i]; if (b.dead || b.owner !== 'enemy') continue; + if (b.hostileToPlayer === false) continue; if (this.hitsSwept(b, player, Number(player.radius) || 12, delta)) { // The shield was struck from the laser's incoming bearing (the // side facing its source — the travel's opposite). @@ -1357,7 +1531,9 @@ export class CombatSystem { // settlement for the fly-by, so a single skill never lands two hits. if (player && !this.dash) { for (const e of this.enemies) { - if (!e.dead) this.ramPair(player, e); + // A neutral-to-the-player faction's craft never rams the ship — + // the bounce read is a hostile's, and the player only observes. + if (!e.dead && this.playerTargetable(e)) this.ramPair(player, e); } } } @@ -1375,6 +1551,7 @@ export class CombatSystem { const m = ms[i]; if (m.dead) { ms.splice(i, 1); continue; } if (m.owner === 'ally') continue; + if (m.hostileToPlayer === false) continue; // the neutral faction's seeker: no player threat for (const a of this.allies) { if (a.dead || !this.craftVisible(a)) continue; if (this.hitsSwept(m, a, a.hitRadius, delta)) { @@ -1402,6 +1579,7 @@ export class CombatSystem { if (m.owner !== 'ally') continue; for (const e of this.enemies) { if (e.dead) continue; + if (!this.playerTargetable(e)) continue; // the neutral faction stands (see playerTargetable) if (!this.craftVisible(e)) continue; if (this.hitsSwept(m, e, e.hitRadius, delta)) { const preShields = e.shields; @@ -2103,7 +2281,7 @@ export class CombatSystem { * @returns {{hits: number, kills: number, damage: number}} */ skillBlast(x, y, radius, damage, fx = {}) { - const hit = radiusHits(this.enemies, x, y, radius); + const hit = radiusHits(this._playerPool(), x, y, radius); let kills = 0; for (const e of hit) { const preShields = e.shields; @@ -2143,10 +2321,12 @@ export class CombatSystem { const eff = def.effect; const speed = Math.max(1, Number(eff.speed) || 0); const count = Math.max(0, Math.floor(Number(eff.targetCount) || 0)); - // The N NEAREST live, UN-CLOAKED hostiles (a phased-out craft can't be - // targeted — and has no on-screen filter — the throw is cast from the - // hull, the way the dash aims: at whatever's nearest in view). - const nearest = this.enemies + // The N NEAREST live, UN-CLOAKED hostiles the player may engage (a + // phased-out craft can't be targeted — and has no on-screen filter — + // the throw is cast from the hull, the way the dash aims: at + // whatever's nearest in view; a neutral-to-the-player faction stands + // — the war-zone's pool rule). + const nearest = this._playerPool() .filter((e) => !e.dead && !e._removed && !e.cloaked) .sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y)) .slice(0, count); @@ -2222,7 +2402,7 @@ export class CombatSystem { * data's (Fx.snag's ring + sparks + flash, the data's sfxEvent voice). */ _snagBola(b, time) { - const caught = radiusHits(this.enemies, b.x, b.y, b.capture); + const caught = radiusHits(this._playerPool(), b.x, b.y, b.capture); for (const e of caught) { e.stunUntil = Math.max(e.stunUntil || 0, time + b.stun); // THE STRUNG READ's data (the hull's dim + the discharge's color) — @@ -2448,8 +2628,9 @@ export class CombatSystem { const eff = def.effect; const speed = Math.max(1, Number(eff.speed) || 0); const count = flameTargetCount(eff, rank); - // Cloaked hostiles can't be targeted — the wave passes them by. - const nearest = this.enemies + // Cloaked hostiles can't be targeted — the wave passes them by (and a + // neutral-to-the-player faction stands — the war-zone's pool rule). + const nearest = this._playerPool() .filter((e) => !e.dead && !e._removed && !e.cloaked) .sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y)) .slice(0, count); @@ -2524,7 +2705,7 @@ export class CombatSystem { _igniteFlames(f, time) { const col = hexToInt(f.fx.flameColor, 0xff9d3a); const ember = hexToInt(f.fx.emberColor, 0xffd84d); - const caught = radiusHits(this.enemies, f.x, f.y, f.splash); + const caught = radiusHits(this._playerPool(), f.x, f.y, f.splash); for (const e of caught) { if (e.dead || e._removed) continue; e.burnUntil = Math.max(e.burnUntil || 0, time + f.burn); diff --git a/js/combat/Encounters.js b/js/combat/Encounters.js index 44fbb5a..ad9addc 100644 --- a/js/combat/Encounters.js +++ b/js/combat/Encounters.js @@ -7,28 +7,31 @@ * WHAT IT DECIDES (GameScene does the spawning): * * WHEN — while the system allows encounters (NEUTRAL — faction null —, - * or a FACTION system with a war context (the rivals' rosters — the - * roll is an opposing faction, never pirates/neutrals)), not home, - * no wave run in flight, the player upright; the scene - * passes `ctx.allowed`, `encounterAllowed` is the pure gate) AND the - * scene's own quest hold stands off — GameScene ANDs in - * `_encountersLockedByQuest`, the 'Special Delivery' gate: the rolls - * stay off until that quest is completed and claimed (a scene-side - * state, so it never crosses this module's pure contract). A roll then - * lands on the interval schedule (data/combat.json → - * encounters.rollIntervalMin..rollIntervalMax, seconds); each roll - * fires at chanceMin..chanceMax probability. ONE AT A TIME: the + * or a FACTION system with a war context (the local faction + one of + * its rivals — never pirates/neutrals)), not home, no wave run in + * flight, the player upright; the scene passes `ctx.allowed`, + * `encounterAllowed` is the pure gate) AND the scene's own quest hold + * stands off — GameScene ANDs in `_encountersLockedByQuest`, the + * 'Special Delivery' gate: the rolls stay off until that quest is + * completed and claimed (a scene-side state, so it never crosses this + * module's pure contract). A roll then lands on the interval schedule + * (data/combat.json → encounters.rollIntervalMin..rollIntervalMax, + * seconds — in FACTION space `ctx.faction === true` and the tighter + * encounters.faction.rollIntervalMin..rollIntervalMax stand), each + * roll fires at chanceMin..chanceMax probability. ONE AT A TIME: the * schedule holds while an encounter is engaged. * * QUIET — when the encounter ends (its ships all dead or off-screen → * the scene reports ctx.live = false) the next roll waits out - * cooldownMin..cooldownMax seconds, and the cadence resumes from - * there. + * cooldownMin..cooldownMax seconds (or + * encounters.faction.cooldownMin..cooldownMax in faction space), and + * the cadence resumes from there. * * WHAT — `rollKind` picks the kind at `weights` (shipped even — ~1/3 * each), and `compose` sizes the group off the player's combat * level band (shipped: 1–2 ships at low levels, 4–5 at high; mixed - * keeps BOTH kinds present). + * keeps BOTH kinds present). FACTION SPACE: both groups are sized + * off the band's pirate count, one per faction. * * IT OWNS NO SHIPS, NO AUDIO, NO SAVE STATE — scene-local, like the * 'Pirate Problems' ambush: a fresh GameScene starts a fresh director. @@ -151,35 +154,51 @@ function drawRoster(band, n, rng) { * picks as it always did). * * FACTION SPACE (`context`, new — the war-zones): `context` of - * `{ mode: 'faction', faction, enemies: [{ id, types: [...] }] }` (the - * scene builds it — this module stays roster-free): the roll is always a - * FACTION kind — one of the OTHER factions (never the controller, never - * pirates or neutrals), sized off the band's pirate count, its types - * drawn from THAT faction's roster (repeated for the count — the scene - * spawns `types[i % types.length]`). A context with no enemies (a - * roster-less world) degrades to the neutral roll (defensive). + * `{ mode: 'faction', local: { id, types: [...] }, enemies: [{ id, + * types: [...] }] }` (the scene builds it — this module stays + * roster-free): the roll is ALWAYS a FACTION kind — the LOCAL faction + * (the system's controller) AND one of the OTHER factions (never + * pirates or neutrals), EACH group sized off the band's pirate count + * (independent rolls) with its types drawn from THAT faction's roster + * (repeated for the count — the scene spawns `types[i % types.length]`). + * A context with no local roster or no rostered rival degrades to the + * neutral roll (defensive). * * @param {object} cfg the encounters section (data/combat.json) * @param {number} xpLevel the player's combat level (a fresh run: 0) * @param {() => number} rng * @param {object} [context] the space's encounter context (see above) * @returns {{kind:string, pirates:number, neutrals:number, pirateTypes?:string[], - * faction?:string, count?:number, types?:string[]}} + * faction?:string, groups?:Array<{id:string, count:number, types:string[]}>}} */ export function compose(cfg, xpLevel, rng, context = null) { + const rostered = (e) => e && Array.isArray(e.types) && e.types.length > 0; if ( context && context.mode === 'faction' && + rostered(context.local) && Array.isArray(context.enemies) && - context.enemies.some((e) => e && Array.isArray(e.types) && e.types.length > 0) + context.enemies.some(rostered) ) { - const live = context.enemies.filter((e) => e && Array.isArray(e.types) && e.types.length > 0); + const live = context.enemies.filter(rostered); const pick = live[Math.floor(rng() * live.length) % live.length]; - const band = bandFor(cfg && cfg.bands, xpLevel); - const n = band && Array.isArray(band.pirates) ? randInt(rng, band.pirates[0], band.pirates[1]) : 1; - const types = []; - for (let i = 0; i < n; i++) types.push(pick.types[i % pick.types.length]); - return { kind: 'faction', faction: String(pick.id), count: n, types, pirates: n, neutrals: 0 }; + const band = bandFor(cfg && cfg.bands, xpLevel) || DEFAULT_BAND; + const group = (roster) => { + const b = Array.isArray(band.pirates) ? band.pirates : [1, 1]; + const n = randInt(rng, b[0], b[1]); + const types = []; + for (let i = 0; i < n; i++) types.push(roster.types[i % roster.types.length]); + return { id: String(roster.id), count: n, types }; + }; + const home = group(context.local); + const rival = group(pick); + return { + kind: 'faction', + faction: home.id, + groups: [home, rival], + pirates: home.count + rival.count, + neutrals: 0, + }; } const cfg2 = cfg && typeof cfg === 'object' ? cfg : {}; const band = bandFor(cfg2.bands, xpLevel) || DEFAULT_BAND; @@ -229,6 +248,9 @@ export function encounterAllowed(gate = {}) { * ctx.allowed — the pure gate above (the scene decides) * ctx.live — an encounter's ships are still up (CombatSystem.active) * ctx.xpLevel — the player's combat level (the size bands) + * ctx.faction — true in FACTION space (the roll takes the + * encounters.faction interval + quiet; the scene passes + * its war context on ctx.context for compose) * * `time` is the scene clock (ms — Phaser's `time`, the same ms clock the * combat's own nextFireAt/respawn clocks run on). @@ -242,19 +264,24 @@ export class EncounterDirector { this.quietUntil = 0; // ms clock (the cooldown's end) } - _intervalMs() { + _intervalMs(src) { // BOTH bounds in the SAME unit (seconds) before scaling to ms: clamping // max to >= min must compare like-with-like, or the band's top collapses to // min*1000 (a 45–90 s roll becomes 45 s–12.5 h). Regression-guarded in // dev/encounters.test.mjs (rng=0 lower bound, rng=1 upper bound). - const minS = Math.max(0, Number(this.cfg.rollIntervalMin) || 45); - const maxS = Math.max(minS, Number(this.cfg.rollIntervalMax) || 90); + // `src` — the FACTION SPACE's own schedule (data/combat.json → + // encounters.faction) when the roll lands in faction territory; + // absent, the neutral-space schedule stands. + const c = src && typeof src === 'object' ? src : this.cfg; + const minS = Math.max(0, Number(c.rollIntervalMin) || 45); + const maxS = Math.max(minS, Number(c.rollIntervalMax) || 90); return rand(this.rng, minS * 1000, maxS * 1000); } - _cooldownMs() { - const minS = Math.max(0, Number(this.cfg.cooldownMin) || 30); - const maxS = Math.max(minS, Number(this.cfg.cooldownMax) || 60); + _cooldownMs(src) { + const c = src && typeof src === 'object' ? src : this.cfg; + const minS = Math.max(0, Number(c.cooldownMin) || 30); + const maxS = Math.max(minS, Number(c.cooldownMax) || 60); return rand(this.rng, minS * 1000, maxS * 1000); } @@ -264,15 +291,25 @@ export class EncounterDirector { return rand(this.rng, min, max); } - /** One tick of the encounter clock (see the class doc). */ + /** One tick of the encounter clock (see the class doc). `ctx.faction` + * true = the roll lands in FACTION space — the encounters.faction + * schedule (its own interval + quiet) stands instead of the + * neutral-space one. */ update(time, ctx = {}) { if (this.cfg.enabled === false) return null; // the data can hold it off time = Number(time) || 0; + // The schedule source (faction space's tighter timers, or the + // neutral-space one) — sampled per tick, so a scene that swaps + // systems flips the cadence with it. + const fac = + ctx.faction === true && this.cfg.faction && typeof this.cfg.faction === 'object' + ? this.cfg.faction + : null; if (this.state === 'engaged') { if (ctx.live !== true) { this.state = 'cooldown'; - this.quietUntil = time + this._cooldownMs(); + this.quietUntil = time + this._cooldownMs(fac); } return null; // one at a time: the schedule holds while it's live } @@ -285,18 +322,18 @@ export class EncounterDirector { // 'idle' if (this.nextRollAt === null) { - this.nextRollAt = time + this._intervalMs(); + this.nextRollAt = time + this._intervalMs(fac); return null; } if (ctx.allowed !== true) { - // Out of allowed space (home / a faction system / a wave run): - // the clock SLIDES — the first roll here lands a full interval out, - // never on the arrival frame. - this.nextRollAt = time + this._intervalMs(); + // Out of allowed space (home / a wave run): the clock SLIDES — the + // first roll here lands a full interval out, never on the arrival + // frame. + this.nextRollAt = time + this._intervalMs(fac); return null; } if (time < this.nextRollAt) return null; - this.nextRollAt = time + this._intervalMs(); + this.nextRollAt = time + this._intervalMs(fac); if (this.rng() < this._chance()) { this.state = 'engaged'; return compose(this.cfg, ctx.xpLevel ?? 0, this.rng, ctx.context ?? null); diff --git a/js/combat/EnemyAbilities.js b/js/combat/EnemyAbilities.js index 7a98c31..594f177 100644 --- a/js/combat/EnemyAbilities.js +++ b/js/combat/EnemyAbilities.js @@ -534,6 +534,10 @@ const HANDLERS = { try { const m = new Missile(ctx.scene, { x: mx, y: my, angle: ang, ...opts }); m.targetRef = player; // the seeker's home target (stepMissiles's rule) + // THE WAR-ZONE DISPOSITION (the shooter's stamp): a + // 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; 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 fecc9fb..a0cacf4 100644 --- a/js/combat/Weapons.js +++ b/js/combat/Weapons.js @@ -191,6 +191,11 @@ function missileTargetOf(m, player, ctx) { } return null; // straight flight, the fuse ends it — never a player threat } + // THE WAR-ZONE DISPOSITION (the spawner's stamp rides the seeker — + // launchMissiles): a neutral-to-the-player faction's seeker never + // re-homes onto the ship — its intended victim was a rival craft, and + // when that falls the rocket flies true and burns out. + if (m.hostileToPlayer === false) return null; return player; } @@ -232,6 +237,10 @@ export function launchMissiles(ctx, ship, player, wpn) { ...opts, }); m.targetRef = player; // the intended victim (the seeker's home target) + // THE WAR-ZONE DISPOSITION (the shooter's stamp — fireEnemy merges it + // 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; ctx.missiles.push(m); } combatSfx(scene, 'missile_fire'); // the rocket voice (distinct from the laser) @@ -292,6 +301,7 @@ export function missilesHitPlayer(ctx, player, playerRadius, delta) { continue; } if (m.owner === 'ally') continue; // no friendly fire: escort seekers pass by + if (m.hostileToPlayer === false) continue; // the neutral faction's seeker: no player threat if (ctx.hitsSwept(m, player, r, delta)) { // The shield is struck from the missile's incoming bearing. ctx.damagePlayer(m.damage, { x: m.x, y: m.y, bearing: m.heading + Math.PI }); diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 30e7874..0d5bdfb 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -13,7 +13,7 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js'; import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js'; import { Ship } from '../entities/Ship.js'; import { EnemyShip } from '../combat/EnemyShip.js'; -import { enemySheetList, enemyTypeIds, neutralTypeIds, factionIds, factionTypeIds, enemyTypeConfig, transportSheet } from '../combat/EnemyTypes.js'; +import { enemySheetList, enemyTypeIds, neutralTypeIds, factionIds, factionTypeIds, transportSheet } from '../combat/EnemyTypes.js'; import { Planet } from '../entities/Planet.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { Station } from '../entities/Station.js'; @@ -2897,9 +2897,11 @@ export class GameScene extends Phaser.Scene { // THE ENCOUNTER DIRECTOR (data/combat.json → encounters): a hit // returns the composition to spawn — off-screen, streamed in by // their own AI (the clock is pure; the scene stays the spawner). - // FACTION SPACE: the context (the controller's rivals, with their - // rosters) turns the roll into an opposing-faction attack — never - // pirates or neutrals — and the local faction's fighters join us. + // FACTION SPACE: the context (the local faction + its rivals, + // with their rosters) turns the roll into the local faction AND + // one opposing faction — never pirates or neutrals — and + // ctx.faction takes the encounters.faction timers (the tighter + // cadence in territory). See _spawnFactionEncounter. const fctx = this._encounterContext(); const comp = this.encounters.update(_time, { allowed: @@ -2915,6 +2917,7 @@ export class GameScene extends Phaser.Scene { playerDown: this.combat.playerDown, }) && !this._encountersLockedByQuest(), // the 'Special Delivery' hold context: fctx, + faction: fctx != null, live: this.combat.active, xpLevel: this.combat.xp.level, }); @@ -7744,33 +7747,37 @@ export class GameScene extends Phaser.Scene { */ /** * The ENCOUNTER CONTEXT for the current system (the war-zones): in a - * faction-controlled system, the two OPPOSING factions with their - * fighter rosters (the director rolls one of them — never the - * controller, never pirates/neutrals); null in neutral space (the - * classic roll stands) or where the roster is unknown (a non-roster - * faction — defensive). - * @returns {?{mode:string, faction:string, enemies:Array<{id:string, types:string[]}>}} + * faction-controlled system, the LOCAL faction (the controller) + the + * other rostered factions with their fighter rosters (the director's + * compose deploys the local faction AND one rival — never + * pirates/neutrals; who is friendly or hostile to the PLAYER is a + * separate disposition, stamped off the player's standing with each + * faction at spawn — GameScene._spawnFactionEncounter); null in + * neutral space (the classic roll stands) or where a roster is + * unknown (a non-roster faction — defensive). + * @returns {?{mode:string, faction:string, local:{id:string, types:string[]}, enemies:Array<{id:string, types:string[]}>}} */ _encounterContext() { const fid = this.systemRecord ? this.systemRecord.faction : null; if (!fid || fid === 'neutral') return null; const all = factionIds(); if (!all.includes(fid)) return null; + const local = { id: fid, types: factionTypeIds(fid) }; + if (local.types.length === 0) return null; // the controller's roster is unknown — defensive const enemies = all .filter((f) => f !== fid) .map((f) => ({ id: f, types: factionTypeIds(f) })) .filter((e) => e.types.length > 0); if (enemies.length === 0) return null; - return { mode: 'faction', faction: fid, enemies }; + return { mode: 'faction', faction: fid, local, enemies }; } _spawnEncounter(comp) { const ship = this.ship; if (!ship || !this.combat) return; - // FACTION SPACE — an opposing faction's fighters attack (hostile on - // sight), and the LOCAL faction's fighters join the player (allies: - // they fight the hostiles, never the player — the NeutralShip 'side: - // player' seam). See _spawnFactionEncounter. + // FACTION SPACE — the local faction and one opposing faction cross + // each other (who bears on the player is the disposition, stamped + // per ship there). See _spawnFactionEncounter. if (comp?.kind === 'faction') { this._spawnFactionEncounter(comp); return; } const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2); const baseA = Math.random() * Math.PI * 2; @@ -7804,60 +7811,62 @@ export class GameScene extends Phaser.Scene { } /** - * THE FACTION ENCOUNTER (the war-zones): an OPPOSING faction's fighters - * attack — hostile on sight, spawned exactly like the neutral-space - * hostiles (their types are roster-registered, spawnEnemy's job) — and - * the LOCAL faction's fighters join the player: player-side crafts (the - * NeutralShip 'side: player' seam — they engage the hostiles, never the - * player, and the player's guns never target them) on a finite escort - * window, so the allies stand for the fight and peel off after. The - * enemy's count/types come from the director's compose (seeded); the - * ally count + window are data (combat.encounters.faction, defaults in - * code). + * THE FACTION ENCOUNTER (the war-zones): the LOCAL faction's fighters + * and one OPPOSING faction's fighters cross each other — both groups + * spawn as pack hostiles (their types are roster-registered, + * spawnEnemy's job) and are ALWAYS hostile to the OTHER faction + * (CombatSystem.pickEnemyTarget). Each ship's disposition toward the + * PLAYER is separate: stamped off the player's standing WITH that + * faction (js/reputation/Reputation.js → factionStanding) against the + * data's floor (combat.encounters.faction.hostileBelow — a standing + * below it makes the faction shoot the player, and the player's + * guns/skills may target them in return; at/above it the faction only + * fights the rival and peels off when the rival is spent — + * CombatSystem._peelOff — so a fresh player, neutral with every + * faction, just flies through the fight and observes). The counts + * come from the director's compose (seeded) — both off the level + * band's pirate range. */ _spawnFactionEncounter(comp) { const ship = this.ship; if (!ship || !this.combat) return; + const groups = Array.isArray(comp?.groups) ? comp.groups : []; + if (groups.length === 0) return; const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2); const baseA = Math.random() * Math.PI * 2; - const count = Math.max(1, comp?.count ?? comp?.pirates ?? 1); - const types = Array.isArray(comp?.types) && comp.types.length > 0 ? comp.types : null; - const fan = (i, n) => baseA + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (Math.random() - 0.5) * 0.4; - // HOSTILES — the rival faction's fighters, one-shots like any encounter. - for (let i = 0; i < count; i++) { - const a = fan(i, count); - const e = this.combat.spawnEnemy( - ship.x + Math.cos(a) * r, - ship.y + Math.sin(a) * r, - this._encounterSerial++, - types ? types[i % types.length] : null, - ); - e.respawn = false; - } - // ALLIES — the local faction's fighters join the fight: the faction's - // OWN roster craft (its frame, weapon pattern, stats) with the - // player-side seam (side 'player', the rams disarmed) — spawnAlly's - // cfg override does the rest (they hold the wing for the window, - // engage any hostile up, and peel off when it lapses). - const ctx = this._encounterContext(); - const myTypes = ctx ? factionTypeIds(ctx.faction) : []; - if (myTypes.length > 0) { - const allyCount = Math.max(1, Math.round(Number(config.get('combat.encounters.faction.allyCount', 2)) || 2)); - const windowMs = Math.max(0, Number(config.get('combat.encounters.faction.allyWindowMs', 90000)) || 90000); - for (let i = 0; i < allyCount; i++) { - const a = fan(i, allyCount) + Math.PI; // opposite bearing: they arrive behind the attack - const tid = myTypes[(Math.random() * myTypes.length) | 0]; - const base = enemyTypeConfig(tid) || {}; - const allyCfg = { ...base, side: 'player', ramDamage: 0, ramDamageSelf: 0 }; - this.combat.spawnAlly( + const hostileBelow = Number(config.get('combat.encounters.faction.hostileBelow', -5)); + const standingOf = (id) => { + const s = this.reputation ? this.reputation.factionStanding(id) : null; + return typeof s === 'number' && Number.isFinite(s) ? s : 0; // no standing yet = neutral (0) + }; + groups.forEach((g, gi) => { + const types = g && Array.isArray(g.types) ? g.types : []; + if (types.length === 0) return; + const count = Math.max(1, (g.count | 0) || 1); + const hostile = standingOf(g.id) < hostileBelow; // the disposition (per faction) + // The rival group (gi > 0) streams in from the opposite bearing — + // the attack comes from behind the local wing's line. + const fan = (i, n) => baseA + (gi > 0 ? Math.PI : 0) + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (Math.random() - 0.5) * 0.4; + for (let i = 0; i < count; i++) { + const a = fan(i, count); + const e = this.combat.spawnEnemy( ship.x + Math.cos(a) * r, ship.y + Math.sin(a) * r, - 2000 + this._encounterSerial++, - null, - { escortMs: windowMs, cfg: allyCfg }, + this._encounterSerial++, + types[i % types.length], ); + e.respawn = false; // one-shots: the fight ends with the sky clear + e.factionId = String(g.id); // the war-zone's side (pickEnemyTarget's rival rule) + e.hostileToPlayer = hostile; // the player's disposition with THIS faction + if (!hostile) { + // Neutral to the player: the rams are disarmed (the collide + // pass also stands the craft off the player's hull — its + // bolts/seekers never land on the ship either). + e.ramDamage = 0; + e.ramDamageSelf = 0; + } } - } + }); } _maybeStartPirateAmbush() {