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:
parent
6ecf199da5
commit
89fac9ef78
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue