Tune level-6+ encounters and bounty tiers with weighted rosters and XP s
- Combat bands split at level 6 (5–7), 9–12, and 13+ (9–12 cap) with weighted pirate rosters; compose() now draws `pirateTypes` from the band roster and GameScene uses it when spawning one-shots. - Patrol encounters gain tiered bands (levels 6+) with mixed rosters, fatter rolling waves (up to 20 deep / 6 concurrent), contact-count odds overrides, and per-tier `xpScale` dials; legacy array rosters normalize to the weighted table. - Bounty hunts replace the stubbed marksman band with tuned tiers (marksman 6–10, deadeye 11–14, apex 15+): growing pickets and escorts, escort roster mixes, elite armor/damage scales 3×–4×, and `xpScale` trims so payout pace tracks the curve. - Tests updated to assert the new band shapes, roster draws, XP scaling, and tier resolution; skills-bar screenshots refreshed.
|
|
@ -427,8 +427,11 @@
|
|||
"bands": [
|
||||
{ "minLevel": 0, "maxLevel": 2, "pirates": [1, 2], "neutrals": [1, 2], "mixedPirates": [1, 1], "mixedNeutrals": [1, 1] },
|
||||
{ "minLevel": 3, "maxLevel": 5, "pirates": [2, 3], "neutrals": [2, 3], "mixedPirates": [1, 2], "mixedNeutrals": [1, 2] },
|
||||
{ "minLevel": 6, "maxLevel": 99, "pirates": [4, 5], "neutrals": [4, 5], "mixedPirates": [2, 3], "mixedNeutrals": [1, 2] }
|
||||
]
|
||||
{ "minLevel": 6, "maxLevel": 8, "pirates": [5, 7], "neutrals": [5, 7], "mixedPirates": [3, 4], "mixedNeutrals": [2, 3], "roster": { "raider": 5, "spitter": 3, "bomber": 2 } },
|
||||
{ "minLevel": 9, "maxLevel": 12, "pirates": [7, 9], "neutrals": [7, 9], "mixedPirates": [4, 5], "mixedNeutrals": [3, 4], "roster": { "raider": 4, "spitter": 2, "stinger": 2, "gunner": 2, "bomber": 1 } },
|
||||
{ "minLevel": 13, "maxLevel": 99, "pirates": [9, 12], "neutrals": [9, 12], "mixedPirates": [4, 5], "mixedNeutrals": [4, 5], "roster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2, "barrage": 2 } }
|
||||
],
|
||||
"_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."
|
||||
},
|
||||
|
||||
"waves": {
|
||||
|
|
|
|||
|
|
@ -94,17 +94,44 @@
|
|||
},
|
||||
"bands": [
|
||||
{
|
||||
"levels": [
|
||||
1,
|
||||
5
|
||||
],
|
||||
"hops": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"enemyTypes": [
|
||||
"raider"
|
||||
]
|
||||
"_comment": "TIER 1 (levels 1–5, 1–2 hops) — the established type only. The low patrols never change: raiders, one rolling six, three at a time.",
|
||||
"levels": [1, 5],
|
||||
"hops": [1, 2],
|
||||
"enemyTypes": ["raider"]
|
||||
},
|
||||
{
|
||||
"_comment": "TIER 2 — THE LEVEL-6 JUMP: the wave grows 6→10 with four at a time, and the pack turns into a mix — raiders stay the anchor, spitters (spray) and bombers (lances) land. hops 1–99: past level 5 the LEVEL is the axis (any system the board can post).",
|
||||
"levels": [6, 8],
|
||||
"hops": [1, 99],
|
||||
"roster": { "raider": 5, "spitter": 3, "bomber": 2 },
|
||||
"wave": { "kind": "rolling", "total": 10, "maxConcurrent": 4, "replacePerKill": 1 },
|
||||
"xpScale": 0.7
|
||||
},
|
||||
{
|
||||
"_comment": "TIER 3 (9–12) — the wave grows to 14 at five; stingers (bursts) and gunners (rapid fire) join the mix.",
|
||||
"levels": [9, 12],
|
||||
"hops": [1, 99],
|
||||
"roster": { "raider": 4, "spitter": 2, "stinger": 2, "gunner": 2, "bomber": 1 },
|
||||
"wave": { "kind": "rolling", "total": 14, "maxConcurrent": 5, "replacePerKill": 1 },
|
||||
"xpScale": 0.55
|
||||
},
|
||||
{
|
||||
"_comment": "TIER 4 (13–18) — 18 at six; hunters (missiles) join. The contact-count odds skew toward three (every waypoint carrying a contact).",
|
||||
"levels": [13, 18],
|
||||
"hops": [1, 99],
|
||||
"roster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2, "bomber": 1 },
|
||||
"wave": { "kind": "rolling", "total": 18, "maxConcurrent": 6, "replacePerKill": 1 },
|
||||
"counts": [[1, 0.1], [2, 0.3], [3, 0.6]],
|
||||
"xpScale": 0.5
|
||||
},
|
||||
{
|
||||
"_comment": "TIER 5 (19+, the cap) — 20 at six, the seats' ceiling; barrages (ring bursts) round out the mix. Past here the count holds and the mix's weight is the pressure.",
|
||||
"levels": [19, 99],
|
||||
"hops": [1, 99],
|
||||
"roster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2, "barrage": 2 },
|
||||
"wave": { "kind": "rolling", "total": 20, "maxConcurrent": 6, "replacePerKill": 1 },
|
||||
"counts": [[1, 0.1], [2, 0.3], [3, 0.6]],
|
||||
"xpScale": 0.45
|
||||
}
|
||||
],
|
||||
"fallbackEnemyTypes": [
|
||||
|
|
@ -178,13 +205,27 @@
|
|||
},
|
||||
{
|
||||
"id": "marksman",
|
||||
"levels": [
|
||||
6,
|
||||
10
|
||||
],
|
||||
"inherits": "hunter",
|
||||
"stub": true,
|
||||
"_comment": "STUB — the level 6+ bounty hunt, not yet tuned. `inherits` points it at the hunter band so a player who outruns level 5 keeps getting hunts (a hunt never vanishes mid-run), and `stub` marks the debt: replace the inheritance with real numbers — heavier pickets, more legs, tougher marks, a fatter band under → rewards.bounty — when levels 6+ get their pass."
|
||||
"levels": [6, 10],
|
||||
"_comment": "TIER 2 — THE LEVEL-6 JUMP (the stub's pass, landed): the pickets grow 6→10 with four at a time and turn into a mix (spitters + bombers beside the raiders); the hunt grows 9→13 — a twelve-strong escort in the same three-at-a-time pressure with one more seat, and the mark's armor/damage scale steps 2→3. escortRoster = the hunt's escort MIX (weighted — js/quests/SideQuests.js → bountyEngagementScript draws it; a band without one is the plain raiderType count). xpScale — the tier's subtle XP dial (the fatter wave's worth, trimmed so the payout's pace stays with the curve): the tier's payout ≈ 298 XP (vs the base 372).",
|
||||
"skirmishWave": { "kind": "rolling", "total": 10, "maxConcurrent": 4, "replacePerKill": 1, "roster": { "raider": 5, "spitter": 3, "bomber": 2 } },
|
||||
"bountyWave": { "kind": "rolling", "total": 13, "raiders": 12, "maxConcurrent": 4, "replacePerKill": 1, "escortRoster": { "raider": 5, "spitter": 2, "bomber": 2 }, "elite": { "healthScale": 3, "damageScale": 3 } },
|
||||
"xpScale": 0.8
|
||||
},
|
||||
{
|
||||
"id": "deadeye",
|
||||
"levels": [11, 14],
|
||||
"_comment": "TIER 3 (11–14) — pickets 14 at five (stingers + gunners join the mix); the hunt 17 — a sixteen-strong escort, the mark at 3.5× armor and damage.",
|
||||
"skirmishWave": { "kind": "rolling", "total": 14, "maxConcurrent": 5, "replacePerKill": 1, "roster": { "raider": 4, "spitter": 2, "stinger": 2, "gunner": 2 } },
|
||||
"bountyWave": { "kind": "rolling", "total": 17, "raiders": 16, "maxConcurrent": 5, "replacePerKill": 1, "escortRoster": { "raider": 4, "spitter": 2, "stinger": 2, "gunner": 2 }, "elite": { "healthScale": 3.5, "damageScale": 3.5 } },
|
||||
"xpScale": 0.7
|
||||
},
|
||||
{
|
||||
"id": "apex",
|
||||
"levels": [15, 99],
|
||||
"_comment": "TIER 4 (15+, the cap) — pickets 18 at six (hunters join); the hunt 21 — a twenty-strong escort with barrages in the mix, the mark at 4× armor and damage. Past here the seats hold and the mix's weight + the mark's scales are the pressure.",
|
||||
"skirmishWave": { "kind": "rolling", "total": 18, "maxConcurrent": 6, "replacePerKill": 1, "roster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2 } },
|
||||
"bountyWave": { "kind": "rolling", "total": 21, "raiders": 20, "maxConcurrent": 6, "replacePerKill": 1, "escortRoster": { "raider": 3, "spitter": 2, "stinger": 2, "gunner": 2, "hunter": 2, "barrage": 2 }, "elite": { "healthScale": 4, "damageScale": 4 } },
|
||||
"xpScale": 0.65
|
||||
}
|
||||
],
|
||||
"fallbackBand": "hunter"
|
||||
|
|
|
|||
|
|
@ -93,9 +93,12 @@ section('THE CATEGORY — BOUNTY HUNT is live, level 2+, Neutral boards only');
|
|||
check("the faction wanted-list stays the documented empty stub", JSON.stringify(SQ.descriptions.faction.bounty) === '[]');
|
||||
check('the hunt carries no contact-red beacon color (patrol law, deliberately)', SQ.bounty?.marker?.contactColor === undefined);
|
||||
const bandIds = SQ.bounty.encounters.bands.map((b) => b.id);
|
||||
check('the bands stand: the live hunter + the stubbed marksman inheriting it', JSON.stringify(bandIds) === JSON.stringify(['hunter', 'marksman']) && SQ.bounty.encounters.fallbackBand === 'hunter');
|
||||
check('the bands stand: hunter (2–5) + the tuned marksman (6–10), deadeye (11–14), apex (15+)', JSON.stringify(bandIds) === JSON.stringify(['hunter', 'marksman', 'deadeye', 'apex']) && SQ.bounty.encounters.fallbackBand === 'hunter');
|
||||
const mm = S.bountyRules(9);
|
||||
check('level 6+ lands on the stub band and inherits the hunter numbers (no content cliff)', mm.id === 'marksman' && mm.bountyChance === 0.2 && mm.bountyWave.total === 9, JSON.stringify(mm).slice(0, 160));
|
||||
check('level 9 lands on marksman with REAL numbers (the stub’s pass landed): pickets 6→10, the hunt 9→13, the mark at 3×, a mixed roster', mm.id === 'marksman' && mm.bountyWave.total === 13 && mm.skirmishWave.total === 10 && mm.bountyWave.elite.healthScale === 3 && (mm.skirmishWave.roster?.bomber ?? 0) > 0, JSON.stringify(mm).slice(0, 200));
|
||||
check('the tiers carry their XP SCALE (the subtle dial; the hunter band pays full worth)', S.bountyRules(9).xpScale === 0.8 && S.bountyRules(11).xpScale === 0.7 && S.bountyRules(20).xpScale === 0.65 && S.bountyRules(3).xpScale === 1, [S.bountyRules(9).xpScale, S.bountyRules(11).xpScale, S.bountyRules(20).xpScale, S.bountyRules(3).xpScale].join(','));
|
||||
const ap = S.bountyRules(20);
|
||||
check('level 20+ lands on apex: pickets 18 at six, a twenty-strong escort, the mark at 4×, barrages in the escort mix', ap.id === 'apex' && ap.skirmishWave.total === 18 && ap.bountyWave.total === 21 && ap.bountyWave.maxConcurrent === 6 && ap.bountyWave.elite.healthScale === 4 && (ap.bountyWave.escortRoster?.barrage ?? 0) > 0, JSON.stringify(ap).slice(0, 200));
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
|
@ -280,6 +283,14 @@ section('THE PAYOUT — bountyHuntXp is the worth of the whole hunt');
|
|||
const spread = eliteTypes.map((f) => S.bountyHuntXp({ ...plan, eliteType: f }));
|
||||
check('a beefier frame is worth more than a cheap one', Math.max(...spread) > Math.min(...spread), spread.join(','));
|
||||
check('the payout is finite and positive', spread.every((v) => Number.isFinite(v) && v > 0));
|
||||
// The tier's XP SCALE (the subtle dial) — the tier's fatter worth, trimmed
|
||||
// by its band's xpScale (a plan without one — the hunter band — pays full).
|
||||
const planT2 = S.deriveBountyPlan(12, 'sq-xp-t2', 'sys-free', 9, { craft: 'gunner' });
|
||||
check('a tier plan carries its band’s xpScale (< 1)', planT2.xpScale === 0.8, `scale=${planT2.xpScale}`);
|
||||
const base = S.bountyHuntXp({ ...planT2, xpScale: 1 });
|
||||
const scaled = S.bountyHuntXp(planT2);
|
||||
check('the tier’s payout is the trimmed worth (round(base × scale), below the full)', scaled === Math.round(base * planT2.xpScale) && scaled < base, `${scaled} vs ${base}`);
|
||||
check('the hunter band’s payout is unscaled (a plan without a scale pays its own full worth)', S.bountyHuntXp(plan) === S.bountyHuntXp({ ...plan, xpScale: 1 }) && plan.xpScale === 1);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
|
|
|||
|
|
@ -92,23 +92,35 @@ const fixed = {
|
|||
check('data: the low band is 1–2 ships', lo.pirates[0] === 1 && lo.pirates[1] === 2 && lo.neutrals[0] === 1 && lo.neutrals[1] === 2);
|
||||
const hi = bandFor(e.bands, 80);
|
||||
check(
|
||||
'data: the high band scales up to 4–5 ships',
|
||||
hi.pirates[0] === 4 && hi.pirates[1] === 5 && hi.neutrals[0] === 4 && hi.neutrals[1] === 5,
|
||||
'data: the top band caps at 9–12 ships',
|
||||
hi.pirates[0] === 9 && hi.pirates[1] === 12 && hi.neutrals[0] === 9 && hi.neutrals[1] === 12,
|
||||
);
|
||||
const jump = bandFor(e.bands, 6);
|
||||
check(
|
||||
'data: the level-6 JUMP band steps 2–3 → 5–7 (a ~2× pack) and carries a weighted roster',
|
||||
jump.pirates[0] === 5 && jump.pirates[1] === 7 && jump.neutrals[0] === 5 && jump.neutrals[1] === 7 &&
|
||||
typeof jump.roster === 'object' && jump.roster.raider > 0,
|
||||
);
|
||||
check(
|
||||
'data: the higher tiers add types (stinger/gunner at 9+, hunter/barrage at 13+)',
|
||||
bandFor(e.bands, 9).roster.stinger > 0 && bandFor(e.bands, 13).roster.hunter > 0 && bandFor(e.bands, 13).roster.barrage > 0 &&
|
||||
bandFor(e.bands, 3).roster === undefined,
|
||||
);
|
||||
check(
|
||||
'data: mixed keeps BOTH kinds present (every band)',
|
||||
e.bands.every((b) => b.mixedPirates[0] >= 1 && b.mixedNeutrals[0] >= 1),
|
||||
);
|
||||
check(
|
||||
'data: the mixed high end is 2–3 pirates + 1–2 neutrals (the objective\'s example)',
|
||||
hi.mixedPirates[0] === 2 && hi.mixedPirates[1] === 3 && hi.mixedNeutrals[0] === 1 && hi.mixedNeutrals[1] === 2,
|
||||
'data: the mixed top end is 4–5 pirates + 4–5 neutrals (the cap band)',
|
||||
hi.mixedPirates[0] === 4 && hi.mixedPirates[1] === 5 && hi.mixedNeutrals[0] === 4 && hi.mixedNeutrals[1] === 5,
|
||||
);
|
||||
check(
|
||||
'bands: 0–2 → low, 3–5 → mid, 6+ → high (and a level past the top stays on it)',
|
||||
'bands: 0–2 → low, 3–5 → mid, 6–8 → jump, 9–12, 13+ (and a level past the top stays on it)',
|
||||
bandFor(e.bands, 0) === e.bands[0] &&
|
||||
bandFor(e.bands, 5) === e.bands[1] &&
|
||||
bandFor(e.bands, 6) === e.bands[2] &&
|
||||
bandFor(e.bands, 120) === e.bands[2],
|
||||
bandFor(e.bands, 10) === e.bands[3] &&
|
||||
bandFor(e.bands, 120) === e.bands[4],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -258,23 +270,34 @@ const fixed = {
|
|||
const r1 = () => 0.999;
|
||||
check('size: level 1 — 1–2 ships (the low band)', count('pirates', 1, r0) === 1 && count('pirates', 1, r1) === 2);
|
||||
check('size: level 4 (the mid band) — 2–3 ships', count('neutrals', 4, r0) === 2 && count('neutrals', 4, r1) === 3);
|
||||
check('size: level 8 (the high band) — 4–5 ships', count('pirates', 8, r0) === 4 && count('pirates', 8, r1) === 5);
|
||||
check('size: the group GROWS with the level (high min > low max)', count('pirates', 8, r0) > count('pirates', 1, r1));
|
||||
check('size: level 8 (the jump band) — 5–7 ships', count('pirates', 8, r0) === 5 && count('pirates', 8, r1) === 7);
|
||||
check('size: the group JUMPS at level 6 (jump min 5 > mid max 3)', count('pirates', 6, r0) === 5 && count('pirates', 6, r1) === 7);
|
||||
check('size: the group GROWS with the level (top min > low max)', count('pirates', 13, r0) === 9 && count('pirates', 13, r1) === 12 && count('pirates', 13, r0) > count('pirates', 1, r1));
|
||||
check('size: the cap holds past level 13 (9–12 at any higher level)', count('pirates', 40, r0) === 9 && count('pirates', 40, r1) === 12);
|
||||
const mixLo = compose(only('mixed'), 1, () => 0.5);
|
||||
check('size: mixed keeps BOTH kinds present (the low end)', mixLo.pirates >= 1 && mixLo.neutrals >= 1);
|
||||
check(
|
||||
'size: mixed at the high end — 2–3 pirates + 1–2 neutrals',
|
||||
compose(only('mixed'), 8, r0).pirates === 2 &&
|
||||
compose(only('mixed'), 8, r0).neutrals === 1 &&
|
||||
compose(only('mixed'), 8, r1).pirates === 3 &&
|
||||
compose(only('mixed'), 8, r1).neutrals === 2,
|
||||
'size: mixed at the jump band — 3–4 pirates + 2–3 neutrals',
|
||||
compose(only('mixed'), 8, r0).pirates === 3 &&
|
||||
compose(only('mixed'), 8, r0).neutrals === 2 &&
|
||||
compose(only('mixed'), 8, r1).pirates === 4 &&
|
||||
compose(only('mixed'), 8, r1).neutrals === 3,
|
||||
);
|
||||
check('size: a single kind leaves the OTHER at zero', compose(only('pirates'), 8, r0).neutrals === 0 && compose(only('neutrals'), 8, r0).pirates === 0);
|
||||
// The band's ROSTER — the weighted mix drawn out to the pirate count (the
|
||||
// types are the mix, not one type; a band without a roster leaves the
|
||||
// scene's random pick standing — pirateTypes unset).
|
||||
const c8 = compose(only('pirates'), 8, () => 0);
|
||||
check('roster: the band’s mix draws out to the pirate count', !!c8.pirateTypes && c8.pirateTypes.length === c8.pirates, JSON.stringify(c8));
|
||||
check('roster: the draw sits inside the band’s types (no foreign ids)', c8.pirateTypes.every((t) => t in e.bands[2].roster));
|
||||
check('roster: a band WITHOUT a roster leaves pirateTypes unset (the scene picks as before)', compose(only('pirates'), 1, () => 0).pirateTypes === undefined);
|
||||
const cHi = compose(only('pirates'), 13, () => 0.999);
|
||||
check('roster: the top tier’s heavier types are reachable (barrage in the mix)', cHi.pirateTypes.includes('barrage') || cHi.pirateTypes.includes('hunter'), JSON.stringify(cHi.pirateTypes));
|
||||
// The director composes at the caller's LEVEL (xpLevel rides the ctx).
|
||||
const d = new EncounterDirector(fixed, { rng: () => 0 });
|
||||
d.update(0, { allowed: true, live: false, xpLevel: 8 });
|
||||
const s = d.update(50000, { allowed: true, live: false, xpLevel: 8 });
|
||||
check('size (director): the level-8 hit deploys the high band (4 pirates)', !!s && s.kind === 'pirates' && s.pirates === 4 && s.neutrals === 0);
|
||||
check('size (director): the level-8 hit deploys the jump band (5 pirates)', !!s && s.kind === 'pirates' && s.pirates === 5 && s.neutrals === 0);
|
||||
// The degenerate-band fallback (a config without bands still composes).
|
||||
const bare = compose({ weights: { pirates: 1, neutrals: 0, mixed: 0 } }, 1, r0);
|
||||
check('size: a config without bands falls back to 1 ship', bare.kind === 'pirates' && bare.pirates === 1);
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 429 KiB After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 579 KiB |
|
Before Width: | Height: | Size: 467 KiB After Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 459 KiB After Width: | Height: | Size: 574 KiB |
|
|
@ -282,8 +282,8 @@
|
|||
"dashing": false,
|
||||
"down": false,
|
||||
"ship": {
|
||||
"x": -4855.862479765997,
|
||||
"y": 613.7055489697374
|
||||
"x": -537.9620367147268,
|
||||
"y": -698.4573644458305
|
||||
},
|
||||
"ledgerSeats": [
|
||||
"ramming_charge",
|
||||
|
|
@ -329,8 +329,8 @@
|
|||
"maxPoints": 5,
|
||||
"energy": 60,
|
||||
"cooldown": 6,
|
||||
"cooldownLeft": 5.68,
|
||||
"cooldownFrac": 0.053333333333333344,
|
||||
"cooldownLeft": 5.906679999999998,
|
||||
"cooldownFrac": 0.015553333333333641,
|
||||
"affordable": false,
|
||||
"ready": false,
|
||||
"inert": false
|
||||
|
|
@ -526,16 +526,16 @@
|
|||
"secs": ""
|
||||
}
|
||||
],
|
||||
"energy": 41.92,
|
||||
"energy": 40.559920000000005,
|
||||
"maxEnergy": 100,
|
||||
"hull": 6000,
|
||||
"maxHull": 6000,
|
||||
"level": 0,
|
||||
"dashing": false,
|
||||
"dashing": true,
|
||||
"down": false,
|
||||
"ship": {
|
||||
"x": -4855.902379850913,
|
||||
"y": 613.3899237503013
|
||||
"x": -667.245123811374,
|
||||
"y": -602.0867092723174
|
||||
},
|
||||
"ledgerSeats": [
|
||||
"ramming_charge",
|
||||
|
|
@ -778,16 +778,16 @@
|
|||
"secs": ""
|
||||
}
|
||||
],
|
||||
"energy": 59.384,
|
||||
"energy": 59.13998,
|
||||
"maxEnergy": 100,
|
||||
"hull": 129,
|
||||
"maxHull": 129,
|
||||
"level": 1,
|
||||
"hull": 5990,
|
||||
"maxHull": 6000,
|
||||
"level": 0,
|
||||
"dashing": false,
|
||||
"down": false,
|
||||
"ship": {
|
||||
"x": -4855.902379850913,
|
||||
"y": 613.3899237503013
|
||||
"x": -864.2761937160317,
|
||||
"y": -466.8622759066493
|
||||
},
|
||||
"ledgerSeats": [
|
||||
"ramming_charge",
|
||||
|
|
@ -1030,16 +1030,16 @@
|
|||
"secs": ""
|
||||
}
|
||||
],
|
||||
"energy": 68.21600000000001,
|
||||
"energy": 62.07956000000003,
|
||||
"maxEnergy": 100,
|
||||
"hull": 104.584,
|
||||
"hull": 129,
|
||||
"maxHull": 129,
|
||||
"level": 1,
|
||||
"dashing": false,
|
||||
"down": false,
|
||||
"ship": {
|
||||
"x": -4855.902379850913,
|
||||
"y": 613.3899237503013
|
||||
"x": -864.2761937160317,
|
||||
"y": -466.8622759066493
|
||||
},
|
||||
"ledgerSeats": [
|
||||
null,
|
||||
|
|
@ -1282,16 +1282,16 @@
|
|||
"secs": ""
|
||||
}
|
||||
],
|
||||
"energy": 71.28800000000001,
|
||||
"energy": 63.05942000000004,
|
||||
"maxEnergy": 100,
|
||||
"hull": 94.584,
|
||||
"hull": 129,
|
||||
"maxHull": 129,
|
||||
"level": 1,
|
||||
"dashing": false,
|
||||
"down": false,
|
||||
"ship": {
|
||||
"x": -4855.902379850913,
|
||||
"y": 613.3899237503013
|
||||
"x": -864.2761937160317,
|
||||
"y": -466.8622759066493
|
||||
},
|
||||
"ledgerSeats": [
|
||||
null,
|
||||
|
|
@ -1534,16 +1534,16 @@
|
|||
"secs": ""
|
||||
}
|
||||
],
|
||||
"energy": 73.97600000000001,
|
||||
"energy": 63.899300000000046,
|
||||
"maxEnergy": 100,
|
||||
"hull": 94.584,
|
||||
"hull": 129,
|
||||
"maxHull": 129,
|
||||
"level": 1,
|
||||
"dashing": false,
|
||||
"down": false,
|
||||
"ship": {
|
||||
"x": -4855.902379850913,
|
||||
"y": 613.3899237503013
|
||||
"x": -864.2761937160317,
|
||||
"y": -466.8622759066493
|
||||
},
|
||||
"ledgerSeats": [
|
||||
"ramming_charge",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 521 KiB After Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 187 KiB |
|
|
@ -427,7 +427,7 @@ console.log('== the patrol’s ROUTE — buildPatrolRoute (the deterministic way
|
|||
// ===========================================================================
|
||||
console.log('== the patrol’s ENCOUNTERS — derivePatrolEncounters (the contacts’ spec) ==');
|
||||
{
|
||||
const { patrolEncounterCounts, patrolEncounterWave, patrolEnemyRoster, patrolEncounterPositions, derivePatrolEncounters, patrolEncounterScript, patrolRouteComplete } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
|
||||
const { patrolEncounterCounts, patrolEncounterWave, patrolEnemyRoster, patrolEncounterPositions, derivePatrolEncounters, patrolEncounterScript, patrolRouteComplete, patrolXpScale } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
|
||||
const { normalizeWaves } = await import(pathToFileURL(join(__dirname, '../js/combat/Waves.js')).href);
|
||||
|
||||
// --- the COUNT’S ODDS (the data’s 1×20% / 2×20% / 3×60%) -----------
|
||||
|
|
@ -467,18 +467,42 @@ console.log('== the patrol’s ENCOUNTERS — derivePatrolEncounters (the contac
|
|||
|
||||
// --- THE SPEC’S SHAPE (roster + wave) --------------------------------
|
||||
assert.equal(a.cleared.length, 0, 'fresh: no contact cleared');
|
||||
assert.deepEqual(a.roster, ['raider'], 'the LOW band’s roster (levels 1–5 × 1–2 hops): the ESTABLISHED type only (raider)');
|
||||
assert.deepEqual(a.roster, { raider: 1 }, 'the LOW band’s roster (levels 1–5 × 1–2 hops): the ESTABLISHED type only (raider)');
|
||||
assert.deepEqual(a.wave, { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 }, 'the contact’s wave: ONE rolling wave of six, three at a time, each kill replaced');
|
||||
assert.ok(a.at.length >= 1 && a.at.length <= 3, 'the count: 1–3 contacts');
|
||||
assert.equal(new Set(a.at).size, a.at.length, 'the contacts’ waypoints are DISTINCT');
|
||||
for (const i of a.at) assert.ok(Number.isInteger(i) && i >= 0 && i < wps5.length, 'each contact stands on a route waypoint (in range)');
|
||||
assert.equal(a.xpScale, 1, 'tier 1 (level 3): the low band carries no scale — the payout pays FULL worth');
|
||||
|
||||
// --- THE BAND’S ROSTER (level × hops from home) ----------------------
|
||||
assert.deepEqual(patrolEnemyRoster(1, 1), ['raider'], 'band: level 1 at 1 hop → the established type');
|
||||
assert.deepEqual(patrolEnemyRoster(5, 2), ['raider'], 'band: level 5 at 2 hops → the established type');
|
||||
assert.deepEqual(patrolEnemyRoster(3, 0), ['raider'], 'band: level 3 at home (0 hops — out of the band’s 1–2) → the fallback (the established type)');
|
||||
assert.deepEqual(patrolEnemyRoster(6, 1), ['raider'], 'band: level 6 (above the low band’s 1–5) → the fallback (the established type, until the higher band’s types land)');
|
||||
assert.deepEqual(patrolEnemyRoster(0, 1), ['raider'], 'band: level 0 (below the low band’s 1) → the fallback');
|
||||
// --- THE BAND’S ROSTER + WAVE TIERS (level × hops from home) ---------
|
||||
assert.deepEqual(patrolEnemyRoster(1, 1), { raider: 1 }, 'tier 1: level 1 at 1 hop → the established type');
|
||||
assert.deepEqual(patrolEnemyRoster(5, 2), { raider: 1 }, 'tier 1: level 5 at 2 hops → the established type');
|
||||
assert.deepEqual(patrolEnemyRoster(3, 0), { raider: 1 }, 'tier 1: level 3 at home (0 hops — out of the band’s 1–2) → the fallback (the established type)');
|
||||
assert.deepEqual(patrolEnemyRoster(0, 1), { raider: 1 }, 'band: level 0 (below tier 1’s 1) → the fallback');
|
||||
// THE LEVEL-6 JUMP — a mixed roster + a fatter wave (4 at a time, ten deep).
|
||||
assert.deepEqual(patrolEnemyRoster(6, 1), { raider: 5, spitter: 3, bomber: 2 }, 'tier 2 (6–8): the jump mix (raider anchor + spitter + bomber)');
|
||||
assert.deepEqual(patrolEncounterWave(6, 1), { kind: 'rolling', total: 10, maxConcurrent: 4, replacePerKill: 1 }, 'tier 2: the wave grows 6→10 with four at a time');
|
||||
assert.ok('stinger' in patrolEnemyRoster(9, 3), 'tier 3 (9–12) brings in the stinger');
|
||||
assert.deepEqual(patrolEnemyRoster(9, 3), { raider: 4, spitter: 2, stinger: 2, gunner: 2, bomber: 1 }, 'tier 3: the mix widens (stinger + gunner join)');
|
||||
assert.deepEqual(patrolEncounterWave(9, 3), { kind: 'rolling', total: 14, maxConcurrent: 5, replacePerKill: 1 }, 'tier 3: 14 at five');
|
||||
assert.deepEqual(patrolEnemyRoster(13, 2), { raider: 3, spitter: 2, stinger: 2, gunner: 2, hunter: 2, bomber: 1 }, 'tier 4 (13–18): the hunter (missiles) joins');
|
||||
assert.deepEqual(patrolEncounterWave(13, 2), { kind: 'rolling', total: 18, maxConcurrent: 6, replacePerKill: 1 }, 'tier 4: 18 at six');
|
||||
assert.deepEqual(patrolEnemyRoster(19, 4), { raider: 3, spitter: 2, stinger: 2, gunner: 2, hunter: 2, barrage: 2 }, 'tier 5 (19+, the cap): the barrage rounds the mix out');
|
||||
assert.deepEqual(patrolEncounterWave(40, 4), { kind: 'rolling', total: 20, maxConcurrent: 6, replacePerKill: 1 }, 'tier 5: the seats + total CAP at 20 / six (past here the mix is the pressure)');
|
||||
assert.ok(Object.keys(patrolEnemyRoster(99, 1)).length === 6, 'the top tier’s mix holds its six types at any level past it');
|
||||
// The tier’s counts override (the higher tiers skew toward three contacts).
|
||||
assert.deepEqual(patrolEncounterCounts(5, 1), [[1, 0.2], [2, 0.2], [3, 0.6]], 'tier 1: the shared odds stand (20 / 20 / 60)');
|
||||
assert.deepEqual(patrolEncounterCounts(14, 2), [[1, 0.1], [2, 0.3], [3, 0.6]], 'tier 4: the band’s own odds (10 / 30 / 60)');
|
||||
assert.deepEqual(patrolEncounterCounts(6, 1), [[1, 0.2], [2, 0.2], [3, 0.6]], 'tier 2: no band odds → the shared table');
|
||||
// The tier’s XP SCALE (the subtle dial — the fatter wave’s worth, trimmed
|
||||
// so the payout’s pace stays with the curve; the low band pays full).
|
||||
assert.equal(patrolXpScale(3, 1), 1, 'tier 1: full worth (no scale)');
|
||||
assert.equal(patrolXpScale(6, 1), 0.7, 'tier 2 (the jump): 0.7');
|
||||
assert.equal(patrolXpScale(9, 3), 0.55, 'tier 3: 0.55');
|
||||
assert.equal(patrolXpScale(14, 2), 0.5, 'tier 4: 0.5');
|
||||
assert.equal(patrolXpScale(20, 4), 0.45, 'tier 5 (the cap): 0.45');
|
||||
const a6 = derivePatrolEncounters(SEED, 'sq-x-patrol-6', 'sys-x', wps5, 6, 1);
|
||||
assert.equal(a6.xpScale, 0.7, 'a level-6 spec fixes the tier’s scale on the route (1, never re-drawn)');
|
||||
|
||||
// --- THE POSITIONING (the contacts spread across the route) ---------
|
||||
const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]];
|
||||
|
|
@ -548,6 +572,8 @@ console.log('== the XP REWARD — patrolEncounterXp (the claim’s second payout
|
|||
assert.equal(patrolEncounterXp(enc([1], { roster: ['raider', 'bomber'] })), Math.round(6 * (XP.raider + XP.bomber) / 2), `a mixed roster (equal weights) → 6 × (${XP.raider} + ${XP.bomber}) / 2`);
|
||||
assert.equal(patrolEncounterXp(enc([1], { wave: { kind: 'rolling', total: 4, maxConcurrent: 2, replacePerKill: 1 } })), 4 * XP.raider, `a smaller wave total rules: 4 × ${XP.raider}`);
|
||||
assert.equal(patrolEncounterXp(enc([1], { roster: ['not-a-type'] })), 6 * XP.raider, 'an unknown roster type falls back to the established type’s wave (the script’s rule)');
|
||||
assert.equal(patrolEncounterXp(enc([1], { xpScale: 0.5 })), Math.round(6 * XP.raider * 0.5), 'a spec’s xpScale trims the payout (round(worth × scale))');
|
||||
assert.equal(patrolEncounterXp(enc([1], { xpScale: 0 })), 0, 'a zero scale pays nothing (the dial’s floor)');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -621,12 +647,13 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl
|
|||
// marks them idempotently (the wave-run completion’s witness).
|
||||
const rte = new SideQuestState();
|
||||
rte.accept('sq-e-patrol-0');
|
||||
const spec = { at: [1, 2, 9, 1], cleared: [1, 7, 1], roster: ['raider'], wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 } };
|
||||
const spec = { at: [1, 2, 9, 1], cleared: [1, 7, 1], roster: ['raider'], wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 }, xpScale: 0.7 };
|
||||
rte.setRoute('sq-e-patrol-0', { targetSystemId: 'sys-e', credits: 120, waypoints: [[1, 1], [2, 2], [3, 3], [4, 4]], visited: [], encounters: spec });
|
||||
const enc = rte.routeFor('sq-e-patrol-0').encounters;
|
||||
assert.deepEqual(enc.at, [1, 2], 'the encounters’ waypoints are deduped + sorted + range-checked');
|
||||
assert.deepEqual(enc.cleared, [1], 'the cleared marks stay inside the route’s contacts (the out-of-range one drops)');
|
||||
assert.deepEqual(enc.roster, ['raider'], 'the band’s roster is fixed on the route (the established type)');
|
||||
assert.deepEqual(enc.roster, { raider: 1 }, 'the legacy array roster (old saves) normalizes to the weighted table (the established type)');
|
||||
assert.equal(enc.xpScale, 0.7, 'the spec’s xpScale is fixed on the route (the payout’s dial)');
|
||||
assert.equal(enc.wave.total, 6, 'the wave’s shape is fixed on the route (six hostiles)');
|
||||
assert.equal(rte.encounterCleared('sq-e-patrol-0', 1), false, 'a cleared contact can’t be re-cleared (the witness is idempotent)');
|
||||
assert.equal(rte.encounterCleared('sq-e-patrol-0', 0), false, 'a waypoint OUTSIDE the contacts’ set doesn’t clear');
|
||||
|
|
|
|||
|
|
@ -92,15 +92,65 @@ const DEFAULT_BAND = {
|
|||
mixedNeutrals: [1, 1],
|
||||
};
|
||||
|
||||
/**
|
||||
* A band's pirate ROSTER as type → weight entries: a `{ type: weight }`
|
||||
* object (the tiers' weighted mixes) or an array of type ids (equal
|
||||
* weight). Unknown ids are left to the spawn side (a roster typo degrades
|
||||
* to the base craft there — the EnemyTypes.js rule); a roster that
|
||||
* empties yields null (the scene then picks its own way).
|
||||
* @returns {?Array<[string, number]>} | null
|
||||
*/
|
||||
function rosterEntries(band) {
|
||||
const raw = band?.roster;
|
||||
const out = [];
|
||||
if (Array.isArray(raw)) {
|
||||
for (const t of raw) {
|
||||
const k = String(t).trim();
|
||||
if (k !== '') out.push([k, 1]);
|
||||
}
|
||||
} else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
for (const [t, w] of Object.entries(raw)) {
|
||||
const k = String(t).trim();
|
||||
const wt = Number(w);
|
||||
if (k !== '' && Number.isFinite(wt) && wt > 0) out.push([k, wt]);
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/** Draw `n` type ids from the band's roster (weighted, with replacement). Null when the band carries none. */
|
||||
function drawRoster(band, n, rng) {
|
||||
const entries = rosterEntries(band);
|
||||
if (!entries) return null;
|
||||
const sum = entries.reduce((a, [, w]) => a + w, 0);
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
let x = rng() * sum;
|
||||
let pick = entries[0][0];
|
||||
for (const [t, w] of entries) {
|
||||
x -= w;
|
||||
if (x <= 0) {
|
||||
pick = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(pick);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The encounter's composition — the kind at `weights`, the group sized
|
||||
* off the level band. `pirates`/`neutrals` are the per-kind counts
|
||||
* (the OTHER kind is 0); `mixedPirates`/`mixedNeutrals` are the mixed
|
||||
* per-kind floors/ceilings — BOTH kinds stay present.
|
||||
* per-kind floors/ceilings — BOTH kinds stay present. `pirateTypes` (new)
|
||||
* — the band's weighted ROSTER drawn out to the pirate count (the tiers
|
||||
* mix their types — a band without a roster leaves it unset and the scene
|
||||
* picks as it always did).
|
||||
* @param {object} cfg the encounters section (data/combat.json)
|
||||
* @param {number} xpLevel the player's combat level (a fresh run: 0)
|
||||
* @param {() => number} rng
|
||||
* @returns {{kind:string, pirates:number, neutrals:number}}
|
||||
* @returns {{kind:string, pirates:number, neutrals:number, pirateTypes?:string[]}}
|
||||
*/
|
||||
export function compose(cfg, xpLevel, rng) {
|
||||
cfg = cfg && typeof cfg === 'object' ? cfg : {};
|
||||
|
|
@ -108,9 +158,17 @@ export function compose(cfg, xpLevel, rng) {
|
|||
const ri = (key, fb) =>
|
||||
Array.isArray(band[key]) ? randInt(rng, band[key][0], band[key][1]) : randInt(rng, fb[0], fb[1]);
|
||||
const kind = rollKind(cfg.weights, rng);
|
||||
if (kind === 'pirates') return { kind, pirates: ri('pirates', [1, 2]), neutrals: 0 };
|
||||
const types = (n) => drawRoster(band, n, rng);
|
||||
if (kind === 'pirates') {
|
||||
const n = ri('pirates', [1, 2]);
|
||||
const pt = types(n);
|
||||
return { kind, pirates: n, neutrals: 0, ...(pt ? { pirateTypes: pt } : {}) };
|
||||
}
|
||||
if (kind === 'neutrals') return { kind, pirates: 0, neutrals: ri('neutrals', [1, 2]) };
|
||||
return { kind: 'mixed', pirates: ri('mixedPirates', [1, 1]), neutrals: ri('mixedNeutrals', [1, 1]) };
|
||||
const np = ri('mixedPirates', [1, 1]);
|
||||
const nt = ri('mixedNeutrals', [1, 1]);
|
||||
const pt = types(np);
|
||||
return { kind: 'mixed', pirates: np, neutrals: nt, ...(pt ? { pirateTypes: pt } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -54,9 +54,12 @@
|
|||
* of patrols, 2 on 20%, 3 on 60%), WHERE they stand (patrolEncounterPositions — the max-spread subset of the route's waypoints — the
|
||||
* contacts spread across the route, never clustered), WHAT they fly
|
||||
* (the band's roster — the player's LEVEL × the target's HOPS from
|
||||
* home; today the low band, levels 1–5 at 1–2 hops, the established
|
||||
* type only), and the fight's shape (the wave — one rolling wave of
|
||||
* six, three at a time). derivePatrolEncounters rolls it all seeded
|
||||
* home; the tiers: the low band (levels 1–5, 1–2 hops) the established
|
||||
* type only, the level-6 jump a weighted mix (spitter + bomber beside
|
||||
* the raiders) in a fatter wave, the higher tiers wider mixes — see
|
||||
* data/sidequests.json → patrol.encounters.bands), and the fight's shape
|
||||
* (the wave — the shared rolling shape, a band's `wave` overriding it —
|
||||
* the tiers grow it, capped at 20 deep / six at a time). derivePatrolEncounters rolls it all seeded
|
||||
* at the accept (the labelled fork (seed, 'sidequests',
|
||||
* <targetSystemId>, 'patrol', <jobId>, 'encounters')) and fixes it
|
||||
* on the route record — a signed contract is a signed contract;
|
||||
|
|
@ -490,13 +493,74 @@ function numOr(v, fb) {
|
|||
}
|
||||
|
||||
/**
|
||||
* The contact COUNT's odds (data/sidequests.json → patrol.encounters.counts
|
||||
* — [count, odds] pairs; the design: 1 contact on 20% of patrols, 2 on
|
||||
* 20%, 3 on 60%). A malformed table falls back to the design's own.
|
||||
* @returns {Array<[number, number]>} sorted by count, positive weights
|
||||
* A wave's ROSTER as a type → weight table (Waves.js's composition
|
||||
* contract — {raider: 2, bomber: 1} + total 6 is a 2:1 mix of six
|
||||
* hostiles). From a data shape: a weighted `{ type: weight }` object, an
|
||||
* array of type ids (equal weight — the legacy bands' `enemyTypes` shape),
|
||||
* or null. Ids are validated against combat.enemyTypes (js/combat/
|
||||
* EnemyTypes.js) — an unknown id (a data typo) never ships; a roster that
|
||||
* empties yields `fb`.
|
||||
* @param {object|string[]|string|null} raw the data's roster shape
|
||||
* @param {?object} fb the fallback table (null = "nothing stands")
|
||||
* @returns {?Record<string, number>}
|
||||
*/
|
||||
export function patrolEncounterCounts() {
|
||||
const raw = config.get('sidequests.patrol.encounters.counts', null);
|
||||
export function rosterWeights(raw, fb = null) {
|
||||
const valid = new Set(enemyTypeIds());
|
||||
const out = {};
|
||||
if (Array.isArray(raw)) {
|
||||
for (const t of raw) {
|
||||
const k = String(t).trim();
|
||||
if (k !== '' && valid.has(k)) out[k] = (out[k] ?? 0) + 1;
|
||||
}
|
||||
} else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
for (const [t, w] of Object.entries(raw)) {
|
||||
const k = String(t).trim();
|
||||
const wt = Math.max(0, Number(w) || 0);
|
||||
if (k !== '' && valid.has(k) && wt > 0) out[k] = (out[k] ?? 0) + wt;
|
||||
}
|
||||
}
|
||||
if (Object.keys(out).length > 0) return out;
|
||||
return fb;
|
||||
}
|
||||
|
||||
/** The established type's weighted table (every fallback's floor). */
|
||||
function establishedRoster() {
|
||||
return rosterWeights(ESTABLISHED_ENEMY_TYPES, { [ESTABLISHED_ENEMY_TYPES[0]]: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* The standing PATROL band (data/sidequests.json → patrol.encounters
|
||||
* .bands): the FIRST band in list order whose `levels` AND `hops` windows
|
||||
* both contain the player's state (the difficulty's axis — the player's
|
||||
* COMBAT level × the target system's HOPS from home). A band may carry its
|
||||
* own `roster` (weighted type mix), `wave` (the contact's fight shape)
|
||||
* and `counts` (the contact-count odds) — each overriding the shared
|
||||
* encounter block. Null when no band stands (the shared defaults + the
|
||||
* fallback roster then rule).
|
||||
* @param {number} level the player's combat level
|
||||
* @param {number} hops the target system's jump-network depth from home
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function patrolEncounterBand(level, hops) {
|
||||
const bands = config.get('sidequests.patrol.encounters.bands', null);
|
||||
if (!Array.isArray(bands)) return null;
|
||||
const lvl = numOr(level, 0);
|
||||
const hop = numOr(hops, 0);
|
||||
for (const b of bands) {
|
||||
if (!b || typeof b !== 'object' || Array.isArray(b)) continue;
|
||||
if (!Array.isArray(b.levels) || b.levels.length < 2 || !Array.isArray(b.hops) || b.hops.length < 2) continue;
|
||||
const [lo, hi] = [numOr(b.levels[0], NaN), numOr(b.levels[1], NaN)];
|
||||
const [ho, hh] = [numOr(b.hops[0], NaN), numOr(b.hops[1], NaN)];
|
||||
if (![lo, hi, ho, hh].every(Number.isFinite)) continue;
|
||||
if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi) && hop >= Math.min(ho, hh) && hop <= Math.max(ho, hh)) {
|
||||
return b; // the first standing band wins (list order)
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The contact COUNT's odds table parsed — positive [count, weight] pairs sorted by count, or null. */
|
||||
function patrolCountsTable(raw) {
|
||||
const pairs = [];
|
||||
if (Array.isArray(raw)) {
|
||||
for (const [c, w] of raw) {
|
||||
|
|
@ -505,19 +569,43 @@ export function patrolEncounterCounts() {
|
|||
if (Number.isFinite(count) && count >= 1 && Number.isFinite(weight) && weight > 0) pairs.push([count, weight]);
|
||||
}
|
||||
}
|
||||
if (pairs.length === 0) return [[1, 0.2], [2, 0.2], [3, 0.6]];
|
||||
if (pairs.length === 0) return null;
|
||||
return pairs.sort((a, b) => a[0] - b[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The contact COUNT's odds (data/sidequests.json → patrol.encounters.counts
|
||||
* — [count, odds] pairs; the design: 1 contact on 20% of patrols, 2 on
|
||||
* 20%, 3 on 60%). A standing band's own `counts` overrides the shared table
|
||||
* (the higher tiers skew the odds toward every waypoint carrying a
|
||||
* contact). A malformed table falls back to the design's own.
|
||||
* @param {number} [level] the player's combat level (the band's axis)
|
||||
* @param {number} [hops] the target system's hops from home
|
||||
* @returns {Array<[number, number]>} sorted by count, positive weights
|
||||
*/
|
||||
export function patrolEncounterCounts(level = 0, hops = 0) {
|
||||
const band = patrolEncounterBand(level, hops);
|
||||
const fromBand = band ? patrolCountsTable(band.counts) : null;
|
||||
if (fromBand) return fromBand;
|
||||
return patrolCountsTable(config.get('sidequests.patrol.encounters.counts', null)) ?? [[1, 0.2], [2, 0.2], [3, 0.6]];
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact's WAVE shape (data/sidequests.json → patrol.encounters.wave —
|
||||
* the design: one ROLLING wave of six, three at a time, each kill
|
||||
* replaced — the combat.waves rolling mechanic, js/combat/Waves.js).
|
||||
* replaced — the combat.waves rolling mechanic, js/combat/Waves.js). A
|
||||
* standing band's own `wave` overrides the shared block (the tiers scale
|
||||
* the force — total grows ~60%/tier, the concurrent seats one/tier, the
|
||||
* replace rule holds).
|
||||
* @param {number} [level] the player's combat level (the band's axis)
|
||||
* @param {number} [hops] the target system's hops from home
|
||||
* @returns {{kind:string, total:number, maxConcurrent:number, replacePerKill:number}}
|
||||
*/
|
||||
export function patrolEncounterWave() {
|
||||
const raw = config.get('sidequests.patrol.encounters.wave', null);
|
||||
const w = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
||||
export function patrolEncounterWave(level = 0, hops = 0) {
|
||||
const shared = config.get('sidequests.patrol.encounters.wave', null);
|
||||
const s = shared && typeof shared === 'object' && !Array.isArray(shared) ? shared : {};
|
||||
const band = patrolEncounterBand(level, hops);
|
||||
const w = { ...s, ...((band && band.wave && typeof band.wave === 'object' && !Array.isArray(band.wave)) ? band.wave : {}) };
|
||||
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
|
||||
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
|
||||
return {
|
||||
|
|
@ -529,50 +617,37 @@ export function patrolEncounterWave() {
|
|||
}
|
||||
|
||||
/**
|
||||
* The standing band's ROSTER — the enemy types a patrol's contacts fly
|
||||
* (data/sidequests.json → patrol.encounters.bands): the FIRST band in
|
||||
* list order whose `levels` AND `hops` windows both contain the
|
||||
* player's state — the difficulty's first axis (the player's COMBAT
|
||||
* level × the target system's HOPS from home — the jump network's tree
|
||||
* depth, the 'systems 1–2 hops out' rule). Today the low band — levels
|
||||
* 1–5 at 1–2 hops from home — flies the ESTABLISHED type only (raider);
|
||||
* the farther/higher bands — with the enemy types they introduce —
|
||||
* append to the list when those types land. A roster's type ids are
|
||||
* validated against combat.enemyTypes (js/combat/EnemyTypes.js) — an
|
||||
* unknown id (a data typo) never ships; a roster that empties — or no
|
||||
* band standing at all — takes the fallback (the established type).
|
||||
* The standing band's XP SCALE (the subtle dial — data → patrol.encounters
|
||||
* bands[].xpScale): a tier's fatter wave is worth more XP per contact, and
|
||||
* the scale keeps the payout's pace with the curve rather than the body
|
||||
* count (the tier's `xpScale`, a positive number; a band without one — the
|
||||
* low band — pays full worth, 1).
|
||||
* @returns {number} the multiplier (1 when the band carries none)
|
||||
*/
|
||||
export function patrolXpScale(level = 0, hops = 0) {
|
||||
return Math.max(0, numOr(patrolEncounterBand(level, hops)?.xpScale, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* The standing band's ROSTER — the weighted type mix a patrol's contacts
|
||||
* fly (data/sidequests.json → patrol.encounters.bands — the first standing
|
||||
* band's `roster` (a `{ type: weight }` object) or legacy `enemyTypes`
|
||||
* (equal weights), validated against combat.enemyTypes): the low tier
|
||||
* (levels 1–5 at 1–2 hops) flies the ESTABLISHED type only (raider); the
|
||||
* level-6 tier jumps to a three-type mix and fatter waves, and the higher
|
||||
* tiers add types one at a time (stinger, gunner, hunter, barrage) as the
|
||||
* level climbs. A roster that empties — or no band standing at all — takes
|
||||
* the fallback (the established type).
|
||||
*
|
||||
* @param {number} level the player's combat level (data/progression.json — js/combat/XpManager.js)
|
||||
* @param {number} hops the target system's jump-network depth from home (0 = home)
|
||||
* @returns {string[]} the roster's type ids (never empty)
|
||||
* @returns {Record<string, number>} the roster's type → weight table (never empty)
|
||||
*/
|
||||
export function patrolEnemyRoster(level, hops) {
|
||||
const valid = new Set(enemyTypeIds());
|
||||
const lvl = numOr(level, 0);
|
||||
const hop = numOr(hops, 0);
|
||||
const bands = config.get('sidequests.patrol.encounters.bands', null);
|
||||
let pick = null;
|
||||
if (Array.isArray(bands)) {
|
||||
for (const b of bands) {
|
||||
if (!b || typeof b !== 'object' || Array.isArray(b)) continue;
|
||||
if (!Array.isArray(b.levels) || b.levels.length < 2 || !Array.isArray(b.hops) || b.hops.length < 2) continue;
|
||||
const [lo, hi] = [numOr(b.levels[0], NaN), numOr(b.levels[1], NaN)];
|
||||
const [ho, hh] = [numOr(b.hops[0], NaN), numOr(b.hops[1], NaN)];
|
||||
if (![lo, hi, ho, hh].every(Number.isFinite)) continue;
|
||||
if (lvl >= Math.min(lo, hi) && lvl <= Math.max(lo, hi) && hop >= Math.min(ho, hh) && hop <= Math.max(ho, hh)) {
|
||||
pick = b.enemyTypes; // the first standing band wins (list order)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = pick;
|
||||
if (raw == null) raw = config.get('sidequests.patrol.encounters.fallbackEnemyTypes', null);
|
||||
if (raw == null) raw = ESTABLISHED_ENEMY_TYPES;
|
||||
const out = (Array.isArray(raw) ? raw : [raw])
|
||||
.map((t) => String(t).trim())
|
||||
.filter((t) => t !== '' && valid.has(t));
|
||||
if (out.length > 0) return out;
|
||||
return ESTABLISHED_ENEMY_TYPES.filter((t) => valid.has(t));
|
||||
const band = patrolEncounterBand(level, hops);
|
||||
const fallback = rosterWeights(config.get('sidequests.patrol.encounters.fallbackEnemyTypes', null), null) ?? establishedRoster();
|
||||
if (!band) return fallback;
|
||||
return rosterWeights(band.roster ?? band.enemyTypes, fallback) ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -697,12 +772,13 @@ export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, l
|
|||
if (config.get('sidequests.patrol.encounters.enabled', true) === false) return null;
|
||||
if (!Array.isArray(waypoints) || waypoints.length === 0) return null;
|
||||
const r = Rng.derive(seed, seedLabel(), targetSystemId, 'patrol', String(jobId), 'encounters');
|
||||
const count = Math.max(1, Math.floor(numOr(r.weighted(patrolEncounterCounts(), 3), 3)));
|
||||
const count = Math.max(1, Math.floor(numOr(r.weighted(patrolEncounterCounts(level, hops), 3), 3)));
|
||||
return {
|
||||
at: patrolEncounterPositions(waypoints, count),
|
||||
cleared: [],
|
||||
roster: patrolEnemyRoster(level, hops),
|
||||
wave: patrolEncounterWave(),
|
||||
wave: patrolEncounterWave(level, hops),
|
||||
xpScale: patrolXpScale(level, hops),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -718,15 +794,13 @@ export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, l
|
|||
*/
|
||||
export function patrolEncounterScript(encounters) {
|
||||
const e = encounters && typeof encounters === 'object' && !Array.isArray(encounters) ? encounters : null;
|
||||
const roster = (e?.roster && Array.isArray(e.roster) && e.roster.length > 0 ? e.roster : ESTABLISHED_ENEMY_TYPES)
|
||||
.map((t) => String(t).trim())
|
||||
.filter((t) => t !== '');
|
||||
const roster = rosterWeights(e?.roster, null) ?? establishedRoster();
|
||||
const w = { ...patrolEncounterWave(), ...(e?.wave && typeof e.wave === 'object' && !Array.isArray(e.wave) ? e.wave : {}) };
|
||||
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
|
||||
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
|
||||
const wave = {
|
||||
kind,
|
||||
roster: Object.fromEntries(roster.map((t) => [t, 1])),
|
||||
roster: { ...roster },
|
||||
total,
|
||||
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
|
||||
};
|
||||
|
|
@ -759,11 +833,12 @@ export function patrolEncounterXp(encounters) {
|
|||
const total = Math.max(0, Math.floor(numOr(wave?.total, 0)));
|
||||
if (total === 0) return 0;
|
||||
const roster =
|
||||
wave?.roster && typeof wave.roster === 'object' && !Array.isArray(wave.roster) ? wave.roster : null;
|
||||
wave?.roster && (typeof wave.roster === 'object' || Array.isArray(wave.roster)) ? wave.roster : null;
|
||||
const rw = rosterWeights(roster, null);
|
||||
let weightSum = 0;
|
||||
let xpWeighted = 0;
|
||||
if (roster) {
|
||||
for (const [typeId, w] of Object.entries(roster)) {
|
||||
if (rw) {
|
||||
for (const [typeId, w] of Object.entries(rw)) {
|
||||
const weight = Math.max(0, Number(w) || 0);
|
||||
if (weight === 0) continue;
|
||||
weightSum += weight;
|
||||
|
|
@ -771,7 +846,11 @@ export function patrolEncounterXp(encounters) {
|
|||
}
|
||||
}
|
||||
if (weightSum === 0) return 0;
|
||||
return Math.round(at * total * (xpWeighted / weightSum));
|
||||
// The band's XP SCALE (the tier's subtle dial — data → patrol.encounters
|
||||
// bands[].xpScale; absent = 1): the fatter waves pay more XP worth, and
|
||||
// the scale keeps the payout's pace with the curve rather than the count.
|
||||
const scale = Math.max(0, numOr(e?.xpScale, 1));
|
||||
return Math.round(at * total * (xpWeighted / weightSum) * scale);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -855,7 +934,16 @@ function bountyWaveShape(raw, fb) {
|
|||
replacePerKill:
|
||||
kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, fb.replacePerKill))) : 0,
|
||||
};
|
||||
if (Array.isArray(w.roster) && w.roster.length > 0) out.roster = w.roster.map(String);
|
||||
if (w.roster != null) {
|
||||
const rw = Array.isArray(w.roster) ? w.roster.map(String) : rosterWeights(w.roster, null);
|
||||
if (rw != null) out.roster = rw;
|
||||
}
|
||||
// The BOUNTY's escort mix (weighted — a tier's escort flies a roster, not
|
||||
// one type; the base craft's plain `raiderType` count is the fallback).
|
||||
if (w.escortRoster != null) {
|
||||
const er = rosterWeights(w.escortRoster, null);
|
||||
if (er != null) out.escortRoster = er;
|
||||
}
|
||||
if (w.raiderType != null) out.raiderType = String(w.raiderType);
|
||||
if (w.raiders != null) out.raiders = Math.max(0, Math.floor(numOr(w.raiders, fb.raiders ?? 0)));
|
||||
if (Array.isArray(w.eliteFrames) && w.eliteFrames.length > 0) out.eliteFrames = w.eliteFrames.map(String);
|
||||
|
|
@ -864,14 +952,18 @@ function bountyWaveShape(raw, fb) {
|
|||
|
||||
/**
|
||||
* The standing band's RULES (data/sidequests.json → bounty + bounty.encounters).
|
||||
* A BAND is a level window's tuning: the live 'hunter' band (levels 2–5) and
|
||||
* the stubbed 'marksman' band (6+), which `inherits` the hunter's numbers so a
|
||||
* player who outruns level 5 keeps getting hunts instead of losing the content
|
||||
* mid-run. Resolution: the first band whose `levels` window holds the player's
|
||||
* A BAND is a level window's tuning: the 'hunter' band (levels 2–5) and the
|
||||
* tuned 'marksman' (6–10), 'deadeye' (11–14), 'apex' (15+) bands — the tiers
|
||||
* grow the pickets (6→10→14→18 at 3/4/5/6 at a time, their mixes widening
|
||||
* — spitters/bombers at the level-6 jump, stingers/gunners/hunters/barrages
|
||||
* higher) and the hunt (9→13→17→21 — the escort's mix via `escortRoster`,
|
||||
* the mark's armor/damage scale 2×→3×→3.5×→4×), so a player who outruns a
|
||||
* level window keeps getting hunts with the tier's pressure, not the same fight.
|
||||
* Resolution: the first band whose `levels` window holds the player's
|
||||
* combat level, else the fallbackBand, else the first band; the chosen band's
|
||||
* own overrides (bountyChance / skirmishChance / waypoints / skirmishWave /
|
||||
* bountyWave) sit on top of the shared encounter block, and `inherits` pulls
|
||||
* that band's numbers underneath them.
|
||||
* a band's numbers underneath them.
|
||||
*
|
||||
* @param {number} level the player's combat level (js/combat/XpManager.js)
|
||||
* @returns {{id:string, legs:[number,number], visitRadius:number, bountyChance:number, skirmishChance:number, skirmishWave:object, bountyWave:object}}
|
||||
|
|
@ -914,6 +1006,7 @@ export function bountyRules(level = 0) {
|
|||
healthScale: Math.max(1, numOr(e.bountyWave?.elite?.healthScale, 2)),
|
||||
damageScale: Math.max(1, numOr(e.bountyWave?.elite?.damageScale, 2)),
|
||||
},
|
||||
xpScale: Math.max(0, numOr(e.xpScale, 1)),
|
||||
enabled: e.enabled !== false,
|
||||
};
|
||||
// The elite's own scales ride the wave shape (the runner reads one object).
|
||||
|
|
@ -960,6 +1053,7 @@ export function bountyRules(level = 0) {
|
|||
if (Number.isFinite(Number(layer.bountyChance))) out.bountyChance = Math.min(1, Math.max(0, Number(layer.bountyChance)));
|
||||
if (Number.isFinite(Number(layer.skirmishChance))) out.skirmishChance = Math.min(1, Math.max(0, Number(layer.skirmishChance)));
|
||||
if (Number.isFinite(Number(layer.visitRadius))) out.visitRadius = Math.max(60, Number(layer.visitRadius));
|
||||
if (Number.isFinite(Number(layer.xpScale)) && Number(layer.xpScale) > 0) out.xpScale = Number(layer.xpScale);
|
||||
if (layer.skirmishWave) out.skirmishWave = bountyWaveShape(layer.skirmishWave, out.skirmishWave);
|
||||
if (layer.bountyWave) {
|
||||
out.bountyWave = bountyWaveShape(layer.bountyWave, out.bountyWave);
|
||||
|
|
@ -1041,19 +1135,25 @@ export function deriveBountyPlan(seed, jobId, targetSystemId, level = 0, opts =
|
|||
mark: bountyMark(opts?.mark),
|
||||
skirmishWave: { ...rules.skirmishWave },
|
||||
bountyWave: { ...rules.bountyWave, total: Math.max(1, raiders + 1) },
|
||||
xpScale: Math.max(0, numOr(rules.xpScale, 1)),
|
||||
visitRadius: rules.visitRadius,
|
||||
};
|
||||
}
|
||||
|
||||
/** A hunt's picket wave — one rolling wave of the established type, three at a time. */
|
||||
/**
|
||||
* A hunt's picket wave — one rolling wave of the standing band's roster
|
||||
* (a weighted mix — the tiers bring in spitters, bombers, stingers, and
|
||||
* gunners beside the established type; a band with no roster is a pure
|
||||
* type + total), the seats as the band says, each kill replaced.
|
||||
*/
|
||||
export function bountySkirmishScript(plan = {}) {
|
||||
const w = plan?.skirmishWave || bountyRules(0).skirmishWave;
|
||||
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
|
||||
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
|
||||
const type = ESTABLISHED_ENEMY_TYPES[0];
|
||||
const roster = rosterWeights(w.roster, null) ?? establishedRoster();
|
||||
const wave = {
|
||||
kind,
|
||||
roster: { [type]: 1 },
|
||||
roster: { ...roster },
|
||||
total,
|
||||
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
|
||||
};
|
||||
|
|
@ -1078,7 +1178,12 @@ export function bountyEngagementScript(plan = {}) {
|
|||
const raiderType = String(w.raiderType ?? ESTABLISHED_ENEMY_TYPES[0]);
|
||||
const total = Math.max(1, raiders + 1); // the escort + exactly one mark
|
||||
const roster = {};
|
||||
if (raiders > 0) roster[raiderType] = raiders;
|
||||
const escort = rosterWeights(w.escortRoster, null);
|
||||
if (escort) {
|
||||
for (const [t, wt] of Object.entries(escort)) roster[t] = wt; // the band's escort mix (weighted)
|
||||
} else if (raiders > 0) {
|
||||
roster[raiderType] = raiders;
|
||||
}
|
||||
roster[eliteType] = (roster[eliteType] ?? 0) + 1;
|
||||
const wave = {
|
||||
kind,
|
||||
|
|
@ -1112,11 +1217,41 @@ export function bountyHuntXp(plan = {}) {
|
|||
const raiderType = p.raiderType != null ? String(p.raiderType) : String(rules.bountyWave.raiderType ?? 'raider');
|
||||
const eliteType = String(p.eliteType ?? ESTABLISHED_ENEMY_TYPES[0]);
|
||||
const scale = numOr(p.bountyWave?.elite?.healthScale, rules.bountyWave.elite?.healthScale ?? 2);
|
||||
let xp = raiders * xpOf(raiderType) + xpOf(eliteType) * Math.max(1, scale);
|
||||
// The band's XP SCALE (the tier's subtle dial — data → bounty.encounters
|
||||
// bands[].xpScale; a band without one — the hunter — pays full worth, 1):
|
||||
// the tiers' fatter escorts + pickets pay more XP worth, and the scale
|
||||
// keeps the payout's pace with the curve rather than the body count.
|
||||
const xpScale = Math.max(0, numOr(p.xpScale, 1));
|
||||
// The escort's XP — a band's escort mix (weighted roster) at its weighted
|
||||
// mean; a band without one is the plain type × its count.
|
||||
const escort = rosterWeights(p.bountyWave?.escortRoster, null);
|
||||
let escortXp;
|
||||
if (escort) {
|
||||
let es = 0;
|
||||
let ex = 0;
|
||||
for (const [t, wt] of Object.entries(escort)) {
|
||||
es += wt;
|
||||
ex += wt * xpOf(t);
|
||||
}
|
||||
escortXp = es > 0 ? (ex / es) * raiders : raiders * xpOf(raiderType);
|
||||
} else {
|
||||
escortXp = raiders * xpOf(raiderType);
|
||||
}
|
||||
const xpMean = (weights) => {
|
||||
let s = 0;
|
||||
let x = 0;
|
||||
for (const [t, wt] of Object.entries(weights)) {
|
||||
s += wt;
|
||||
x += wt * xpOf(t);
|
||||
}
|
||||
return s > 0 ? x / s : xpOf(ESTABLISHED_ENEMY_TYPES[0]);
|
||||
};
|
||||
let xp = escortXp + xpOf(eliteType) * Math.max(1, scale);
|
||||
const skirmishTotal = Math.max(0, Math.floor(numOr(p.skirmishWave?.total, rules.skirmishWave.total)));
|
||||
const pickets = (Array.isArray(p.pickets) ? p.pickets : []).filter(Boolean).length;
|
||||
xp += pickets * skirmishTotal * xpOf(ESTABLISHED_ENEMY_TYPES[0]);
|
||||
return Math.round(xp);
|
||||
const skirmishRoster = rosterWeights(p.skirmishWave?.roster, null) ?? establishedRoster();
|
||||
xp += pickets * skirmishTotal * xpMean(skirmishRoster);
|
||||
return Math.round(xp * xpScale);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1300,6 +1435,7 @@ function normalizeBountyPlan(raw, wpCount) {
|
|||
damageScale: Math.max(1, numOr(el.damageScale, 2)),
|
||||
},
|
||||
},
|
||||
xpScale: Math.max(0, numOr(raw.xpScale, 1)),
|
||||
visitRadius: Math.max(60, numOr(raw.visitRadius, rules.visitRadius)),
|
||||
// The runtime marks (the scene's witness marks — a fresh plan carries none).
|
||||
down: raw.down === true,
|
||||
|
|
@ -1606,10 +1742,7 @@ function normalizePatrolEncounters(raw, wpCount) {
|
|||
(i) => Number.isInteger(i) && at.includes(i),
|
||||
),
|
||||
)].sort((a, b) => a - b);
|
||||
const validTypes = new Set(enemyTypeIds());
|
||||
const roster = (Array.isArray(raw.roster) ? raw.roster : [])
|
||||
.map((t) => String(t).trim())
|
||||
.filter((t) => t !== '' && validTypes.has(t));
|
||||
const roster = rosterWeights(raw.roster, null);
|
||||
const wave = patrolEncounterWave();
|
||||
const w = raw.wave && typeof raw.wave === 'object' && !Array.isArray(raw.wave) ? raw.wave : {};
|
||||
const kind = w.kind === 'fixed' ? 'fixed' : wave.kind;
|
||||
|
|
@ -1617,7 +1750,8 @@ function normalizePatrolEncounters(raw, wpCount) {
|
|||
const out = {
|
||||
at,
|
||||
cleared,
|
||||
roster: roster.length > 0 ? roster : ESTABLISHED_ENEMY_TYPES.slice(),
|
||||
roster: roster ?? establishedRoster(),
|
||||
xpScale: Math.max(0, numOr(raw.xpScale, 1)),
|
||||
wave: {
|
||||
kind,
|
||||
total,
|
||||
|
|
|
|||
|
|
@ -7624,6 +7624,10 @@ export class GameScene extends Phaser.Scene {
|
|||
const eids = enemyTypeIds();
|
||||
const nids = neutralTypeIds();
|
||||
const pick = (list) => (list.length ? list[(Math.random() * list.length) | 0] : null);
|
||||
// The band's ROSTER (data/combat.json → encounters.bands[].roster): the
|
||||
// tier's weighted mix, drawn by the director's compose — a band without
|
||||
// one leaves pirateTypes unset and the random pick stands (the old way).
|
||||
const ptypes = Array.isArray(comp?.pirateTypes) && comp.pirateTypes.length > 0 ? comp.pirateTypes : null;
|
||||
const fan = (i, n) => baseA + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (Math.random() - 0.5) * 0.4;
|
||||
for (let i = 0; i < (comp.pirates | 0); i++) {
|
||||
const a = fan(i, comp.pirates);
|
||||
|
|
@ -7631,7 +7635,7 @@ export class GameScene extends Phaser.Scene {
|
|||
ship.x + Math.cos(a) * r,
|
||||
ship.y + Math.sin(a) * r,
|
||||
this._encounterSerial++,
|
||||
pick(eids),
|
||||
ptypes ? ptypes[i % ptypes.length] : pick(eids),
|
||||
);
|
||||
e.respawn = false; // one-shots: the encounter ends with the sky clear
|
||||
}
|
||||
|
|
|
|||