Add hostile contact encounters to patrol side quests
- Patrol routes now carry 1–3 seeded enemy contacts placed evenly across waypoints. - Arriving at a contact fires one rolling wave of six raiders, three at a time. - Route completion and reward claiming require every waypoint and contact cleared. - Contact specs are fixed on the route at accept for stable saves and deterministic replay. - Job board, quest checklist, toasts, and map markers expose contact progress.
This commit is contained in:
parent
b461bc98af
commit
d3fadcf6a5
|
|
@ -70,13 +70,47 @@
|
|||
"regionInset": 96,
|
||||
"neighborMinD": 0.4,
|
||||
"neighborChance": 0.5,
|
||||
"encounters": {
|
||||
"_comment": "The patrol's ENEMY ENCOUNTERS — the contract's hostile CONTACTS standing on route waypoints (the data contract: data/sidequests.json → patrol.encounters; the logic: js/quests/SideQuests.js → derivePatrolEncounters + patrolEncounterScript; the firing: the scene's updatePatrol → _firePatrolContact — the wave starts when the ship checks in at a CONTACT's waypoint, and the cleared mark lands on the wave run's COMPLETION — the witness is the completion, not the attempt: a mid-fight quit re-arms the contact on return, the combatDone precedent — and the route's completion, with the reward's door, waits for every contact). COUNTS — the odds of how many CONTACTS a patrol carries (1 contact on 20% of patrols, 2 on 20%, 3 on 60%): [count, odds] pairs, drawn SEED-STABLE at the accept from the labelled fork (seed, 'sidequests', <targetSystemId>, 'patrol', <jobId>, 'encounters') and fixed on the route record — a signed contract is a signed contract: a later re-tune of the odds can't re-roll a standing contract's combat. WAVE — each contact's fight: ONE rolling wave of the band's roster (equal weights — a single-type band is a pure type + total), the data's shape — six hostiles, three at a time, each kill replaced — the combat.waves rolling mechanic (js/combat/Waves.js); the script is built by patrolEncounterScript (no named entry in data/combat.json — the roster comes from the band below). BANDS — the difficulty's first axis (the player's COMBAT level × the target system's HOPS from home — the jump network's tree depth, galaxy.jumpNetwork.depth — the 'systems 1–2 hops out' rule): the FIRST band in list order whose levels AND hops windows both contain the player stands, and its enemyTypes are the hostiles its contacts fly (validated against combat.enemyTypes — js/combat/EnemyTypes.js — an unknown id never ships). No band stands, or a roster empties, → fallbackEnemyTypes. TODAY the low band — levels 1–5 at 1–2 hops from home — flies the ESTABLISHED type only (raider — the base craft's named id, the 'Pirate Problems' wave's own type); the farther/higher bands — with the enemy types they introduce — append to this list when those types land, and the other difficulty axes (the distance's own scaling, the level's bonus) wire onto this same seam. POSITIONING — the contacts stand on route waypoints SPREAD evenly across the route (patrolEncounterPositions): for ≥ 2 contacts, the most evenly spread subset of the route's waypoints (scored by its MINIMUM pairwise separation — maximin — then its total: on a near-linear route that's the ends and the middle; a 3–5-point route's contacts never cluster); a single contact stands on the waypoint nearest the route's centroid (the patrol's middle). Deterministic — a pure function of the seed, the contract, and the route's geometry.",
|
||||
"enabled": true,
|
||||
"counts": [
|
||||
[1, 0.2],
|
||||
[2, 0.2],
|
||||
[3, 0.6]
|
||||
],
|
||||
"wave": {
|
||||
"kind": "rolling",
|
||||
"total": 6,
|
||||
"maxConcurrent": 3,
|
||||
"replacePerKill": 1
|
||||
},
|
||||
"bands": [
|
||||
{
|
||||
"levels": [
|
||||
1,
|
||||
5
|
||||
],
|
||||
"hops": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"enemyTypes": [
|
||||
"raider"
|
||||
]
|
||||
}
|
||||
],
|
||||
"fallbackEnemyTypes": [
|
||||
"raider"
|
||||
]
|
||||
},
|
||||
"compass": {
|
||||
"color": "#ffd60a",
|
||||
"typeLabel": "Patrol"
|
||||
},
|
||||
"marker": {
|
||||
"color": "#ffd60a",
|
||||
"doneColor": "#67e863"
|
||||
"doneColor": "#67e863",
|
||||
"contactColor": "#ff5c77"
|
||||
}
|
||||
},
|
||||
"descriptions": {
|
||||
|
|
@ -229,6 +263,17 @@
|
|||
"claimed": "REWARD CLAIMED",
|
||||
"destination": "DESTINATION — {name}",
|
||||
"routeProgress": "ROUTE — {visited}/{total} WAYPOINTS LOGGED",
|
||||
"contactProgress": "CONTACTS — {cleared}/{total} CLEARED",
|
||||
"patrolContactToast": {
|
||||
"glyph": "⚔",
|
||||
"color": "#ff5c77",
|
||||
"text": "HOSTILE CONTACT — {n} ENEMIES INBOUND"
|
||||
},
|
||||
"patrolContactClearedToast": {
|
||||
"glyph": "✓",
|
||||
"color": "#67e863",
|
||||
"text": "CONTACT CLEARED — {i} OF {n}"
|
||||
},
|
||||
"acceptToast": {
|
||||
"glyph": "◆",
|
||||
"color": "#ffc94d",
|
||||
|
|
|
|||
|
|
@ -394,6 +394,104 @@ 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 { normalizeWaves } = await import(pathToFileURL(join(__dirname, '../js/combat/Waves.js')).href);
|
||||
|
||||
// --- the COUNT’S ODDS (the data’s 1×20% / 2×20% / 3×60%) -----------
|
||||
const counts = patrolEncounterCounts();
|
||||
assert.deepEqual(counts, [[1, 0.2], [2, 0.2], [3, 0.6]], 'the data’s counts: 1 contact on 20% of patrols, 2 on 20%, 3 on 60%');
|
||||
const wps5 = [[0, 0], [100, 20], [210, -30], [120, 180], [-40, 90]];
|
||||
const dist = { 1: 0, 2: 0, 3: 0 };
|
||||
const N = 20000;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const e = derivePatrolEncounters(`seed${i}`, 'sq-x-patrol-0', 'sys-x', wps5, 3, 1);
|
||||
assert.ok(e && Array.isArray(e.at), 'a spec stands for a live route');
|
||||
dist[e.at.length]++;
|
||||
}
|
||||
for (const [k, p] of [[1, 0.2], [2, 0.2], [3, 0.6]]) {
|
||||
assert.ok(Math.abs(dist[k] / N - p) < 0.02, `the count’s odds: ${k} contact on ${(100 * dist[k] / N).toFixed(1)}% of patrols (the data’s ${(100 * p).toFixed(0)}% ± 2%)`);
|
||||
}
|
||||
|
||||
// --- DETERMINISM (the worldgen contract) ----------------------------
|
||||
const a = derivePatrolEncounters(SEED, 'sq-x-patrol-0', 'sys-x', wps5, 3, 1);
|
||||
const b = derivePatrolEncounters(SEED, 'sq-x-patrol-0', 'sys-x', wps5, 3, 1);
|
||||
assert.deepEqual(a, b, 'same seed + contract + route → the SAME spec (re-accepts / scene restarts / saves)');
|
||||
// Different seeds draw their own spec — over a spread of seeds the
|
||||
// count’s odds move, so multiple distinct specs stand.
|
||||
const specs = new Set();
|
||||
for (let s = 0; s < 64; s++) specs.add(JSON.stringify(derivePatrolEncounters(SEED + s * 7, 'sq-x-patrol-0', 'sys-x', wps5, 3, 1)));
|
||||
assert.ok(specs.size >= 3, `${specs.size} distinct specs across 64 seeds (the count’s odds spread them — 1/2/3 contacts)`);
|
||||
// Different CONTRACTS (the fork’s jobId + target) draw independent rolls
|
||||
// — over a seed spread the two contracts’ specs diverge (a fork that
|
||||
// dropped the contract would keep them identical, seed for seed).
|
||||
let diverge = 0;
|
||||
for (let s = 0; s < 64; s++) {
|
||||
if (JSON.stringify(derivePatrolEncounters(SEED + s * 7, 'sq-x-patrol-0', 'sys-x', wps5, 3, 1))
|
||||
!== JSON.stringify(derivePatrolEncounters(SEED + s * 7, 'sq-x-patrol-0', 'sys-y', wps5, 3, 1))) diverge++;
|
||||
}
|
||||
assert.ok(diverge > 5, `${diverge}/64 seeds diverge the two contracts’ specs (the fork is per-contract — not one shared roll)`);
|
||||
assert.notDeepEqual(derivePatrolEncounters(SEED, 'sq-x-patrol-0', 'sys-x', [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]], 3, 1), a, 'a different route’s geometry draws its own spec');
|
||||
|
||||
// --- 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.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)');
|
||||
|
||||
// --- 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 POSITIONING (the contacts spread across the route) ---------
|
||||
const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]];
|
||||
assert.deepEqual(patrolEncounterPositions(line, 2), [0, 4], 'two contacts on a five-point line: the ENDS (the most separated pair — never a cluster)');
|
||||
assert.deepEqual(patrolEncounterPositions(line, 3), [0, 2, 4], 'three contacts on a five-point line: the ends + the MIDDLE (the even spread)');
|
||||
assert.deepEqual(patrolEncounterPositions(line, 1), [2], 'one contact on a five-point line: the route’s MIDDLE (the waypoint nearest the centroid)');
|
||||
assert.deepEqual(patrolEncounterPositions(line, 5), [0, 1, 2, 3, 4], 'count ≥ the route’s length → every waypoint stands');
|
||||
assert.deepEqual(patrolEncounterPositions([], 2), [], 'an empty route → no contacts');
|
||||
const pair = patrolEncounterPositions(wps5, 2);
|
||||
const bestPair = (() => { // the farthest pair of the route (the positioning’s optimum)
|
||||
let bi = null;
|
||||
let bd = -1;
|
||||
for (let i = 0; i < wps5.length; i++) for (let j = i + 1; j < wps5.length; j++) {
|
||||
const d = Math.hypot(wps5[i][0] - wps5[j][0], wps5[i][1] - wps5[j][1]);
|
||||
if (d > bd + 1e-9) { bd = d; bi = [i, j]; }
|
||||
}
|
||||
return { pair: bi, dist: bd };
|
||||
})();
|
||||
const pairDist = Math.hypot(wps5[pair[0]][0] - wps5[pair[1]][0], wps5[pair[0]][1] - wps5[pair[1]][1]);
|
||||
assert.ok(pairDist >= bestPair.dist - 1e-9, 'two contacts: the pair is the route’s most separated pair (the spread is real, not just distinct)');
|
||||
|
||||
// --- THE WAVE SCRIPT (CombatSystem.startWaves’ shape) ----------------
|
||||
const script = patrolEncounterScript(a);
|
||||
const norm = normalizeWaves(script);
|
||||
assert.ok(norm.ok, `the contact’s script normalizes cleanly (${norm.ok ? 'ok' : norm.error})`);
|
||||
assert.equal(norm.waves.length, 1, 'ONE wave per contact (the data’s shape)');
|
||||
const w0 = norm.waves[0];
|
||||
assert.equal(w0.kind, 'rolling', '…rolling (the data’s kind)');
|
||||
assert.equal(w0.total, 6, '…six hostiles (the data’s total)');
|
||||
assert.equal(w0.maxConcurrent, 3, '…three at a time (the data’s maxConcurrent)');
|
||||
assert.equal(w0.replacePerKill, 1, '…each kill replaced (the data’s replacePerKill)');
|
||||
assert.deepEqual(Object.keys(w0.roster), ['raider'], '…the band’s roster (the established type)');
|
||||
assert.equal(patrolEncounterScript(null).waves[0].roster.raider, 1, 'a missing spec degrades to the established type’s wave (never an empty roster)');
|
||||
|
||||
// --- THE ROUTE’S COMPLETION (waypoints + contacts) -------------------
|
||||
const routeOk = { waypoints: [[1, 1], [2, 2], [3, 3]], visited: [0, 1, 2], encounters: { at: [0], cleared: [0], roster: ['raider'], wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 } } };
|
||||
assert.equal(patrolRouteComplete(routeOk), true, 'every waypoint logged + every contact cleared → COMPLETE');
|
||||
assert.equal(patrolRouteComplete({ ...routeOk, visited: [0, 1] }), false, 'a waypoint unlogged → not complete');
|
||||
assert.equal(patrolRouteComplete({ ...routeOk, encounters: { ...routeOk.encounters, cleared: [] } }), false, 'a contact UNCLEARED → not complete (the combat can’t be outrun)');
|
||||
assert.equal(patrolRouteComplete({ waypoints: [[1, 1]], visited: [0] }), true, 'a route WITHOUT encounters (old saves load) completes on its waypoints alone');
|
||||
assert.equal(patrolRouteComplete(null), false, 'no route → not complete');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
console.log('== the ledger — SideQuestState (the run’s accepted/active/completed/claimed marks) ==');
|
||||
{
|
||||
|
|
@ -458,6 +556,38 @@ console.log('== the ledger — SideQuestState (the run’s accepted/active/compl
|
|||
assert.deepEqual(rtm.routeFor('sq-m-patrol-0').waypoints, [[1, 2]], 'the malformed waypoints degrade to the finite pairs');
|
||||
assert.deepEqual(rtm.routeFor('sq-m-patrol-0').visited, [0], 'the visited indices dedupe + range-check');
|
||||
|
||||
// The route’s ENCOUNTERS (the contract’s contacts — the spec is fixed
|
||||
// on the route record at the accept): setRoute normalizes them
|
||||
// (cleared ⊆ at, in range, dedupe, sorted) — and encounterCleared
|
||||
// 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 } };
|
||||
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.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');
|
||||
assert.equal(rte.encounterCleared('sq-e-patrol-0', 2), true, 'an uncleared contact clears (the wave run’s completion)');
|
||||
assert.equal(rte.encounterCleared('sq-e-patrol-0', 2), false, '…idempotently (a double fire can’t double-mark)');
|
||||
assert.deepEqual(rte.routeFor('sq-e-patrol-0').encounters.cleared, [1, 2], 'the cleared marks stand in clear order');
|
||||
// A route WITHOUT encounters (the pre-feature shape) degrades — the
|
||||
// old saves’ routes load, complete on their waypoints alone.
|
||||
const rtOld = new SideQuestState();
|
||||
rtOld.accept('sq-o-patrol-0');
|
||||
rtOld.setRoute('sq-o-patrol-0', { targetSystemId: 'sys-o', credits: 60, waypoints: [[1, 1]], visited: [] });
|
||||
assert.equal(rtOld.routeFor('sq-o-patrol-0').encounters, null, 'a route without encounters → encounters null (the old shape stands)');
|
||||
assert.equal(rtOld.encounterCleared('sq-o-patrol-0', 0), false, '…and no contact can clear on it');
|
||||
// The encounters survive the save round-trip (the spec is part of the
|
||||
// route record — re-accepts / reloads re-read it, never re-draw it).
|
||||
const encJson = rte.toJSON();
|
||||
const backEnc = SideQuestState.fromJSON(encJson);
|
||||
assert.deepEqual(backEnc.routeFor('sq-e-patrol-0').encounters, enc, 'the route’s encounters (at/cleared/roster/wave) round-trip the save');
|
||||
assert.equal(backEnc.encounterCleared('sq-e-patrol-0', 2), false, '…and the cleared mark is still idempotent after the restore');
|
||||
|
||||
// The save round-trip — the ledger is the save’s side-quest state
|
||||
// (the postings themselves regenerate from the seed: a save = seed +
|
||||
// player state).
|
||||
|
|
@ -558,7 +688,7 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
|
|||
// The scene's seam methods under test — the REAL prototype functions,
|
||||
// bound onto the stub `this` (their internal `this.*` calls land on
|
||||
// the stub's ledger/galaxy/ship seams).
|
||||
for (const k of ['questSnapshot', '_sideQuestView', '_sideQuestPosting', 'claimQuest', 'claimSideQuest', 'setPriorityQuest']) {
|
||||
for (const k of ['questSnapshot', '_sideQuestView', '_sideQuestPosting', 'claimQuest', 'claimSideQuest', 'setPriorityQuest', 'updatePatrol', '_firePatrolContact', 'patrolProgress']) {
|
||||
scene[k] = GameScene.prototype[k];
|
||||
}
|
||||
return scene;
|
||||
|
|
@ -676,6 +806,136 @@ console.log('== the scene’s seams — the QUESTS console + tracker see the acc
|
|||
sceneQ.sideQuestState.complete(other.id); // a READY contract — nothing left to fly
|
||||
assert.equal(call(GameScene.prototype.setPriorityQuest, sceneQ, other.id), false, 'SET PRIORITY on a DONE contract refuses (nothing left to fly)');
|
||||
assert.equal(call(GameScene.prototype.setPriorityQuest, sceneQ, 'start_your_journey'), false, 'SET PRIORITY on an ungranted story quest refuses (the story ledger’s own gate)');
|
||||
|
||||
// --- THE CONTACTS (the route’s encounters): the combat the patrol ----
|
||||
// owes — fired at the contact’s waypoint, cleared on the wave run’s
|
||||
// completion, gating the route’s completion (the witness is the
|
||||
// combat’s completion, not the firing).
|
||||
const sceneC = makeScene();
|
||||
const combat = {
|
||||
runs: [],
|
||||
waveRun: { state: 'done' },
|
||||
startWaves(script, opts = {}) {
|
||||
const run = { state: 'lead', script, opts };
|
||||
this.waveRun = run;
|
||||
this.runs.push(run);
|
||||
return run;
|
||||
},
|
||||
complete() { // the run’s completion (the combatDone moment)
|
||||
this.waveRun.state = 'done';
|
||||
this.waveRun.opts.onComplete?.();
|
||||
},
|
||||
};
|
||||
sceneC.ship = { x: 0, y: 0, addCredits: (n) => { sceneC.state.credits += n; } };
|
||||
sceneC.systemRecord = REC_NEUTRAL;
|
||||
sceneC.combat = combat;
|
||||
const jobC = posts.find((p) => p.category === 'patrol');
|
||||
sceneC.sideQuestState.accept(jobC.id);
|
||||
// The route: three collinear waypoints (the contact on the middle),
|
||||
// the visit radius (420) well inside the 1000 gap.
|
||||
sceneC.sideQuestState.setRoute(jobC.id, {
|
||||
targetSystemId: REC_NEUTRAL.id,
|
||||
credits: 140,
|
||||
waypoints: [[0, 0], [1000, 0], [2000, 0]],
|
||||
visited: [],
|
||||
encounters: { at: [1], cleared: [], roster: ['raider'], wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 } },
|
||||
});
|
||||
sceneC.sideQuestState.setActive(jobC.id);
|
||||
|
||||
assert.deepEqual(sceneC.patrolProgress(jobC.id).contacts, { cleared: 0, total: 1 }, 'the board’s seam reads the contacts (0/1 CLEARED)');
|
||||
let viewC = sceneC._sideQuestView(jobC.id);
|
||||
assert.equal(viewC.checks.length, 4, 'the checklist = the route’s waypoints + its contact');
|
||||
assert.equal(viewC.checks[3].done, false, '…the contact stands OPEN (hostiles)');
|
||||
|
||||
// At the FIRST waypoint: it logs, the contact (not near) stays silent,
|
||||
// no completion (waypoints stand open).
|
||||
sceneC.ship.x = 0; sceneC.ship.y = 0;
|
||||
sceneC.updatePatrol();
|
||||
assert.deepEqual(sceneC.sideQuestState.routeFor(jobC.id).visited, [0], 'the waypoint logs on arrival');
|
||||
assert.equal(combat.runs.length, 0, '…and no wave fires at a non-contact waypoint');
|
||||
|
||||
// At the CONTACT’s waypoint: it logs AND the contact’s wave fires —
|
||||
// one rolling wave of six (the spec’s shape), the run live.
|
||||
sceneC.ship.x = 1000; sceneC.ship.y = 0;
|
||||
sceneC.updatePatrol();
|
||||
assert.deepEqual(sceneC.sideQuestState.routeFor(jobC.id).visited, [0, 1], 'the contact’s waypoint logs (the check-in is separate from the fight)');
|
||||
assert.equal(combat.runs.length, 1, 'the contact’s wave fires (one startWaves call)');
|
||||
assert.equal(combat.runs[0].script.waves.length, 1, '…ONE wave (the data’s shape — one rolling wave per contact)');
|
||||
assert.equal(combat.runs[0].script.waves[0].total, 6, '…six hostiles (the data’s total)');
|
||||
assert.equal(combat.runs[0].script.waves[0].maxConcurrent, 3, '…three at a time (the data’s maxConcurrent)');
|
||||
assert.deepEqual(Object.keys(combat.runs[0].script.waves[0].roster), ['raider'], '…the band’s roster (the established type)');
|
||||
assert.equal(combat.waveRun.state, 'lead', '…the run is live (lead)');
|
||||
assert.ok(sceneC.state.toasts.some((t) => t.label.toUpperCase().includes('HOSTILE CONTACT')), '…with the contact’s console toast (the ship’s voice)');
|
||||
assert.deepEqual(sceneC.sideQuestState.routeFor(jobC.id).encounters.cleared, [], '…but the contact is NOT cleared by the firing (the witness is the completion)');
|
||||
|
||||
// One fight at a time — the live run holds the fire (no double wave).
|
||||
sceneC.updatePatrol();
|
||||
assert.equal(combat.runs.length, 1, 'a live run holds the fire (no second wave while one is in flight)');
|
||||
|
||||
// The run completes → the contact clears (the witness) — idempotent
|
||||
// even if the completion fires twice.
|
||||
combat.complete();
|
||||
assert.deepEqual(sceneC.sideQuestState.routeFor(jobC.id).encounters.cleared, [1], 'the completion clears the contact (the witness is the combat’s completion)');
|
||||
combat.complete();
|
||||
assert.deepEqual(sceneC.sideQuestState.routeFor(jobC.id).encounters.cleared, [1], '…idempotently (a double completion can’t double-mark)');
|
||||
assert.ok(sceneC.state.toasts.some((t) => t.label.toUpperCase().includes('CONTACT CLEARED')), '…with the cleared toast (the console’s voice)');
|
||||
|
||||
// The route still stands OPEN (the last waypoint unlogged) — then
|
||||
// completes on it: waypoints + contacts, both.
|
||||
assert.equal(sceneC.sideQuestState.isCompleted(jobC.id), false, '…but the route is NOT complete (a waypoint stands open)');
|
||||
sceneC.updatePatrol();
|
||||
sceneC.ship.x = 2000; sceneC.ship.y = 0;
|
||||
sceneC.updatePatrol();
|
||||
assert.equal(sceneC.sideQuestState.isCompleted(jobC.id), true, 'every waypoint logged + every contact cleared → the route COMPLETES (the reward ready)');
|
||||
assert.ok(sceneC.state.toasts.some((t) => t.label.toUpperCase().includes('PATROL ROUTE COMPLETE')), '…with the completion toast (the console’s voice)');
|
||||
assert.deepEqual(sceneC.patrolProgress(jobC.id), { visited: 3, total: 3, contacts: { cleared: 1, total: 1 } }, 'the board’s seam reads the finished route (3/3 + 1/1)');
|
||||
viewC = sceneC._sideQuestView(jobC.id);
|
||||
assert.equal(viewC.checks[3].done, true, '…and the checklist’s contact reads CLEARED');
|
||||
const beforeC = sceneC.state.credits;
|
||||
call(GameScene.prototype.claimQuest, sceneC, jobC.id);
|
||||
assert.equal(sceneC.state.credits, beforeC + 140, '…and the claim pays the contract’s payout (the route’s credits)');
|
||||
|
||||
// The combat’s completion GATES the route (the combat can’t be
|
||||
// outrun): waypoints all logged, the contact’s wave fired but NOT
|
||||
// completed → the route stands open.
|
||||
const sceneD = makeScene();
|
||||
const combatD = {
|
||||
runs: [],
|
||||
waveRun: { state: 'done' },
|
||||
startWaves(script, opts = {}) { const run = { state: 'live', script, opts }; this.waveRun = run; this.runs.push(run); return run; },
|
||||
};
|
||||
sceneD.ship = { x: 0, y: 0, addCredits: (n) => { sceneD.state.credits += n; } };
|
||||
sceneD.systemRecord = REC_NEUTRAL;
|
||||
sceneD.combat = combatD;
|
||||
sceneD.sideQuestState.accept(jobC.id);
|
||||
sceneD.sideQuestState.setActive(jobC.id);
|
||||
sceneD.sideQuestState.setRoute(jobC.id, {
|
||||
targetSystemId: REC_NEUTRAL.id,
|
||||
credits: 80,
|
||||
waypoints: [[1000, 0]],
|
||||
visited: [0], // the waypoint already logged
|
||||
encounters: { at: [0], cleared: [], roster: ['raider'], wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 } },
|
||||
});
|
||||
sceneD.ship.x = 1000;
|
||||
sceneD.updatePatrol();
|
||||
assert.equal(combatD.runs.length, 1, 'the contact’s wave fires (the waypoint is the contact’s)');
|
||||
assert.equal(sceneD.sideQuestState.isCompleted(jobC.id), false, '…but the route is NOT complete while the run is live (the combat gates the completion)');
|
||||
const beforeD = sceneD.state.credits;
|
||||
call(GameScene.prototype.claimQuest, sceneD, jobC.id);
|
||||
assert.equal(sceneD.state.credits, beforeD, '…and it pays NOTHING uncompleted (the combat can’t be outrun)');
|
||||
|
||||
// A route WITHOUT encounters (the pre-feature shape) completes on its
|
||||
// waypoints alone (old saves load + finish) — no combat involved.
|
||||
const sceneE = makeScene();
|
||||
sceneE.ship = { x: 0, y: 0, addCredits: (n) => { sceneE.state.credits += n; } };
|
||||
sceneE.systemRecord = REC_NEUTRAL;
|
||||
// no combat on the scene at all — the old-shape route doesn’t need one
|
||||
sceneE.sideQuestState.accept(jobC.id);
|
||||
sceneE.sideQuestState.setActive(jobC.id);
|
||||
sceneE.sideQuestState.setRoute(jobC.id, { targetSystemId: REC_NEUTRAL.id, credits: 55, waypoints: [[0, 0]], visited: [] });
|
||||
sceneE.ship.x = 0; sceneE.ship.y = 0;
|
||||
sceneE.updatePatrol();
|
||||
assert.equal(sceneE.sideQuestState.isCompleted(jobC.id), true, 'a route without encounters (old saves) completes on its waypoints alone — no combat, no throw');
|
||||
}
|
||||
|
||||
console.log('\nsidequests: OK — the board’s model (the gate, the voice, the patrol’s target + route, the ledger) + the QUESTS console/tracker seams');
|
||||
console.log('\nsidequests: OK — the board’s model (the gate, the voice, the patrol’s target + route + encounters, the ledger) + the QUESTS console/tracker seams');
|
||||
|
|
|
|||
|
|
@ -45,6 +45,23 @@
|
|||
* must always be completable). Seeded on (seed, 'sidequests',
|
||||
* <targetSystemId>, 'patrol', <jobId>, 'route') — same contract +
|
||||
* region ⇒ same route, across re-accepts, scene restarts, saves.
|
||||
* - ENCOUNTERS — the patrol's hostile CONTACTS (data →
|
||||
* patrol.encounters): how many stand (counts — the odds: 1 on 20%
|
||||
* 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
|
||||
* 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;
|
||||
* patrolEncounterScript shapes the contact's wave script for
|
||||
* CombatSystem.startWaves; the scene's updatePatrol fires the wave
|
||||
* at the contact's waypoint and marks it cleared on the run's
|
||||
* completion (the witness is the completion, not the attempt — the
|
||||
* combatDone precedent), and patrolRouteComplete stands the
|
||||
* contract: every waypoint logged AND every contact cleared.
|
||||
* - LEDGER — SideQuestState carries the accepted order, the ONE
|
||||
* active contract (reconcileActive: the top accepted contract
|
||||
* stands active until it is done and claimed), the route record
|
||||
|
|
@ -74,6 +91,7 @@
|
|||
import { config } from '../config/Config.js';
|
||||
import { Rng } from '../utils/Rng.js';
|
||||
import { Tether } from '../tether/Tether.js';
|
||||
import { enemyTypeIds } from '../combat/EnemyTypes.js';
|
||||
|
||||
/** The four job types, canonical slot order (the data's category keys). */
|
||||
export const SIDE_QUEST_CATEGORIES = ['patrol', 'delivery', 'bounty', 'escort'];
|
||||
|
|
@ -385,6 +403,292 @@ export function buildPatrolRoute(seed, jobId, targetSystemId, region, anchors =
|
|||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the patrol's ENCOUNTERS
|
||||
// The contract's hostile CONTACTS (data/sidequests.json → patrol.encounters):
|
||||
// how many stand (counts), where they stand (positions — the max-spread
|
||||
// subset of the route's waypoints), what they fly (the band's roster —
|
||||
// the player's level × the target's hops from home), and the fight's shape
|
||||
// (the wave — one rolling wave of six, three at a time). All pure and
|
||||
// seeded — the scene's _establishPatrol rolls the spec at the accept and
|
||||
// fixes it on the route record; the scene's updatePatrol fires it at the
|
||||
// contact's waypoint and marks the clearance on the wave run's completion.
|
||||
|
||||
/** The established type (the base craft's named id) — the low band's roster and the bandless fallback alike. */
|
||||
const ESTABLISHED_ENEMY_TYPES = ['raider'];
|
||||
|
||||
/** A finite number, else the fallback. */
|
||||
function numOr(v, fb) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 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
|
||||
*/
|
||||
export function patrolEncounterCounts() {
|
||||
const raw = config.get('sidequests.patrol.encounters.counts', null);
|
||||
const pairs = [];
|
||||
if (Array.isArray(raw)) {
|
||||
for (const [c, w] of raw) {
|
||||
const count = Math.floor(numOr(c, NaN));
|
||||
const weight = numOr(w, NaN);
|
||||
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]];
|
||||
return pairs.sort((a, b) => a[0] - b[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @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 : {};
|
||||
const kind = w.kind === 'fixed' ? 'fixed' : 'rolling';
|
||||
const total = Math.max(1, Math.floor(numOr(w.total, 6)));
|
||||
return {
|
||||
kind,
|
||||
total,
|
||||
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
|
||||
replacePerKill: kind === 'rolling' ? Math.max(0, Math.floor(numOr(w.replacePerKill, 1))) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* @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)
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* The contact's WAYPOINTS — the `count` of the route's `waypoints`
|
||||
* (finite [x,y] pairs) the contacts stand on, SPREAD across the route
|
||||
* (data/sidequests.json → patrol.encounters — POSITIONING): for count ≥
|
||||
* 2, the most EVENLY SPREAD subset of the route's waypoints (scored by
|
||||
* its MINIMUM pairwise separation — maximin — then its total: on a
|
||||
* near-linear route that's the ends and the middle; a 3–5-point
|
||||
* route's contacts never cluster); a single contact stands on the
|
||||
* waypoint nearest the route's centroid (the patrol's
|
||||
* middle). Pure in (waypoints, count) — deterministic, ties keep the
|
||||
* lowest index order (the route itself is seeded, so the pair is stable
|
||||
* for the run).
|
||||
*
|
||||
* @param {Array<[number, number]>} waypoints the route's waypoints
|
||||
* @param {number} count the contacts' number (clamped to the route's length)
|
||||
* @returns {number[]} the waypoint indices (distinct, ascending; [] for an empty route)
|
||||
*/
|
||||
export function patrolEncounterPositions(waypoints, count) {
|
||||
const n = Array.isArray(waypoints) ? waypoints.length : 0;
|
||||
if (n === 0) return [];
|
||||
const k = Math.max(1, Math.min(Math.floor(numOr(count, 1)), n));
|
||||
if (k === n) return waypoints.map((_, i) => i);
|
||||
if (k === 1) {
|
||||
// One contact: the patrol's MIDDLE — the waypoint nearest the
|
||||
// route's centroid (the points' center of mass — the route is a set
|
||||
// of points, not a path, so the centroid is its only 'middle').
|
||||
let cx = 0, cy = 0, m = 0;
|
||||
for (const w of waypoints) {
|
||||
if (Array.isArray(w) && w.length >= 2 && Number.isFinite(w[0]) && Number.isFinite(w[1])) {
|
||||
cx += w[0];
|
||||
cy += w[1];
|
||||
m++;
|
||||
}
|
||||
}
|
||||
if (m === 0) return [0];
|
||||
cx /= m;
|
||||
cy /= m;
|
||||
let best = 0;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = waypoints[i];
|
||||
if (!Array.isArray(w) || w.length < 2) continue;
|
||||
const d = (w[0] - cx) ** 2 + (w[1] - cy) ** 2;
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return [best];
|
||||
}
|
||||
// k ≥ 2: every C(n, k) subset (n ≤ the route's 5 — a trivial space),
|
||||
// scored for EVEN SPREAD: first the MINIMUM pairwise separation
|
||||
// (maximin — the contacts are as far apart from each other as the
|
||||
// route allows: on a near-linear route that's the ends and the middle,
|
||||
// never a cluster), then the total pairwise separation, ties the
|
||||
// lowest index order (the subsets enumerate in index order).
|
||||
const dist = (a, b) => Math.hypot(waypoints[a][0] - waypoints[b][0], waypoints[a][1] - waypoints[b][1]);
|
||||
const score = (idxs) => {
|
||||
let min = Infinity;
|
||||
let sum = 0;
|
||||
for (let a = 0; a < idxs.length; a++) {
|
||||
for (let b = a + 1; b < idxs.length; b++) {
|
||||
const d = dist(idxs[a], idxs[b]);
|
||||
if (d < min) min = d;
|
||||
sum += d;
|
||||
}
|
||||
}
|
||||
return { min, sum };
|
||||
};
|
||||
const combos = [];
|
||||
const rec = (start, cur) => {
|
||||
if (cur.length === k) {
|
||||
combos.push(cur.slice());
|
||||
return;
|
||||
}
|
||||
for (let i = start; i <= n - (k - cur.length); i++) rec(i + 1, [...cur, i]);
|
||||
};
|
||||
rec(0, []);
|
||||
let best = null;
|
||||
let bestMin = -1;
|
||||
let bestSum = -1;
|
||||
for (const c of combos) {
|
||||
const s = score(c);
|
||||
if (s.min > bestMin + 1e-9 || (Math.abs(s.min - bestMin) <= 1e-9 && s.sum > bestSum + 1e-9)) {
|
||||
bestMin = s.min;
|
||||
bestSum = s.sum;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best ?? [0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The patrol's ENCOUNTERS — the contract's contact spec, rolled/derived
|
||||
* at the accept (the scene's _establishPatrol) from the data's rules:
|
||||
* the COUNT by its odds (patrolEncounterCounts — 1 on 20%, 2 on 20%, 3
|
||||
* on 60%), the POSITIONS on the route's waypoints (patrolEncounter
|
||||
* Positions — the max-spread subset, the single contact at the route's
|
||||
* middle), the ROSTER from the standing band (patrolEnemyRoster — the
|
||||
* player's level × the target's hops from home; today the low band's
|
||||
* established type only), and the WAVE shape (patrolEncounterWave — one
|
||||
* rolling wave of six, three at a time).
|
||||
*
|
||||
* Seeded on its own labelled fork (seed, 'sidequests',
|
||||
* <targetSystemId>, 'patrol', <jobId>, 'encounters') — the worldgen
|
||||
* contract: same seed + contract ⇒ the same encounters, across
|
||||
* re-accepts, scene restarts, and saves — and the result is FIXED on
|
||||
* the route record (SideQuestState.setRoute), so a later re-tune of
|
||||
* the data's odds or bands can't re-roll a standing contract's combat.
|
||||
*
|
||||
* @param {string|number} seed the galaxy's seed
|
||||
* @param {string} jobId the accepted contract's id (the fork's key)
|
||||
* @param {string} targetSystemId the route's system (the fork's key)
|
||||
* @param {Array<[number, number]>} waypoints the route's waypoints
|
||||
* @param {number} level the player's combat level at the accept
|
||||
* @param {number} hops the target system's jump-network depth from home
|
||||
* @returns {?{at:number[], cleared:number[], roster:string[], wave:object}} null when the feature is off or the route is empty
|
||||
*/
|
||||
export function derivePatrolEncounters(seed, jobId, targetSystemId, waypoints, level, hops) {
|
||||
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)));
|
||||
return {
|
||||
at: patrolEncounterPositions(waypoints, count),
|
||||
cleared: [],
|
||||
roster: patrolEnemyRoster(level, hops),
|
||||
wave: patrolEncounterWave(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact's WAVE SCRIPT (the shape for CombatSystem.startWaves —
|
||||
* js/combat/CombatSystem.js; the same `{ waves: [...] }` shape the named
|
||||
* scripts normalize to, js/combat/Waves.js): one wave of the contact's
|
||||
* roster (equal weights — a single-type band is a pure type + total),
|
||||
* the spec's wave shape (the data's: rolling six, three at a time). Pure
|
||||
* — the scene's _firePatrolContact hands it to startWaves.
|
||||
* @param {object} encounters the route record's encounters spec
|
||||
* @returns {{waves: object[]}}
|
||||
*/
|
||||
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 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])),
|
||||
total,
|
||||
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, 3)))),
|
||||
};
|
||||
if (kind === 'rolling') wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, 1)));
|
||||
return { waves: [wave] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A patrol's ROUTE is COMPLETE when every waypoint is LOGGED and every
|
||||
* contact CLEARED (the contract's combat is part of the job — the
|
||||
* reward's door opens on the last of the two). A route without
|
||||
* encounters (a save predating the contacts — old saves load) completes
|
||||
* on its waypoints alone.
|
||||
* @param {object} route the route record (sideQuestState.routeFor's shape)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function patrolRouteComplete(route) {
|
||||
if (!route || typeof route !== 'object') return false;
|
||||
const wpCount = Array.isArray(route.waypoints) ? route.waypoints.length : 0;
|
||||
const visited = Array.isArray(route.visited) ? route.visited.length : 0;
|
||||
if (wpCount === 0 || visited < wpCount) return false;
|
||||
const enc = route.encounters;
|
||||
if (!enc || typeof enc !== 'object') return true;
|
||||
const at = Array.isArray(enc.at) ? enc.at : [];
|
||||
if (at.length === 0) return true;
|
||||
const cleared = new Set(Array.isArray(enc.cleared) ? enc.cleared : []);
|
||||
return at.every((i) => cleared.has(i));
|
||||
}
|
||||
|
||||
/**
|
||||
* The system's LIVE postings (the board's list) — 3–4 by design
|
||||
* (sideQuestCountRange), drawn from the LIVE categories only
|
||||
|
|
@ -535,6 +839,53 @@ export function allSideQuestJobIds(records) {
|
|||
* not propagated; a route record only for a contract this ledger
|
||||
* holds (a route for a foreign id degrades away).
|
||||
*/
|
||||
/**
|
||||
* A route record's ENCOUNTERS spec, normalized (SideQuestState.setRoute's
|
||||
* pass): the at/cleared indices valid for the route (distinct, sorted;
|
||||
* cleared ⊆ at), the roster's type ids (combat.enemyTypes — unknown ids
|
||||
* dropped, the established type standing when the roster empties), the
|
||||
* wave shape finite. A malformed or missing spec degrades to null (an
|
||||
* encounter-free route — the waypoints still stand the contract), so a
|
||||
* hand-edited save or a future data shape can't break the patrol.
|
||||
* @param {*} raw the raw spec (derivePatrolEncounters' result, or a save's)
|
||||
* @param {number} wpCount the route's waypoint count
|
||||
* @returns {?{at:number[], cleared:number[], roster:string[], wave:object}}
|
||||
*/
|
||||
function normalizePatrolEncounters(raw, wpCount) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const at = [...new Set(
|
||||
(Array.isArray(raw.at) ? raw.at : []).filter(
|
||||
(i) => Number.isInteger(i) && i >= 0 && i < wpCount,
|
||||
),
|
||||
)].sort((a, b) => a - b);
|
||||
if (at.length === 0) return null; // no contact stands (the route's waypoints do)
|
||||
const cleared = [...new Set(
|
||||
(Array.isArray(raw.cleared) ? raw.cleared : []).filter(
|
||||
(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 wave = patrolEncounterWave();
|
||||
const w = raw.wave && typeof raw.wave === 'object' && !Array.isArray(raw.wave) ? raw.wave : {};
|
||||
const kind = w.kind === 'fixed' ? 'fixed' : wave.kind;
|
||||
const total = Math.max(1, Math.floor(numOr(w.total, wave.total)));
|
||||
const out = {
|
||||
at,
|
||||
cleared,
|
||||
roster: roster.length > 0 ? roster : ESTABLISHED_ENEMY_TYPES.slice(),
|
||||
wave: {
|
||||
kind,
|
||||
total,
|
||||
maxConcurrent: Math.min(total, Math.max(1, Math.floor(numOr(w.maxConcurrent, wave.maxConcurrent)))),
|
||||
},
|
||||
};
|
||||
if (kind === 'rolling') out.wave.replacePerKill = Math.max(0, Math.floor(numOr(w.replacePerKill, wave.replacePerKill)));
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SideQuestState {
|
||||
constructor() {
|
||||
this.accepted = new Set();
|
||||
|
|
@ -647,12 +998,15 @@ export class SideQuestState {
|
|||
// ------------------------------------------------- the patrol's ROUTE
|
||||
|
||||
/**
|
||||
* Stand a contract's ROUTE record (the patrol's waypoints — the scene's
|
||||
* buildPatrolRoute result, at the accept). Only a TAKEN contract can
|
||||
* carry a route (a route for a foreign id degrades away); the record
|
||||
* is validated + normalized (finite waypoint pairs, visited indices
|
||||
* in range, no duplicates).
|
||||
* @returns {boolean} true when it stands
|
||||
* Stand a contract's ROUTE record (the patrol's waypoints + the
|
||||
* contract's ENCOUNTERS — the scene's buildPatrolRoute +
|
||||
* derivePatrolEncounters results, at the accept). Only a TAKEN
|
||||
* contract can carry a route (a route for a foreign id degrades away);
|
||||
* the record is validated + normalized (finite waypoint pairs, visited
|
||||
* indices in range, no duplicates; the encounters' at/cleared indices
|
||||
* valid for the route, the roster the data's type ids, the wave shape
|
||||
* finite — a malformed spec degrades to an encounter-free route rather
|
||||
* than a broken one). @returns {boolean} true when it stands
|
||||
*/
|
||||
setRoute(id, route) {
|
||||
if (typeof id !== 'string' || id === '') return false;
|
||||
|
|
@ -671,10 +1025,28 @@ export class SideQuestState {
|
|||
credits: Number.isFinite(Number(route.credits)) ? Number(route.credits) : 0, // the contract's payout — fixed at the accept
|
||||
waypoints,
|
||||
visited,
|
||||
encounters: normalizePatrolEncounters(route.encounters, waypoints.length),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a contract's contact CLEARED (the wave run completed — the
|
||||
* scene's _firePatrolContact's onComplete). Idempotent — the run's
|
||||
* completion fires once; a re-fire (a mid-fight quit re-arms the
|
||||
* contact — the witness is the completion, not the attempt) marks it
|
||||
* again without a second effect. A cleared contact's index is always a
|
||||
* route waypoint index (validated). @returns {boolean} true when NEWLY cleared
|
||||
*/
|
||||
encounterCleared(id, idx) {
|
||||
const route = this.routes.get(id);
|
||||
if (!route || !route.encounters) return false;
|
||||
if (!Number.isInteger(idx) || !route.encounters.at.includes(idx)) return false;
|
||||
if (route.encounters.cleared.includes(idx)) return false;
|
||||
route.encounters.cleared.push(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A contract's route record (the scene's check-ins + the board's route
|
||||
* line read it).
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ import { MapWindow } from '../ui/MapWindow.js';
|
|||
import { QuestWindow } from '../ui/QuestWindow.js';
|
||||
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
|
||||
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
|
||||
import { SideQuestState, sideQuestPostingsFor, buildPatrolRoute } from '../quests/SideQuests.js';
|
||||
import { SideQuestState, sideQuestPostingsFor, buildPatrolRoute, derivePatrolEncounters, patrolEncounterScript, patrolRouteComplete } from '../quests/SideQuests.js';
|
||||
import { tutorialTarget } from '../quests/TutorialGuide.js';
|
||||
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
|
||||
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
|
||||
|
|
@ -4303,10 +4303,29 @@ export class GameScene extends Phaser.Scene {
|
|||
done: visited.includes(i),
|
||||
detail: visited.includes(i) ? 'LOGGED' : '',
|
||||
}));
|
||||
// The CONTACTS (the route's encounter waypoints — the contract's
|
||||
// combat, cleared by the wave runs: the checklist's rows are the
|
||||
// fight, the waypoints the flying). An old save's route (no
|
||||
// encounters) stands on its waypoints alone.
|
||||
const enc = route && route.encounters && Array.isArray(route.encounters.at) ? route.encounters : null;
|
||||
const clearedSet = new Set(enc && Array.isArray(enc.cleared) ? enc.cleared : []);
|
||||
if (enc) {
|
||||
enc.at.forEach((wpIdx, i) => {
|
||||
const cleared = clearedSet.has(wpIdx);
|
||||
checks.push({
|
||||
label: `Contact ${i + 1} — waypoint ${wpIdx + 1}`,
|
||||
done: cleared,
|
||||
detail: cleared ? 'CLEARED' : 'HOSTILES — CLEAR ON CONTACT',
|
||||
});
|
||||
});
|
||||
}
|
||||
const credits =
|
||||
route && Number.isFinite(route.credits) && route.credits > 0
|
||||
? route.credits
|
||||
: (posting.reward?.credits ?? 0);
|
||||
const complete =
|
||||
waypoints.length > 0 && visited.length === waypoints.length &&
|
||||
(!enc || enc.at.every((i) => clearedSet.has(i)));
|
||||
return {
|
||||
id,
|
||||
category: 'side',
|
||||
|
|
@ -4317,7 +4336,10 @@ export class GameScene extends Phaser.Scene {
|
|||
active: st.isActive(id),
|
||||
checks,
|
||||
reward: `${credits} CR`,
|
||||
complete: waypoints.length > 0 && visited.length === waypoints.length,
|
||||
// The ledger's mark is the claim's gate (the scene's claim path); the
|
||||
// derived route state stands in for it in the transient frame between
|
||||
// the last contact's clearance and the mark's landing.
|
||||
complete: st.isCompleted(id) || complete,
|
||||
claimed: st.isClaimed(id),
|
||||
progress: `${visited.length}/${waypoints.length}`,
|
||||
};
|
||||
|
|
@ -4730,6 +4752,20 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
const route = buildPatrolRoute(this.galaxy?.seed ?? 0, jobId, targetId, region, anchors);
|
||||
route.credits = posting.reward?.credits ?? 0; // the contract's payout (fixed at the accept)
|
||||
// The contract's ENCOUNTERS — its hostile contacts (data/sidequests.json
|
||||
// → patrol.encounters): the count by the data's odds (1 on 20% of
|
||||
// patrols, 2 on 20%, 3 on 60%), their waypoints SPREAD across the
|
||||
// route, their roster the standing band's enemy types (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), their fight one
|
||||
// rolling wave of six, three at a time. Rolled seeded at the accept
|
||||
// (the labelled fork) and fixed on the route record — a signed
|
||||
// contract is a signed contract: a later re-tune of the data's odds
|
||||
// or bands can't re-roll a standing contract's combat.
|
||||
const level = Number(this.combat?.xp?.level) || 0;
|
||||
const depth = this.galaxy?.jumpNetwork?.depth;
|
||||
const hops = depth instanceof Map && Number.isFinite(depth.get(targetId)) ? depth.get(targetId) : 0; // the target's tree depth from home (0 = home)
|
||||
route.encounters = derivePatrolEncounters(this.galaxy?.seed ?? 0, jobId, targetId, route.waypoints, level, hops);
|
||||
this.sideQuestState.setRoute(jobId, route);
|
||||
}
|
||||
|
||||
|
|
@ -4787,15 +4823,22 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
/**
|
||||
* The window's ROUTE readout seam (the detail's "ROUTE — 2/4 WAYPOINTS
|
||||
* LOGGED" line): the accepted patrol's checkpoint count. Null when the
|
||||
* LOGGED" line + the contacts' line — "CONTACTS — 1/2 CLEARED"): the
|
||||
* accepted patrol's checkpoint + contact counts. Null when the
|
||||
* contract carries no route (the other categories) or its route never
|
||||
* stood.
|
||||
* @returns {{visited:number, total:number}|null}
|
||||
* stood; `contacts` null when the route carries no encounters (old
|
||||
* saves load).
|
||||
* @returns {?{visited:number, total:number, contacts:?{cleared:number, total:number}}}
|
||||
*/
|
||||
patrolProgress(jobId) {
|
||||
const route = this.sideQuestState?.routeFor(jobId);
|
||||
if (!route || !Array.isArray(route.waypoints) || route.waypoints.length === 0) return null;
|
||||
return { visited: route.visited.length, total: route.waypoints.length };
|
||||
const enc = route.encounters && Array.isArray(route.encounters.at) && route.encounters.at.length > 0 ? route.encounters : null;
|
||||
return {
|
||||
visited: route.visited.length,
|
||||
total: route.waypoints.length,
|
||||
contacts: enc ? { cleared: (Array.isArray(enc.cleared) ? enc.cleared : []).length, total: enc.at.length } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4841,14 +4884,18 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
/**
|
||||
* The active patrol's CHECK-INS (per frame, update): the ship within
|
||||
* visitRadius (data/sidequests.json → patrol.visitRadius) of an
|
||||
* unvisited waypoint logs it — any order, the route is a set of
|
||||
* points (each check-in toasts + pings in the console's voice);
|
||||
* every waypoint logged stands the contract COMPLETE — the board's
|
||||
* CLAIM REWARD door opens, and the reward stays unclaimed until the
|
||||
* player takes it (the patrol's completion is separate from its
|
||||
* payout, like the story quests' claim).
|
||||
* The active patrol's CHECK-INS + CONTACTS (per frame, update): the
|
||||
* ship within visitRadius (data/sidequests.json → patrol.visitRadius)
|
||||
* of an unvisited waypoint logs it — any order, the route is a set of
|
||||
* points (each check-in toasts + pings in the console's voice); at a
|
||||
* CONTACT's waypoint (one of the route's encounter waypoints) the
|
||||
* contact's wave fires (_firePatrolContact — one rolling wave of the
|
||||
* band's roster, six, three at a time — cleared marked on the run's
|
||||
* completion); every waypoint logged AND every contact cleared stands
|
||||
* the contract COMPLETE — the board's CLAIM REWARD door opens, and
|
||||
* the reward stays unclaimed until the player takes it (the patrol's
|
||||
* completion is separate from its payout, like the story quests'
|
||||
* claim).
|
||||
*/
|
||||
updatePatrol() {
|
||||
const st = this.sideQuestState;
|
||||
|
|
@ -4873,26 +4920,95 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (route.visited.length === wps.length && !st.isCompleted(id)) {
|
||||
this._firePatrolContact(st, id, route, radius); // the contact's wave, at its waypoint
|
||||
if (patrolRouteComplete(route) && !st.isCompleted(id)) {
|
||||
st.complete(id); // the done contract drops out of the ACTIVE slot (reconcileActive stands the next top one)
|
||||
const t = config.get('sidequests.ui.patrolCompleteToast', {});
|
||||
this.playSfx('construct'); // the route's done tick
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'PATROL ROUTE COMPLETE — REPORT TO THE JOB BOARD FOR YOUR REWARD'),
|
||||
String(t.text ?? 'PATROL ROUTE COMPLETE — ITS REWARD IS READY TO CLAIM'),
|
||||
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active patrol's CONTACTS (data/sidequests.json →
|
||||
* patrol.encounters — the contract's hostile encounters): with the
|
||||
* ship at an uncleared contact's waypoint and no wave run in flight,
|
||||
* the contact's wave fires — one rolling wave of the standing band's
|
||||
* roster (the player's level × the target's hops from home; today the
|
||||
* low band, levels 1–5 at 1–2 hops from home, the ESTABLISHED type
|
||||
* only), the data's shape (six hostiles, three at a time, each kill
|
||||
* replaced — patrolEncounterScript → CombatSystem.startWaves — the
|
||||
* same wave machinery as the 'Pirate Problems' ambush). The witness
|
||||
* is the run's COMPLETION (the cleared mark lands on onComplete — the
|
||||
* combatDone precedent: a mid-fight quit re-arms the contact on
|
||||
* return, and the route's completion — patrolRouteComplete — waits
|
||||
* for it, so the reward can't outrun the fight). One fight at a time
|
||||
* (an in-flight run — lead/gap/live — holds the fire; the ship at the
|
||||
* waypoint on the next idle frame takes its wave then). No-op without
|
||||
* a combat system or encounters on the route (a save predating the
|
||||
* contacts — old saves load).
|
||||
* @param {SideQuestState} st the side ledger
|
||||
* @param {string} id the active contract's id
|
||||
* @param {object} route its route record (the route's system = here)
|
||||
* @param {number} radius the check-in's range (the contact fires at its waypoint)
|
||||
*/
|
||||
_firePatrolContact(st, id, route, radius) {
|
||||
const enc = route.encounters;
|
||||
if (!enc || !Array.isArray(enc.at) || enc.at.length === 0) return; // no contacts (old saves load)
|
||||
if (!this.combat || typeof this.combat.startWaves !== 'function') return; // no combat system — the route stands on its waypoints (degrades)
|
||||
const run = this.combat.waveRun;
|
||||
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // one fight at a time
|
||||
const cleared = new Set(Array.isArray(enc.cleared) ? enc.cleared : []);
|
||||
const idx = enc.at.find((i) => {
|
||||
if (cleared.has(i)) return false;
|
||||
const wp = route.waypoints[i];
|
||||
if (!Array.isArray(wp)) return false;
|
||||
return Math.hypot(this.ship.x - wp[0], this.ship.y - wp[1]) <= radius;
|
||||
});
|
||||
if (idx == null) return; // not at an uncleared contact's waypoint (yet)
|
||||
const script = patrolEncounterScript(enc);
|
||||
const total = script.waves[0]?.total ?? 6;
|
||||
const t = config.get('sidequests.ui.patrolContactToast', {});
|
||||
this.playSfx('discovery'); // the contact's chime (the wave's own combat comms ride the bar)
|
||||
this.consoleToast(
|
||||
String(t.text ?? 'HOSTILE CONTACT — {n} ENEMIES INBOUND').replace('{n}', String(total)),
|
||||
{ glyph: t.glyph ?? '⚔', glyphColor: t.color ? toCss(t.color) : undefined },
|
||||
);
|
||||
this.combat.startWaves(script, {
|
||||
onComplete: () => {
|
||||
// The witness is the COMPLETION (the combatDone precedent): only
|
||||
// a finished run clears the contact — a quit mid-fight re-arms it
|
||||
// on return, and the route's completion waits for it.
|
||||
if (st.encounterCleared(id, idx)) {
|
||||
const c = route.encounters;
|
||||
const done = (Array.isArray(c?.cleared) ? c.cleared : []).length;
|
||||
const n = (Array.isArray(c?.at) ? c.at : []).length;
|
||||
const tc = config.get('sidequests.ui.patrolContactClearedToast', {});
|
||||
this.playSfx('construct'); // the cleared tick
|
||||
this.consoleToast(
|
||||
String(tc.text ?? 'CONTACT CLEARED — {i} OF {n}').replace('{i}', String(done)).replace('{n}', String(n)),
|
||||
{ glyph: tc.glyph ?? '✓', glyphColor: tc.color ? toCss(tc.color) : undefined },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The active patrol's IN-WORLD beacons (per frame, update): the route
|
||||
* waypoints in the route's system — a diamond + ring + soft glow per
|
||||
* point (the data's patrol.marker colors): the yellow PULSE while the
|
||||
* waypoint stands, the dim done mark once visited. Only the ACTIVE
|
||||
* contract's route, only in ITS system (the other contracts' routes
|
||||
* sleep in the ledger; the arrow points the player to their system
|
||||
* first). Clear of the world's solids by construction (the route's
|
||||
* keep-out), so no collision glow is owed.
|
||||
* waypoint stands, the dim done mark once visited — and the RED pulse
|
||||
* on the CONTACT's waypoints (the route's encounter waypoints, the
|
||||
* contract's combat) while their contact stands, the done mark once
|
||||
* the contact is cleared. Only the ACTIVE contract's route, only in
|
||||
* ITS system (the other contracts' routes sleep in the ledger; the
|
||||
* arrow points the player to their system first). Clear of the
|
||||
* world's solids by construction (the route's keep-out), so no
|
||||
* collision glow is owed.
|
||||
*/
|
||||
_drawPatrolMarkers(time) {
|
||||
const g = this.patrolG;
|
||||
|
|
@ -4905,10 +5021,17 @@ export class GameScene extends Phaser.Scene {
|
|||
if (!route || route.targetSystemId !== this.systemRecord.id) return;
|
||||
const color = toColor(config.get('sidequests.patrol.marker.color', '#ffd60a'), 0xffd60a);
|
||||
const doneColor = toColor(config.get('sidequests.patrol.marker.doneColor', '#67e863'), 0x67e863);
|
||||
const contactColor = toColor(config.get('sidequests.patrol.marker.contactColor', '#ff5c77'), 0xff5c77);
|
||||
// The CONTACT beacons — the route's encounter waypoints (the contract's
|
||||
// combat): the RED pulse while a contact stands (cleared → the done
|
||||
// mark, like any visited waypoint). A route without encounters (old
|
||||
// saves) is all ordinary waypoints.
|
||||
const enc = route.encounters && Array.isArray(route.encounters.at) ? route.encounters : null;
|
||||
const contactIdx = new Set(enc ? enc.at : []);
|
||||
for (let i = 0; i < route.waypoints.length; i++) {
|
||||
const [x, y] = route.waypoints[i];
|
||||
const done = route.visited.includes(i);
|
||||
const c = done ? doneColor : color;
|
||||
const c = done ? doneColor : (contactIdx.has(i) ? contactColor : color);
|
||||
const pulse = done ? 0 : 0.5 + 0.5 * Math.sin(time * 0.004 + i * 1.7); // each point on its own phase
|
||||
const s = 7 + (done ? 0 : 3 * pulse); // the diamond's half-diagonal
|
||||
g.fillStyle(c, 0.05 + (done ? 0 : 0.05 * pulse)); // the soft glow
|
||||
|
|
|
|||
|
|
@ -724,9 +724,12 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
.setScrollFactor(0);
|
||||
// The contract's LINES (the patrol's shape — the other categories',
|
||||
// when they land, add their own): the DESTINATION (a patrol whose
|
||||
// route stands in ANOTHER system — the posting names the target)
|
||||
// and the ROUTE progress (the accepted patrol's checkpoints — the
|
||||
// ledger's truth off the scene's seam).
|
||||
// route stands in ANOTHER system — the posting names the target),
|
||||
// the ROUTE progress (the accepted patrol's checkpoints — the
|
||||
// ledger's truth off the scene's seam), and the CONTACTS (the
|
||||
// route's encounter waypoints — the combat the patrol owes, cleared
|
||||
// by the wave runs — old saves' routes carry none and the line stays
|
||||
// down).
|
||||
let lineY = py + 52;
|
||||
if (sel.targetSystemName && sel.targetSystemName !== sel.systemName) {
|
||||
const dest = s
|
||||
|
|
@ -762,6 +765,27 @@ export class JobBoardWindow extends Phaser.GameObjects.Container {
|
|||
this.detailSection.content.push(route);
|
||||
lineY += 16;
|
||||
}
|
||||
if (prog && prog.contacts && Number.isFinite(prog.contacts.cleared) && prog.contacts.total > 0) {
|
||||
const contacts = s
|
||||
.text(
|
||||
rightX + 14,
|
||||
lineY,
|
||||
String(ui.contactProgress ?? 'CONTACTS — {cleared}/{total} CLEARED')
|
||||
.replace('{cleared}', String(prog.contacts.cleared))
|
||||
.replace('{total}', String(prog.contacts.total)),
|
||||
{
|
||||
fontFamily: BODY,
|
||||
fontSize: '9.5px',
|
||||
color: toCss(prog.contacts.cleared >= prog.contacts.total ? this._activeColor() : 0xff5c77),
|
||||
letterSpacing: 2,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
)
|
||||
.setScrollFactor(0);
|
||||
this.add(contacts);
|
||||
this.detailSection.content.push(contacts);
|
||||
lineY += 16;
|
||||
}
|
||||
const body = s
|
||||
.text(rightX + 14, lineY, fitText(String(sel.description ?? ''), Math.floor(wrapW / 5.4), 5), {
|
||||
fontFamily: BODY,
|
||||
|
|
|
|||
Loading…
Reference in New Issue