From d3fadcf6a59f27c2f0e61ec4d943d8f8c0e16552 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 18 Sep 2026 11:12:42 -0600 Subject: [PATCH] Add hostile contact encounters to patrol side quests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- data/sidequests.json | 47 ++++- dev/sidequests.test.mjs | 264 ++++++++++++++++++++++++++- js/quests/SideQuests.js | 384 +++++++++++++++++++++++++++++++++++++++- js/scenes/GameScene.js | 167 ++++++++++++++--- js/ui/JobBoardWindow.js | 30 +++- 5 files changed, 858 insertions(+), 34 deletions(-) diff --git a/data/sidequests.json b/data/sidequests.json index 0b7fd70..2562fdc 100644 --- a/data/sidequests.json +++ b/data/sidequests.json @@ -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', , 'patrol', , '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", diff --git a/dev/sidequests.test.mjs b/dev/sidequests.test.mjs index 7336a63..7940e66 100644 --- a/dev/sidequests.test.mjs +++ b/dev/sidequests.test.mjs @@ -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'); diff --git a/js/quests/SideQuests.js b/js/quests/SideQuests.js index a9db4fc..7aa098c 100644 --- a/js/quests/SideQuests.js +++ b/js/quests/SideQuests.js @@ -45,6 +45,23 @@ * must always be completable). Seeded on (seed, 'sidequests', * , 'patrol', , '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', + * , 'patrol', , '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', + * , 'patrol', , '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). diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index b1195eb..11e6fab 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 diff --git a/js/ui/JobBoardWindow.js b/js/ui/JobBoardWindow.js index 0b0cff6..4002857 100644 --- a/js/ui/JobBoardWindow.js +++ b/js/ui/JobBoardWindow.js @@ -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,