Rework faction encounters into two-faction crossfire with per-player dis

- 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.
This commit is contained in:
Brian Fertig 2026-09-30 16:28:56 -06:00
parent 6ecf199da5
commit 89fac9ef78
7 changed files with 440 additions and 151 deletions

View File

@ -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": {

View File

@ -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`);

View File

@ -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<object>} 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);

View File

@ -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);

View File

@ -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 */ }
}

View File

@ -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 });

View File

@ -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() {