diff --git a/assets/videos/toxic-surface-01.mp4 b/assets/videos/toxic-surface-01.mp4 new file mode 100644 index 0000000..f2582dd Binary files /dev/null and b/assets/videos/toxic-surface-01.mp4 differ diff --git a/data/combat.json b/data/combat.json index 0557831..5de99ad 100644 --- a/data/combat.json +++ b/data/combat.json @@ -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": { diff --git a/data/sidequests.json b/data/sidequests.json index b5a193b..78d2278 100644 --- a/data/sidequests.json +++ b/data/sidequests.json @@ -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" diff --git a/dev/bounty-hunts.test.mjs b/dev/bounty-hunts.test.mjs index 592089f..a312a90 100644 --- a/dev/bounty-hunts.test.mjs +++ b/dev/bounty-hunts.test.mjs @@ -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); } // ========================================================================== diff --git a/dev/encounters.test.mjs b/dev/encounters.test.mjs index 4f90beb..7eca67e 100644 --- a/dev/encounters.test.mjs +++ b/dev/encounters.test.mjs @@ -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); diff --git a/dev/shots/skills-bar-assign.png b/dev/shots/skills-bar-assign.png index 9c1857d..88f9d35 100644 Binary files a/dev/shots/skills-bar-assign.png and b/dev/shots/skills-bar-assign.png differ diff --git a/dev/shots/skills-bar-no-energy.png b/dev/shots/skills-bar-no-energy.png index 8aaff94..f3f1abd 100644 Binary files a/dev/shots/skills-bar-no-energy.png and b/dev/shots/skills-bar-no-energy.png differ diff --git a/dev/shots/skills-bar-ready.png b/dev/shots/skills-bar-ready.png index 5fbe40e..40fabdf 100644 Binary files a/dev/shots/skills-bar-ready.png and b/dev/shots/skills-bar-ready.png differ diff --git a/dev/shots/skills-bar-recharging.png b/dev/shots/skills-bar-recharging.png index 0a820c9..29e42cf 100644 Binary files a/dev/shots/skills-bar-recharging.png and b/dev/shots/skills-bar-recharging.png differ diff --git a/dev/shots/skills-bar.json b/dev/shots/skills-bar.json index bfebb3d..5695c34 100644 --- a/dev/shots/skills-bar.json +++ b/dev/shots/skills-bar.json @@ -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", diff --git a/dev/shots/skills-seat.png b/dev/shots/skills-seat.png index 2a5276e..7cf0476 100644 Binary files a/dev/shots/skills-seat.png and b/dev/shots/skills-seat.png differ diff --git a/dev/shots/skills-window-chain.png b/dev/shots/skills-window-chain.png index a1d1f51..5b56ea0 100644 Binary files a/dev/shots/skills-window-chain.png and b/dev/shots/skills-window-chain.png differ diff --git a/dev/shots/skills-window-closed.png b/dev/shots/skills-window-closed.png index 600883c..f613412 100644 Binary files a/dev/shots/skills-window-closed.png and b/dev/shots/skills-window-closed.png differ diff --git a/dev/shots/skills-window-long-tree.png b/dev/shots/skills-window-long-tree.png index 2e0adac..d3244e5 100644 Binary files a/dev/shots/skills-window-long-tree.png and b/dev/shots/skills-window-long-tree.png differ diff --git a/dev/shots/skills-window-ranked.png b/dev/shots/skills-window-ranked.png index 0cdf902..1a6f1b7 100644 Binary files a/dev/shots/skills-window-ranked.png and b/dev/shots/skills-window-ranked.png differ diff --git a/dev/shots/skills-window-selected.png b/dev/shots/skills-window-selected.png index 6b95960..5a0c915 100644 Binary files a/dev/shots/skills-window-selected.png and b/dev/shots/skills-window-selected.png differ diff --git a/dev/sidequests.test.mjs b/dev/sidequests.test.mjs index df78f20..c0a9ca6 100644 --- a/dev/sidequests.test.mjs +++ b/dev/sidequests.test.mjs @@ -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'); diff --git a/js/combat/Encounters.js b/js/combat/Encounters.js index 148579e..6c30c2b 100644 --- a/js/combat/Encounters.js +++ b/js/combat/Encounters.js @@ -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 } : {}) }; } /** diff --git a/js/quests/SideQuests.js b/js/quests/SideQuests.js index ca6bc6c..f644470 100644 --- a/js/quests/SideQuests.js +++ b/js/quests/SideQuests.js @@ -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', * , 'patrol', , '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} */ -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} 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, diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index c564344..b71d64b 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 }