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:
Brian Fertig 2026-09-18 11:12:42 -06:00
parent b461bc98af
commit d3fadcf6a5
5 changed files with 858 additions and 34 deletions

View File

@ -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",

View File

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

View File

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

View File

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

View File

@ -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,