// dev/sidequests.test.mjs — the JOB BOARD’s model, verified headless // (node dev/sidequests.test.mjs — no browser, no Phaser render). // // What this pins (the board’s contract — data/sidequests.json): // - the CATEGORY GATE — the four types (patrol / delivery / bounty / // escort) are all accounted for; PATROL is the first LIVE category // (its rules land with this pass) while the other three stay // OFFLINE, and a category's jobs appear on the board ONLY when its // `enabled` flag flips (flip the rest → the per-world count // spreads; flip the feature's master switch → an empty board). // - DETERMINISM — the same contract stands on the same WORLD across // calls (seed + record + world → the same postings — visits, scene // restarts, saves), a different seed draws its own board, and // different worlds (another planet in the same system, another // system) draw different boards (no global call-order dependence). // - The per-world COUNT (1–3 by design, within the data's range), // unique ids, and the slots spreading across the live types. // - The CONTEXT voice — the NEUTRAL pools are the pirate-safety voice // (the data’s contract: 2 of the 3 variants per category name the // pirates/raiders), the FACTION pools are the push-the-other- // factions-out voice (every variant carries {enemies}), and the // placeholders ({system}/{faction}/{issuer}/{enemies}) resolve to // the system’s values (no brace pair ever ships to the player). // - The REWARDS — inside the category’s band (scaled by the system’s // distance d), in 5 CR steps, floored at 5 CR. // - The PATROL’s TARGET rule — the posting system by default; a // NEIGHBOR jumpgate-adjacent system (a different non-neutral // faction, at least neighborMinD from home) under the seeded // neighborChance; the posting’s fields carry the target. // - The PATROL’s ROUTE — buildPatrolRoute’s deterministic waypoints: // inside the region (inset), clear of the anchors, separated, the // count in the data’s range, and a completable fallback (an empty // region still stands a route — a patrol must always finish). // - The LEDGER (SideQuestState) — the accepted / active / completed / // claimed marks: the idempotent accept/complete/claim, the ONE // ACTIVE slot (the top accepted stands; a done/claimed contract // drops out), the ROUTE records (the patrol’s waypoints + the // visited marks), the save round-trip (toJSON/fromJSON), and the // malformed-record degradation (a save predating the feature loads // empty; a foreign id drops against the save’s galaxy job space). // // The model is pure ES (js/quests/SideQuests.js); the data-driven // contract lives in data/sidequests.json. The scene’s seams // (GameScene.sideQuestPostings / acceptSideQuest / setActiveSideQuest / // claimSideQuest) and the window’s rendering run over the same // functions this pins — the window’s headless pass stands in // dev/jobboard.test.mjs. process.env.NODE_ENV = 'dev'; import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import assert from 'node:assert/strict'; import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only — the scene’s seams) const __dirname = dirname(fileURLToPath(import.meta.url)); // --- Load the real config (data/*.json) into the config singleton ------ const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); const fs = await import('node:fs'); const dataDir = join(__dirname, '../data'); const configData = {}; for (const f of fs.readdirSync(dataDir)) { if (!f.endsWith('.json') || f === 'manifest.json') continue; configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); } config.init(configData); const DATA = configData; const { SIDE_QUEST_CATEGORIES, sideQuestsEnabled, sideQuestCategoryMeta, enabledSideQuestCategories, sideQuestCountRange, sideQuestPostingsFor, parseSideQuestId, resolveSideQuestText, rollSideQuestReward, allSideQuestJobIds, SideQuestState, patrolTargetSystem, buildPatrolRoute, } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href); const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href); const SEED = 20491; // The same hand records the other dev tests use — one neutral system // (the pirate-voice system) and one per faction (the push-out-voice // systems), each with a different home→far diagonal d (the reward scale). const REC_NEUTRAL = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40, zone: 'mid', type: 'void', capital: false }; const REC_ZENITH = { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 60, zone: 'mid', type: 'void', capital: false }; const REC_APEX = { id: 'sys-apex', name: 'Apex Threshold', faction: 'apex', d: 80, zone: 'far', type: 'void', capital: false }; const RECORDS = [ { id: 'home', name: 'Cradle', faction: null, d: 0, zone: 'home', type: 'void', capital: true }, REC_NEUTRAL, REC_ZENITH, REC_APEX, ]; // Flip a category’s gate by mutating the live config data (the data file // is the source of truth in the game; this test exercises the SAME // config.get path the model reads). const setEnabled = (map) => { for (const cat of SIDE_QUEST_CATEGORIES) { config.data.sidequests.categories[cat].enabled = !!map[cat]; } }; // The data’s default gates (PATROL + BOUNTY HUNT + ESCORT live — the three // categories whose rules landed; DELIVERY stays OFFLINE until its rules do). const restoreDefaultGates = () => setEnabled({ patrol: true, bounty: true, escort: true }); // =========================================================================== console.log('== the category gate — patrol + bounty hunt + escort LIVE by default, delivery OFFLINE =='); { assert.deepEqual(SIDE_QUEST_CATEGORIES, ['patrol', 'delivery', 'bounty', 'escort'], 'the four types, canonical slot order'); assert.equal(sideQuestsEnabled(), true, 'the feature’s master switch is ON (the data’s enabled flag)'); assert.deepEqual(enabledSideQuestCategories(), ['patrol', 'bounty', 'escort'], 'PATROL, the BOUNTY HUNT, and ESCORT are the live categories (the gates that landed)'); assert.deepEqual(enabledSideQuestCategories('faction'), ['patrol'], 'a FACTION board never draws a hunt or an escort (their contexts are ["neutral"])'); assert.equal(sideQuestCategoryMeta('huntr'), null, 'an unknown category → null (no fabrication)'); for (const cat of SIDE_QUEST_CATEGORIES) { const meta = sideQuestCategoryMeta(cat); assert.equal(meta.id, cat, `${cat}: its meta stands`); assert.equal(meta.enabled, cat === 'patrol' || cat === 'bounty' || cat === 'escort', `${cat}: live iff its gate says so (patrol + bounty + escort live, delivery OFFLINE by default)`); assert.ok(meta.label.length > 0, `${cat}: a label for the board’s rows`); assert.match(meta.accent, /^#[0-9a-f]{6}$/i, `${cat}: a row-chip accent`); const band = DATA.sidequests.rewards[cat]; assert.ok(Array.isArray(band) && band.length === 2 && band.every((n) => Number.isFinite(n)), `${cat}: a reward band in the data`); for (const ctx of ['neutral', 'faction']) { const pool = DATA.sidequests.descriptions[ctx][cat]; if (cat === 'bounty' && ctx === 'faction') { // The documented stub: the hunt’s dossiers are the neutral pirate-hunt // voice, so the faction wanted-list stays EMPTY (and its category’s // contexts keep it off a faction board regardless). assert.deepEqual(pool, [], `bounty.faction: the empty stub the data documents`); continue; } assert.ok(Array.isArray(pool) && pool.length >= 3, `${cat}.${ctx}: a variant pool of 3+ (the boards don’t read as one repeated ad)`); for (const v of pool) { assert.ok(typeof v.title === 'string' && v.title.length > 0, `${cat}.${ctx}: a title per variant`); assert.ok(typeof v.body === 'string' && v.body.length > 0, `${cat}.${ctx}: the contract’s body per variant`); } } } // The DEFAULT board stands live with the live categories’ postings (patrol + // bounty + escort — delivery stays OFFLINE). const def = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1'); const [lo, hi] = sideQuestCountRange(); assert.ok(def.length >= lo && def.length <= hi, `the default board: ${def.length} postings within [${lo},${hi}]`); assert.ok(def.every((p) => p.category === 'patrol' || p.category === 'bounty' || p.category === 'escort'), 'the default board posts only the live types'); } // =========================================================================== console.log('== the gate flips — one live type → only it posts; all live → the spread =='); { setEnabled({ patrol: true }); // ONE live type, so the shape asserts below are patrol alone { assert.deepEqual(enabledSideQuestCategories(), ['patrol'], 'one live category'); const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1'); const [lo, hi] = sideQuestCountRange(); assert.ok(lo >= 1 && hi >= lo, 'the per-world count range stands (1–3 by design)'); assert.ok(posts.length >= lo && posts.length <= hi, `${posts.length} postings within [${lo},${hi}]`); assert.ok(posts.every((p) => p.category === 'patrol'), 'only the live type posts'); for (const p of posts) { assert.equal(p.systemId, 'sys-free', 'the posting’s system id'); assert.equal(p.worldKey, 'p1', 'the posting’s world key (the board’s world)'); assert.equal(p.systemName, 'Free Reach', 'the posting’s system name'); assert.equal(p.faction, null, 'a neutral system’s postings carry no faction'); assert.match(p.id, /^sq-sys-free-p1-patrol-\d+$/, 'a stable, save-safe id (the ledger’s key)'); assert.equal(p.label, 'PATROL', 'the row’s label (the data’s category label)'); assert.match(p.accent, /^#[0-9a-f]{6}$/i, 'the row’s accent'); assert.ok(p.title.length > 0, 'the title'); assert.ok(p.issuer.length > 0, 'the issuer (the neutral pool’s board name)'); assert.ok(p.description.length > 20, 'the contract’s body'); assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'no placeholder ships to the player'); assert.equal(p.reward.credits % 5, 0, 'credits in 5 CR steps'); assert.ok(p.reward.credits >= 5, 'the 5 CR floor'); assert.deepEqual(p.objective, { type: 'patrol' }, 'the completion socket stands typed (the type’s rules fill it in)'); } // The id's split (parseSideQuestId — the scene’s posting lookups + // the claim's reward read parse the ledger’s id back to its board + // slot, then regenerate the board): planet AND station worlds stand, // and the OLD per-system shape (no world key) stands no world — null. assert.deepEqual(parseSideQuestId('sq-sys-free-p1-patrol-2'), { systemId: 'sys-free', worldKey: 'p1', category: 'patrol', slot: 2 }, 'a planet board’s id splits back apart'); assert.deepEqual(parseSideQuestId('sq-sys-zenith-s3-bounty-0'), { systemId: 'sys-zenith', worldKey: 's3', category: 'bounty', slot: 0 }, 'a station board’s id splits back apart'); assert.equal(parseSideQuestId('sq-sys-zenith-patrol-0'), null, 'an OLD per-system id (no world key) is not a live id — null'); assert.equal(parseSideQuestId('sq-sys-zenith-p1-noclue-0'), null, 'a category the board never posts is not a live id — null'); assert.equal(parseSideQuestId('nope'), null, 'a non-posting id → null'); assert.equal(parseSideQuestId(42), null, 'a non-string → null'); } setEnabled({ patrol: true, delivery: true, bounty: true, escort: true }); try { const [lo, hi] = sideQuestCountRange(); for (const rec of RECORDS) { const posts = sideQuestPostingsFor(SEED, rec, 'p1'); assert.ok(posts.length >= lo && posts.length <= hi, `${rec.id}: ${posts.length} postings within [${lo},${hi}]`); const ids = new Set(posts.map((p) => p.id)); assert.equal(ids.size, posts.length, `${rec.id}: the ids are unique (category + slot)`); // The slots can't spread past the board's LIVE categories for that // context (a faction board never draws a neutral-only type — bounty // and escort stand off it), nor past the posting count itself. const ctx = rec.faction ? 'faction' : 'neutral'; const liveInCtx = enabledSideQuestCategories(ctx).length; assert.ok(new Set(posts.map((p) => p.category)).size >= Math.min(liveInCtx, posts.length), `${rec.id}: the slots spread across the live types for the board's context (${liveInCtx} live in ${ctx})`); } // DETERMINISM — same seed + record + world → the same board across // calls (visits / scene restarts / saves); a different seed draws its // own; a different WORLD (another planet in the same system) stands // its own board too. const a = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'); const b = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'); assert.deepEqual(b, a, 'the same contract stands across calls'); assert.notDeepEqual(sideQuestPostingsFor(SEED + 7, REC_ZENITH, 'p1'), a, 'a different seed → a different board'); // …and the worlds don't share one ad (the variant pools + the // per-posting draws stand per world). assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_ZENITH, 'p2'), a, 'a different WORLD in the same system draws its own board'); assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX, 'p1'), a, 'a different system draws its own board'); // NEUTRAL voice — the pirate-safety pools (the data’s contract: the // majority of each neutral pool names the pirates/raiders). for (const cat of SIDE_QUEST_CATEGORIES) { const pool = DATA.sidequests.descriptions.neutral[cat]; const themed = pool.filter((v) => /pirate|raider/i.test(v.title + v.body)).length; assert.ok(themed >= 2, `neutral ${cat}: ${themed}/${pool.length} variants carry the pirate-safety voice`); } const neutralPosts = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1'); for (const p of neutralPosts) { assert.equal(p.faction, null, 'the neutral postings carry no faction'); assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'the neutral copy is placeholder-free'); } // FACTION voice — pushing the OTHER factions out (every faction // variant carries {enemies}; the resolved copy names both the // system’s own faction and at least one of the other two). for (const cat of SIDE_QUEST_CATEGORIES) { const pool = DATA.sidequests.descriptions.faction[cat]; assert.ok(pool.every((v) => v.body.includes('{enemies}')), `faction ${cat}: every variant carries {enemies}`); } const z = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'); for (const p of z) { assert.equal(p.faction, 'zenith', 'the posting carries its system’s faction'); assert.match(p.issuer, /Zenith/, 'the faction’s ISSUER names the faction (the data’s factions table)'); const others = (p.description.match(/Apex|MindWeave/g) ?? []).length; assert.ok(others >= 1, 'the other factions stand in the push-out copy'); assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}/.test(p.title + p.issuer + p.description), 'the faction copy is placeholder-free'); } // The REWARDS — inside the band (scaled by d), in 5 CR steps. for (const cat of SIDE_QUEST_CATEGORIES) { const [b0, b1] = DATA.sidequests.rewards[cat]; const scale = 1 + 0.5 * DATA.sidequests.rewards.distanceScale; const r = Rng.derive(SEED, 'bandtest', cat); const { credits } = rollSideQuestReward(cat, 0.5, r); assert.ok(credits >= 5, `${cat}: the 5 CR floor holds (d=0.5)`); assert.ok(credits <= Math.ceil((b1 * scale) / 5) * 5, `${cat}: the scaled ceiling holds (d=0.5)`); assert.ok(b0 <= b1, `${cat}: the band is ordered`); } // The FEATURE’s master switch — off → the board empties regardless // of the category flags (the kill-switch contract). config.data.sidequests.enabled = false; try { assert.deepEqual(sideQuestPostingsFor(SEED, REC_ZENITH, 'p1'), [], 'sidequests.enabled:false → no postings (the kill-switch)'); } finally { config.data.sidequests.enabled = true; } } finally { restoreDefaultGates(); } } // =========================================================================== console.log('== the placeholder resolution (the pool’s {…} keys → the context) =='); { const ctx = { context: 'faction', system: 'Zenith Verge', faction: 'Zenith', issuer: 'Zenith Concord', enemies: 'Apex and MindWeave' }; assert.equal(resolveSideQuestText('Holds {system} against the {enemies}.', ctx), 'Holds Zenith Verge against the Apex and MindWeave.', 'every placeholder resolves to the context'); assert.equal(resolveSideQuestText('{issuer} pays.', { context: 'faction', system: 'X', faction: 'Zenith', issuer: 'Zenith Concord', enemies: 'A and B' }), 'Zenith Concord pays.', 'the issuer resolves to the faction’s board name'); assert.equal(resolveSideQuestText('{faction} keeps {faction}.', { context: 'neutral', system: 'X', faction: '', issuer: '', enemies: '' }), ' keeps .', 'a value-less placeholder blanks (never ships a brace)'); assert.equal(resolveSideQuestText('plain', null), 'plain', 'a null context is safe'); assert.equal(resolveSideQuestText('', {}), '', 'empty in, empty out'); } // =========================================================================== console.log('== the patrol’s TARGET rule (the posting’s system, or a NEIGHBOR’s) =='); { // A stand-in GALAXY (the model’s interface: byId Map + jumpGatesFor): // sys-zenith (zenith, d=0.6) holds a gate to sys-apex (apex) — the // push-out neighbor — and a gate to sys-neutral (neutral: never a // patrol target) and to itself (same faction: never a target). const galaxy = { byId: new Map([ [REC_ZENITH.id, { ...REC_ZENITH, d: 0.6 }], [REC_APEX.id, { ...REC_APEX, d: 0.8 }], ['sys-neutral2', { id: 'sys-neutral2', name: 'Drift Line', faction: null, d: 0.7, zone: 'far', type: 'void', capital: false }], ]), jumpGatesFor: (id) => { const links = { [REC_ZENITH.id]: [REC_APEX.id, 'sys-neutral2', REC_ZENITH.id], }; return (links[id] ?? []).map((to) => galaxy.byId.get(to)).filter(Boolean); }, }; const rec = galaxy.byId.get(REC_ZENITH.id); assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 1), null), null, 'no galaxy → null (the posting’s own system stands — the fallback)'); assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 2), { byId: new Map(), jumpGatesFor: () => [] }), null, 'no links → null (the posting’s own system stands)'); const neutral = { id: 'sys-neutral2', name: 'Drift Line', faction: null, d: 0.7 }; assert.equal(patrolTargetSystem(neutral, Rng.derive(SEED, 't', 3), galaxy), null, 'a NEUTRAL system never pushes (the pirate-voice posts stand local)'); const close = { id: 'sys-close', name: 'Close Post', faction: 'zenith', d: 0.1 }; assert.equal(patrolTargetSystem(close, Rng.derive(SEED, 't', 4), galaxy), null, 'too close to home (< neighborMinD) → the posting’s own system stands'); // A far FACTION system with a different-faction gate: the seeded draw // (neighborChance) splits between the two — over many seeds BOTH // outcomes must stand (the draw is real, not pinned), and the // neighbor, when chosen, is always a DIFFERENT non-neutral faction. const picks = new Set(); for (let i = 0; i < 64; i++) { const out = patrolTargetSystem(rec, Rng.derive(SEED, 't', 100 + i), galaxy); assert.ok(out === null || out.id === REC_APEX.id, `seed ${100 + i}: the target is null (the posting’s system) or the different-faction neighbor`); if (out) picks.add(out.id); else picks.add(rec.id); } assert.deepEqual([...picks].sort(), [REC_APEX.id, rec.id].sort(), 'the neighbor draw is live: both the posting’s system and the neighbor stand across seeds'); // The POSTING’s fields carry the target (the board’s DESTINATION line // + the scene’s route establishment read them). const posts = sideQuestPostingsFor(SEED, rec, 'p1', galaxy).filter((p) => p.category === 'patrol'); assert.ok(posts.length >= 1, 'a faction system posts patrols'); for (const p of posts) { assert.equal(typeof p.targetSystemId, 'string', 'the patrol’s target system id stands'); assert.ok(p.targetSystemId === rec.id || p.targetSystemId === REC_APEX.id, 'the target is a real system (the posting’s own or the neighbor’s)'); assert.equal(typeof p.targetSystemName, 'string', 'the patrol’s target system name stands'); assert.equal(p.targetSystemName, galaxy.byId.get(p.targetSystemId).name, 'the target name resolves to the target id'); } // The non-patrol categories (the stubs) carry no target fields. setEnabled({ patrol: true, delivery: true }); try { const all = sideQuestPostingsFor(SEED, rec, 'p1', galaxy); const del = all.find((p) => p.category === 'delivery'); assert.ok(del, 'a delivery stub stands alongside the patrols'); assert.equal(del.targetSystemId, undefined, 'the stub categories carry no target (their rules land later)'); } finally { restoreDefaultGates(); } } // =========================================================================== console.log('== the patrol’s ROUTE — buildPatrolRoute (the deterministic waypoints) =='); { const [lo, hi] = [ Number(config.get('sidequests.patrol.waypoints', [3, 5])[0]), Number(config.get('sidequests.patrol.waypoints', [3, 5])[1]), ]; const inset = Number(config.get('sidequests.patrol.regionInset', 96)); const clearance = Number(config.get('sidequests.patrol.anchorClearance', 1100)); const sep = Number(config.get('sidequests.patrol.minSeparation', 1500)); // A single generous region (radius 2000 about the origin) + one // anchor at the center: the feasible set is the annulus // [clearance, 2000−inset] — the route must stand entirely inside it. const region = [{ x: 0, y: 0, radius: 2000 }]; const anchors = [{ x: 0, y: 0 }]; const r1 = buildPatrolRoute(SEED, 'sq-x-patrol-0', 'sys-x', region, anchors); const r2 = buildPatrolRoute(SEED, 'sq-x-patrol-0', 'sys-x', region, anchors); assert.deepEqual(r2, r1, 'determinism: same seed + contract + target → the SAME route (re-visits / scene restarts / saves)'); assert.notDeepEqual( buildPatrolRoute(SEED + 13, 'sq-x-patrol-0', 'sys-x', region, anchors), r1, 'a different seed draws its own route', ); assert.ok(r1.waypoints.length >= lo && r1.waypoints.length <= hi, `${r1.waypoints.length} waypoints within the data’s range [${lo},${hi}]`); for (const [x, y] of r1.waypoints) { const d = Math.hypot(x, y); assert.ok(d >= clearance, `waypoint ${x},${y}: clear of the anchor (≥ ${clearance})`); assert.ok(d <= 2000 - inset + 1e-6, `waypoint ${x},${y}: inside the region’s inset (≤ ${2000 - inset})`); } for (let i = 0; i < r1.waypoints.length; i++) { for (let j = i + 1; j < r1.waypoints.length; j++) { const [ax, ay] = r1.waypoints[i]; const [bx, by] = r1.waypoints[j]; assert.ok(Math.hypot(ax - bx, ay - by) >= sep - 1e-6, `waypoints ${i},${j}: separated (≥ ${sep})`); } } // MULTIPLE regions (the unlocked-tether case): every waypoint lands in // SOME region (inset) — the union is the flight envelope. const region2 = [ { x: -3000, y: 0, radius: 900 }, { x: 3000, y: 0, radius: 900 }, ]; const anchors2 = [{ x: -3000, y: 0 }, { x: 3000, y: 0 }]; const rm = buildPatrolRoute(SEED, 'sq-x-patrol-1', 'sys-x', region2, anchors2); assert.ok(rm.waypoints.length >= lo, `the multi-region route stands ${rm.waypoints.length} waypoints`); for (const [x, y] of rm.waypoints) { const inSome = region2.some((rg) => Math.hypot(x - rg.x, y - rg.y) <= rg.radius - inset + 1e-6); assert.ok(inSome, `waypoint ${x},${y}: inside some region of the envelope`); } // The DEGENERATE case — an empty region (a barren system): the route // still stands (a patrol must ALWAYS be completable) — the fallback // disc (level-1 tether radius about the origin). const L1 = Number(config.get('tether.level1Radius', 5120)); // the fallback disc’s scale (data/tether.json’s level-1 radius) const r0 = buildPatrolRoute(SEED, 'sq-x-patrol-2', 'sys-x', [], []); assert.ok(r0.waypoints.length >= lo && r0.waypoints.length <= hi, `the fallback route stands ${r0.waypoints.length} waypoints (the range)`); for (const [x, y] of r0.waypoints) { assert.ok(Math.hypot(x, y) <= L1 + 1e-6, `fallback waypoint ${x},${y}: inside the level-1 disc`); } // A TIGHT region (inset + clearance leave no room): the clearance // relaxes (the waypoint count and the region never do). const tight = [{ x: 0, y: 0, radius: 900 }]; // inset → 804 < clearance → the annulus is empty const rt = buildPatrolRoute(SEED, 'sq-x-patrol-3', 'sys-x', tight, anchors); assert.ok(rt.waypoints.length >= lo, `the tight region still stands ${rt.waypoints.length} waypoints (the count never relaxes)`); for (const [x, y] of rt.waypoints) { assert.ok(Math.hypot(x, y) <= 900 - inset + 1e-6, 'the REGION is never relaxed (the flight envelope holds)'); } } // =========================================================================== console.log('== the patrol’s ENCOUNTERS — derivePatrolEncounters (the contacts’ spec) =='); { const { patrolEncounterCounts, patrolEncounterWave, patrolEnemyRoster, patrolEncounterPositions, derivePatrolEncounters, patrolEncounterScript, patrolRouteComplete, patrolXpScale } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href); const { normalizeWaves } = await import(pathToFileURL(join(__dirname, '../js/combat/Waves.js')).href); // --- the COUNT’S ODDS (the data’s 1×20% / 2×20% / 3×60%) ----------- 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: 1 }, 'the LOW band’s roster (levels 1–5 × 1–2 hops): the ESTABLISHED type only (raider)'); assert.deepEqual(a.wave, { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 }, 'the contact’s wave: ONE rolling wave of six, three at a time, each kill replaced'); assert.ok(a.at.length >= 1 && a.at.length <= 3, 'the count: 1–3 contacts'); assert.equal(new Set(a.at).size, a.at.length, 'the contacts’ waypoints are DISTINCT'); for (const i of a.at) assert.ok(Number.isInteger(i) && i >= 0 && i < wps5.length, 'each contact stands on a route waypoint (in range)'); assert.equal(a.xpScale, 1, 'tier 1 (level 3): the low band carries no scale — the payout pays FULL worth'); // --- THE BAND’S ROSTER + WAVE TIERS (level × hops from home) --------- assert.deepEqual(patrolEnemyRoster(1, 1), { raider: 1 }, 'tier 1: level 1 at 1 hop → the established type'); assert.deepEqual(patrolEnemyRoster(5, 2), { raider: 1 }, 'tier 1: level 5 at 2 hops → the established type'); assert.deepEqual(patrolEnemyRoster(3, 0), { raider: 1 }, 'tier 1: level 3 at home (0 hops — out of the band’s 1–2) → the fallback (the established type)'); assert.deepEqual(patrolEnemyRoster(0, 1), { raider: 1 }, 'band: level 0 (below tier 1’s 1) → the fallback'); // THE LEVEL-6 JUMP — a mixed roster + a fatter wave (4 at a time, ten deep). assert.deepEqual(patrolEnemyRoster(6, 1), { raider: 5, spitter: 3, bomber: 2 }, 'tier 2 (6–8): the jump mix (raider anchor + spitter + bomber)'); assert.deepEqual(patrolEncounterWave(6, 1), { kind: 'rolling', total: 10, maxConcurrent: 4, replacePerKill: 1 }, 'tier 2: the wave grows 6→10 with four at a time'); assert.ok('stinger' in patrolEnemyRoster(9, 3), 'tier 3 (9–12) brings in the stinger'); assert.deepEqual(patrolEnemyRoster(9, 3), { raider: 4, spitter: 2, stinger: 2, gunner: 2, bomber: 1 }, 'tier 3: the mix widens (stinger + gunner join)'); assert.deepEqual(patrolEncounterWave(9, 3), { kind: 'rolling', total: 14, maxConcurrent: 5, replacePerKill: 1 }, 'tier 3: 14 at five'); assert.deepEqual(patrolEnemyRoster(13, 2), { raider: 3, spitter: 2, stinger: 2, gunner: 2, hunter: 2, bomber: 1 }, 'tier 4 (13–18): the hunter (missiles) joins'); assert.deepEqual(patrolEncounterWave(13, 2), { kind: 'rolling', total: 18, maxConcurrent: 6, replacePerKill: 1 }, 'tier 4: 18 at six'); assert.deepEqual(patrolEnemyRoster(19, 4), { raider: 3, spitter: 2, stinger: 2, gunner: 2, hunter: 2, barrage: 2 }, 'tier 5 (19+, the cap): the barrage rounds the mix out'); assert.deepEqual(patrolEncounterWave(40, 4), { kind: 'rolling', total: 20, maxConcurrent: 6, replacePerKill: 1 }, 'tier 5: the seats + total CAP at 20 / six (past here the mix is the pressure)'); assert.ok(Object.keys(patrolEnemyRoster(99, 1)).length === 6, 'the top tier’s mix holds its six types at any level past it'); // The tier’s counts override (the higher tiers skew toward three contacts). assert.deepEqual(patrolEncounterCounts(5, 1), [[1, 0.2], [2, 0.2], [3, 0.6]], 'tier 1: the shared odds stand (20 / 20 / 60)'); assert.deepEqual(patrolEncounterCounts(14, 2), [[1, 0.1], [2, 0.3], [3, 0.6]], 'tier 4: the band’s own odds (10 / 30 / 60)'); assert.deepEqual(patrolEncounterCounts(6, 1), [[1, 0.2], [2, 0.2], [3, 0.6]], 'tier 2: no band odds → the shared table'); // The tier’s XP SCALE (the subtle dial — the fatter wave’s worth, trimmed // so the payout’s pace stays with the curve; the low band pays full). assert.equal(patrolXpScale(3, 1), 1, 'tier 1: full worth (no scale)'); assert.equal(patrolXpScale(6, 1), 0.7, 'tier 2 (the jump): 0.7'); assert.equal(patrolXpScale(9, 3), 0.55, 'tier 3: 0.55'); assert.equal(patrolXpScale(14, 2), 0.5, 'tier 4: 0.5'); assert.equal(patrolXpScale(20, 4), 0.45, 'tier 5 (the cap): 0.45'); const a6 = derivePatrolEncounters(SEED, 'sq-x-patrol-6', 'sys-x', wps5, 6, 1); assert.equal(a6.xpScale, 0.7, 'a level-6 spec fixes the tier’s scale on the route (1, never re-drawn)'); // --- THE POSITIONING (the contacts spread across the route) --------- const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]]; 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 XP REWARD — patrolEncounterXp (the claim’s second payout) =='); { const { patrolEncounterXp } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href); // The payout's XP = the XP WORTH of the patrol’s defeated contacts — // the contacts × the wave's total × the roster’s weighted-average xp. // The per-type XP is read from data/combat.json rather than pinned here // (the tuning breathes — a re-balance moves the numbers, not the rule). const { enemyTypeConfig } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href); const XP = Object.fromEntries(['raider', 'bomber'].map((t) => [t, Number(enemyTypeConfig(t)?.xp) || 0])); const enc = (at, extra = {}) => ({ at, cleared: [], roster: ['raider'], wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 }, ...extra, }); assert.equal(patrolEncounterXp(null), 0, 'no encounters (old saves, the feature off) → 0 (the credits stand alone)'); assert.equal(patrolEncounterXp({ at: [] }), 0, 'no contacts → 0'); assert.equal(patrolEncounterXp(enc([1])), 6 * XP.raider, `1 contact × 6 raiders × ${XP.raider} XP (the base hostile)`); assert.equal(patrolEncounterXp(enc([1, 2])), 2 * 6 * XP.raider, '2 contacts → double the XP worth of every defeated contact'); assert.equal(patrolEncounterXp(enc([0, 2, 4])), 3 * 6 * XP.raider, '3 contacts → triple'); assert.equal(patrolEncounterXp(enc([1], { roster: ['bomber'] })), 6 * XP.bomber, `a bomber band (${XP.bomber} XP each) → 6 × ${XP.bomber}`); assert.equal(patrolEncounterXp(enc([1], { roster: ['raider', 'bomber'] })), Math.round(6 * (XP.raider + XP.bomber) / 2), `a mixed roster (equal weights) → 6 × (${XP.raider} + ${XP.bomber}) / 2`); assert.equal(patrolEncounterXp(enc([1], { wave: { kind: 'rolling', total: 4, maxConcurrent: 2, replacePerKill: 1 } })), 4 * XP.raider, `a smaller wave total rules: 4 × ${XP.raider}`); assert.equal(patrolEncounterXp(enc([1], { roster: ['not-a-type'] })), 6 * XP.raider, 'an unknown roster type falls back to the established type’s wave (the script’s rule)'); assert.equal(patrolEncounterXp(enc([1], { xpScale: 0.5 })), Math.round(6 * XP.raider * 0.5), 'a spec’s xpScale trims the payout (round(worth × scale))'); assert.equal(patrolEncounterXp(enc([1], { xpScale: 0 })), 0, 'a zero scale pays nothing (the dial’s floor)'); } // =========================================================================== console.log('== the ledger — SideQuestState (the run’s accepted/active/completed/claimed marks) =='); { const st = new SideQuestState(); assert.equal(st.accept('sq-a-patrol-0'), true, 'a new accept lands'); assert.equal(st.accept('sq-a-patrol-0'), false, 'the idempotent accept (a double-press can’t double-take)'); assert.equal(st.isAccepted('sq-a-patrol-0'), true, 'isAccepted'); assert.equal(st.complete('sq-a-bounty-0'), false, 'only an ACCEPTED contract can complete (completed ⊆ accepted)'); assert.equal(st.accept('sq-a-bounty-0'), true, 'a second contract is accepted'); assert.deepEqual(st.acceptedList(), ['sq-a-patrol-0', 'sq-a-bounty-0'], 'the taken order stands (the active slot’s "top")'); assert.equal(st.complete('sq-a-bounty-0'), true, 'a taken contract completes'); assert.equal(st.complete('sq-a-bounty-0'), false, 'complete is idempotent too'); assert.equal(st.isCompleted('sq-a-bounty-0'), true, 'isCompleted'); assert.equal(st.isCompleted('sq-a-patrol-0'), false, '…while its sibling stays accepted, not complete'); // The ONE ACTIVE slot — no active → the TOP accepted stands; a // contract completed/claimed drops out (the next top stands); the // player’s explicit set stands over the default. assert.equal(st.reconcileActive(), 'sq-a-patrol-0', 'the top accepted stands (the one not done/claimed)'); assert.equal(st.isActive('sq-a-patrol-0'), true, 'isActive'); assert.equal(st.setActive('sq-a-bounty-0'), false, 'a DONE contract can’t stand active'); assert.equal(st.setActive('sq-a-patrol-0'), true, 'an explicit set stands'); assert.equal(st.activeId, 'sq-a-patrol-0', 'the slot holds the set contract'); st.accept('sq-a-bounty-0'); // already taken — a no-op st.complete('sq-a-patrol-0'); // done → it drops out of the slot assert.equal(st.activeId, null, 'both contracts done → no active contract'); // CLAIM — idempotent, completed ⊆ claimed enforced. const stc = new SideQuestState(); stc.accept('sq-c-patrol-0'); stc.complete('sq-c-patrol-0'); assert.equal(stc.claim('sq-c-patrol-0'), true, 'a completed contract claims'); assert.equal(stc.claim('sq-c-patrol-0'), false, 'the idempotent claim (a double-press can’t double-pay)'); assert.equal(stc.isClaimed('sq-c-patrol-0'), true, 'isClaimed'); const stx = new SideQuestState(); stx.accept('sq-x-patrol-0'); assert.equal(stx.claim('sq-x-patrol-0'), false, 'an UNCOMPLETED contract can’t claim (claimed ⊆ completed)'); stx.complete('sq-x-patrol-0'); stx.claim('sq-x-patrol-0'); assert.equal(stx.reconcileActive(), null, 'a claimed contract drops out of the active slot (nothing stands)'); stx.accept('sq-x-patrol-1'); assert.equal(stx.reconcileActive(), 'sq-x-patrol-1', 'the next top accepted stands'); // The ROUTE records (the patrol’s waypoints + the visited marks). const rtr = new SideQuestState(); rtr.accept('sq-r-patrol-0'); assert.equal(rtr.setRoute('sq-r-foreign-0', { waypoints: [[1, 2]] }), false, 'a route for an UNACCEPTED contract drops'); assert.equal(rtr.setRoute('sq-r-patrol-0', null), false, 'a null route drops'); const route = { targetSystemId: 'sys-r', credits: 90, waypoints: [[100, 200], [300, 400], [500, 600]], visited: [] }; assert.equal(rtr.setRoute('sq-r-patrol-0', route), true, 'a route stands for a taken contract'); assert.equal(rtr.routeFor('sq-r-patrol-0').credits, 90, 'the contract’s payout is fixed on the route'); assert.equal(rtr.routeFor('sq-r-other-0'), null, 'routeFor: no route → null'); assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 7), false, 'an out-of-range index doesn’t log'); assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), false, 'a LATER waypoint doesn’t log (the route is a path — 1, 2, 3, …)'); assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), true, 'the FIRST waypoint logs'); assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), false, 'the idempotent visit (a per-frame pass can’t double-log)'); assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), true, 'the next waypoint logs once the first stands logged'); assert.deepEqual(rtr.routeFor('sq-r-patrol-0').visited, [0, 1], 'the visited marks stand (in log order)'); // Malformed route records degrade (the finite pairs keep, the rest drops). const rtm = new SideQuestState(); rtm.accept('sq-m-patrol-0'); rtm.setRoute('sq-m-patrol-0', { waypoints: [[1, 2], [NaN, 9], ['x'], 42], visited: [0, 0, 9] }); 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 }, xpScale: 0.7 }; rte.setRoute('sq-e-patrol-0', { targetSystemId: 'sys-e', credits: 120, waypoints: [[1, 1], [2, 2], [3, 3], [4, 4]], visited: [], encounters: spec }); const enc = rte.routeFor('sq-e-patrol-0').encounters; assert.deepEqual(enc.at, [1, 2], 'the encounters’ waypoints are deduped + sorted + range-checked'); assert.deepEqual(enc.cleared, [1], 'the cleared marks stay inside the route’s contacts (the out-of-range one drops)'); assert.deepEqual(enc.roster, { raider: 1 }, 'the legacy array roster (old saves) normalizes to the weighted table (the established type)'); assert.equal(enc.xpScale, 0.7, 'the spec’s xpScale is fixed on the route (the payout’s dial)'); assert.equal(enc.wave.total, 6, 'the wave’s shape is fixed on the route (six hostiles)'); assert.equal(rte.encounterCleared('sq-e-patrol-0', 1), false, 'a cleared contact can’t be re-cleared (the witness is idempotent)'); assert.equal(rte.encounterCleared('sq-e-patrol-0', 0), false, 'a waypoint OUTSIDE the contacts’ set doesn’t clear'); 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 route's XP PAYOUT (the claim’s second reward — beside the // credits): an explicit one (the accept's fix) stands on the record; // a route without one (an old save) derives it from its own spec; // a route without encounters (the old shape) degrades to 0 (the // credits stand alone). const specXp = { at: [1, 2], cleared: [], roster: ['raider'], wave: { kind: 'rolling', total: 6, maxConcurrent: 3, replacePerKill: 1 } }; const rtx = new SideQuestState(); rtx.accept('sq-xp-patrol-0'); rtx.setRoute('sq-xp-patrol-0', { targetSystemId: 'sys-x', credits: 90, waypoints: [[1, 1], [2, 2], [3, 3]], visited: [], encounters: specXp, xp: 120 }); assert.equal(rtx.routeFor('sq-xp-patrol-0').xp, 120, 'an explicit XP payout (the accept’s fix) stands on the route'); rtx.setRoute('sq-xp-patrol-0', { targetSystemId: 'sys-x', credits: 90, waypoints: [[1, 1], [2, 2], [3, 3]], visited: [], encounters: specXp }); assert.equal(rtx.routeFor('sq-xp-patrol-0').xp, 120, 'a route without an XP field (an old save) derives it from its own spec (2 contacts × 6 × 10)'); const rtxOld = new SideQuestState(); rtxOld.accept('sq-xpo-patrol-0'); rtxOld.setRoute('sq-xpo-patrol-0', { targetSystemId: 'sys-x', credits: 60, waypoints: [[1, 1]], visited: [] }); assert.equal(rtxOld.routeFor('sq-xpo-patrol-0').xp, 0, 'a route without encounters (the old shape) → 0 XP (the credits stand alone)'); assert.equal(SideQuestState.fromJSON(rtx.toJSON()).routeFor('sq-xp-patrol-0').xp, 120, 'the XP payout survives the save round-trip (the contract’s payout)'); // 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). const json = st.toJSON(); assert.deepEqual(json, { accepted: ['sq-a-patrol-0', 'sq-a-bounty-0'], completed: ['sq-a-bounty-0', 'sq-a-patrol-0'], claimed: [], activeId: null, routes: {}, }, 'the toJSON shape (accepted/completed/claimed/activeId/routes)'); const back = SideQuestState.fromJSON(json); assert.deepEqual(back.toJSON(), json, 'fromJSON(toJSON) is the identity'); assert.deepEqual(back.acceptedList(), st.acceptedList(), 'the taken order survives the round-trip'); // A save WITH a route + an active slot — the scene’s check-ins and the // board’s route line read them after a reload. const live = new SideQuestState(); live.accept('sq-s-patrol-0'); live.accept('sq-s-patrol-1'); live.setActive('sq-s-patrol-1'); live.setRoute('sq-s-patrol-0', { targetSystemId: 'sys-s', credits: 60, waypoints: [[1, 1], [2, 2]], visited: [0] }); const liveJson = live.toJSON(); const backLive = SideQuestState.fromJSON(liveJson); assert.equal(backLive.activeId, 'sq-s-patrol-1', 'the saved ACTIVE slot stands'); assert.deepEqual(backLive.routeFor('sq-s-patrol-0').visited, [0], 'the saved visited marks stand'); assert.equal(backLive.routeFor('sq-s-patrol-1'), null, 'a contract without a route stays route-less'); assert.equal(backLive.visitWaypoint('sq-s-patrol-0', 1), true, 'check-ins resume on the restored route'); // A save predating the feature — no field → an empty ledger, no throw. assert.deepEqual(SideQuestState.fromJSON(null).toJSON(), { accepted: [], completed: [], claimed: [], activeId: null, routes: {} }, 'a pre-feature save loads empty (old saves load)'); assert.deepEqual(SideQuestState.fromJSON('garbage').toJSON(), { accepted: [], completed: [], claimed: [], activeId: null, routes: {} }, 'a malformed record degrades, not throws'); assert.deepEqual(SideQuestState.fromJSON({ accepted: 'x', completed: 42 }).toJSON(), { accepted: [], completed: [], claimed: [], activeId: null, routes: {} }, 'wrong shapes are dropped'); // completed ⊆ accepted and claimed ⊆ completed are RE-ESTABLISHED on // restore; the active slot reconciles (a done saved-active drops out, // the top accepted stands instead). const base = { accepted: ['sq-a-patrol-0'], completed: [], claimed: [], activeId: null, routes: {} }; assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-z-bounty-9'] }).toJSON(), { ...base, completed: [], activeId: 'sq-a-patrol-0' }, 'a completed id with no accept drops (the top accepted stands active)'); assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'], claimed: ['sq-a-patrol-0'] }).toJSON(), { ...base, completed: ['sq-a-patrol-0'], claimed: ['sq-a-patrol-0'] }, 'a completed+claimed entry inside accepted survives'); assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0', 'sq-a-patrol-1'], completed: ['sq-a-patrol-0'], activeId: 'sq-a-patrol-0' }).activeId, 'sq-a-patrol-1', 'a DONE saved-active drops out — the top accepted stands'); assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'], claimed: ['sq-a-patrol-0'], activeId: 'sq-a-patrol-0' }).activeId, null, 'a CLAIMED saved-active drops out — nothing stands'); assert.equal(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], activeId: 'sq-nowhere-0' }).activeId, 'sq-a-patrol-0', 'a foreign saved-active drops — the top accepted stands'); // The job-space guard — ids the save's galaxy never posted are dropped // (a hand-edited or foreign ledger degrades instead of sticking), // routes included. The space is PER-WORLD now: each system's SURFACE // WORLDS (the fake galaxy's planets + free-space stations) × the slot // range × the four types. const fakeGalaxy = { records: RECORDS, byId: new Map(RECORDS.map((r) => [r.id, r])), ensureContent: (id) => { const rec = RECORDS.find((r) => r.id === id) ?? null; if (!rec) return null; return { planets: [1, 2].map((n) => ({ id: `${id}-p${n}`, name: `${rec.name ?? id} ${n}`, ordinal: n })), settlements: [{ id: `${id}-s1`, name: `${rec.name ?? id} Outpost`, anchor: { type: 'space' } }], jumps: [], }; }, }; const known = allSideQuestJobIds(fakeGalaxy); const [lo2, hi2] = sideQuestCountRange(); const WORLDS_PER_SYSTEM = 3; // 2 planets + 1 free-space station (the fake galaxy's content) assert.equal(known.size, RECORDS.length * WORLDS_PER_SYSTEM * hi2 * SIDE_QUEST_CATEGORIES.length, 'the space = worlds × the slot range × the four types'); assert.ok(known.has('sq-sys-zenith-p1-patrol-0'), 'a real job id stands in the space'); assert.ok(known.has('sq-sys-zenith-s1-patrol-0'), 'a free-space STATION’s job id stands in the space'); assert.ok(!known.has('sq-sys-zenith-patrol-0'), 'an OLD per-system id (no world key) is outside the space (Path A — it drops)'); assert.ok(!known.has('sq-nowhere-patrol-0'), 'a foreign system id is outside the space'); const guarded = SideQuestState.fromJSON( { accepted: ['sq-sys-zenith-p1-patrol-0', 'sq-nowhere-patrol-0'], completed: [], claimed: [], activeId: null, routes: { 'sq-sys-zenith-p1-patrol-0': { targetSystemId: 'x', credits: 10, waypoints: [[1, 1]], visited: [] }, 'sq-nowhere-patrol-0': { waypoints: [[1, 1]], visited: [] } }, }, known, ); assert.deepEqual(guarded.toJSON().accepted, ['sq-sys-zenith-p1-patrol-0'], 'the foreign id drops; the real one stands'); assert.equal(guarded.routeFor('sq-nowhere-patrol-0'), null, 'a foreign route drops with its id'); assert.ok(guarded.routeFor('sq-sys-zenith-p1-patrol-0'), 'the real route stands'); assert.deepEqual(SideQuestState.fromJSON(null, known).toJSON(), { accepted: [], completed: [], claimed: [], activeId: null, routes: {} }, 'a pre-feature save loads empty even with a job space'); } // =========================================================================== console.log('== the scene’s seams — the QUESTS console + tracker see the accepted contracts =='); { // This section pins the PATROL's seams (setRoute / updatePatrol / // patrolProgress / the checklist). Stand PATROL alone so the board's // seeded single slot on the neutral record is guaranteed to be a patrol // (with the live set wider, that one slot could land on bounty/escort). setEnabled({ patrol: true }); // The scene's seams (GameScene.questSnapshot / claimQuest / // setPriorityQuest / claimSideQuest) run over the SAME ledger + postings // this file pins — called headless on a stub `this` (the jobboard’s // makeScene pattern): the ledger is the real SideQuestState, the story // ledger the real (ungranted) QuestState, the galaxy the hand records. const { GameScene } = await import(pathToFileURL(join(__dirname, '../js/scenes/GameScene.js')).href); const { QuestState } = await import(pathToFileURL(join(__dirname, '../js/quests/QuestState.js')).href); const makeScene = () => { const state = { credits: 0, toasts: [], sfx: [], refreshes: 0, }; const scene = { sideQuestState: new SideQuestState(), questState: new QuestState(), galaxy: { seed: SEED, byId: new Map(RECORDS.map((r) => [r.id, r])) }, ship: { addCredits: (n) => { state.credits += n; } }, refreshCreditsHud: () => {}, playSfx: (n) => state.sfx.push(n), consoleToast: (label, o) => state.toasts.push({ label: String(label), o }), questWindow: { refresh: () => state.refreshes++ }, state, }; // 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', 'updatePatrol', '_firePatrolContact', 'patrolProgress']) { scene[k] = GameScene.prototype[k]; } return scene; }; const call = (fn, scene, ...args) => fn.call(scene, ...args); // A REAL posting id on the neutral record (the board stands it — // sq--p1-patrol-), and its deterministic reward. const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1'); const job = posts.find((p) => p.category === 'patrol'); assert.ok(job, 'the neutral board stands a patrol (the live category)'); // --- the snapshot: the SIDE QUESTS tab’s rows ------------------------- const scene = makeScene(); const callQuestSnapshot = () => GameScene.prototype.questSnapshot.call(scene); const sideRows = (snap) => (snap.quests ?? []).filter((q) => q.category === 'side'); assert.equal(sideRows(callQuestSnapshot()).length, 0, 'no accepted contracts → the side tab is empty (the story rows stand alone)'); scene.sideQuestState.accept(job.id); let row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id); assert.ok(row, 'the ACCEPTED contract stands in the snapshot (the tab lists it)'); assert.equal(row.title, job.title, '…on the posting’s title'); assert.equal(row.issuer, job.issuer, '…on the posting’s issuer'); assert.equal(row.claimed, false, '…not yet claimed'); assert.equal(row.complete, false, '…not complete before the route stood (no route, no waypoints)'); // The route lands (the accept seam’s job — buildPatrolRoute on the // target’s region): the checklist is its waypoints, LIVE. scene.sideQuestState.setRoute(job.id, { targetSystemId: REC_NEUTRAL.id, credits: 77, waypoints: [[10, 20], [300, 400], [7, 8]], visited: [] }); row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id); assert.equal(row.checks.length, 3, 'the checklist = the route’s waypoints (three)'); assert.equal(row.progress, '0/3', 'progress 0/3'); assert.deepEqual(row.reward, '77 CR', 'the reward = the contract’s payout (fixed at the accept)'); assert.equal(row.active, false, 'accepted but not yet ACTIVE (the board’s SET PATROL ACTIVE door stands)'); scene.sideQuestState.setActive(job.id); row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id); assert.equal(row.active, true, '…and once stood active (the scene’s setActive seam), it reads ACTIVE'); scene.sideQuestState.visitWaypoint(job.id, 0); row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id); assert.equal(row.checks[0].done, true, 'a logged waypoint reads done in the checklist'); assert.equal(row.checks[0].detail, 'LOGGED', '…with its LOGGED detail'); assert.equal(row.checks[1].done, false, '…and the next one stands open'); assert.equal(row.progress, '1/3', 'progress 1/3'); assert.equal(row.complete, false, 'not complete until every waypoint is logged'); scene.sideQuestState.visitWaypoint(job.id, 1); scene.sideQuestState.visitWaypoint(job.id, 2); scene.sideQuestState.complete(job.id); // the scene’s updatePatrol does this on the last check-in row = sideRows(callQuestSnapshot()).find((q) => q.id === job.id); assert.equal(row.complete, true, 'every waypoint logged → COMPLETE (the READY row)'); assert.equal(row.claimed, false, '…until its reward is claimed'); assert.equal(row.active, false, 'the done contract drops out of the ACTIVE slot (reconcileActive)'); // A foreign accepted id (outside the galaxy’s job space) never stands // a row — no posting to read it from, no crash. scene.sideQuestState.accept('sq-nowhere-patrol-0'); assert.equal(sideRows(callQuestSnapshot()).length, 1, 'a foreign contract stands no row (degrades, doesn’t fabricate)'); // --- the claim seam: the console/tracker’s CLAIM REWARD --------------- const before = scene.state.credits; call(GameScene.prototype.claimQuest, scene, job.id); assert.equal(scene.state.credits, before + 77, 'the claim pays the contract’s payout (the route’s credits)'); assert.equal(scene.sideQuestState.isClaimed(job.id), true, '…and marks it claimed (exactly once — the ledger’s idempotency)'); assert.ok(scene.state.toasts.some((t) => t.label === 'REWARD CLAIMED — +77 CR'), '…with the console’s claim toast (the credits’ payout — a route without encounters stands credits-only, no XP line)'); assert.ok(scene.state.sfx.includes('construct'), '…with the payout tick (the scene’s voice)'); assert.ok(scene.state.refreshes > 0, '…and the open console repaints (refresh)'); const after = scene.state.credits; call(GameScene.prototype.claimQuest, scene, job.id); assert.equal(scene.state.credits, after, 'a second claim pays NOTHING (idempotent — the ledger holds the mark)'); // The uncomplete refusal — the console's voice, no payout. (job2 comes // from a DIFFERENT world's board — the per-world count is 1–3, so this // world's single patrol isn't guaranteed a sibling; another world's // board always stands its own patrols.) let job2 = null; for (const wk of ['p2', 'p3', 'p4']) { job2 = sideQuestPostingsFor(SEED, REC_NEUTRAL, wk).find((p) => p.category === 'patrol' && p.id !== job.id); if (job2) break; } assert.ok(job2, 'a sibling contract stands on another world’s board'); scene.sideQuestState.accept(job2.id); scene.sideQuestState.setRoute(job2.id, { targetSystemId: REC_NEUTRAL.id, credits: 99, waypoints: [[1, 1]], visited: [] }); const before2 = scene.state.credits; call(GameScene.prototype.claimQuest, scene, job2.id); assert.equal(scene.state.credits, before2, 'an uncomplete contract pays NOTHING'); assert.equal(scene.sideQuestState.isClaimed(job2.id), false, '…and stays unclaimed'); assert.ok(scene.state.toasts.some((t) => t.label.toUpperCase().includes('NOTHING TO CLAIM')), '…with the refusal toast (the console’s voice)'); // A story id falls through to the story ledger’s path (ungranted here — // no throw, no side-ledger mark, no payout). const before3 = scene.state.credits; call(GameScene.prototype.claimQuest, scene, 'start_your_journey'); assert.equal(scene.state.credits, before3, 'a story id takes the story path (no side-ledger mark, no payout)'); // A claimed contract drops off the tracker’s featured slot (the story // claim’s shape — the priority re-resolves on the next poll). const sceneP = makeScene(); sceneP.sideQuestState.accept(job.id); sceneP.sideQuestState.setRoute(job.id, { targetSystemId: REC_NEUTRAL.id, credits: 50, waypoints: [[1, 1]], visited: [0] }); sceneP.sideQuestState.complete(job.id); sceneP.questState.priorityCleared = false; sceneP.questState.resolvePriority([job.id]); // the tracker’s auto-priority (no story quest stands) assert.equal(sceneP.questState.priority, job.id, 'the tracker’s auto-priority features the only active quest (the side contract)'); call(GameScene.prototype.claimQuest, sceneP, job.id); assert.equal(sceneP.questState.priority, null, 'the claimed contract drops off the featured slot (the priority re-resolves)'); assert.equal(sceneP.questState.priorityCleared, false, '…with the auto-assignment RE-ARMED (the claim re-arms)'); // --- SET PRIORITY: the popup/console's door on a side contract -------- const sceneQ = makeScene(); // The per-world count is 1–3, so one world may not stand three distinct // contracts — pool the world's own boards in order (p1, p2, …). The // ledger doesn't care which world posted a contract (the id is the key). const pool = []; for (const wk of ['p1', 'p2', 'p3', 'p4']) { for (const p of sideQuestPostingsFor(SEED, REC_NEUTRAL, wk)) { if (!pool.some((q) => q.id === p.id)) pool.push(p); } if (pool.length >= 3) break; } const a = pool.find((p) => p.category === 'patrol'); const b = pool.find((p) => p.id !== a.id); sceneQ.sideQuestState.accept(a.id); sceneQ.sideQuestState.accept(b.id); assert.equal(call(GameScene.prototype.setPriorityQuest, sceneQ, b.id), true, 'SET PRIORITY on an accepted contract stands (the side path)'); assert.equal(sceneQ.sideQuestState.isActive(b.id), true, '…it stands the side ledger’s ACTIVE slot (the contract in flight)'); assert.equal(sceneQ.questState.priority, b.id, '…featured through the tracker’s seam (the story ledger’s priority)'); assert.ok(sceneQ.state.toasts.some((t) => t.label.toUpperCase().includes('PRIORITY SET')), '…with the priority toast (the console’s voice)'); const other = pool.find((p) => p.id !== a.id && p.id !== b.id); assert.ok(other, 'a third distinct contract stands (the world boards pool)'); sceneQ.sideQuestState.accept(other.id); 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 xpGrants = []; // the run's XP bank’s witness (the claim’s XP payout) combat.xp = { grant(n, src = 'kill') { xpGrants.push({ n: Math.max(0, Math.floor(Number(n) || 0)), src }); return { gained: n, level: 1, levelsGained: 0, leveledUp: false }; }, }; 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, xp: 60, contacts: { cleared: 1, total: 1 } }, 'the board’s seam reads the finished route (3/3 + 1/1) + the contract’s XP payout (1 contact × 6 × 10)'); viewC = sceneC._sideQuestView(jobC.id); assert.equal(viewC.checks[3].done, true, '…and the checklist’s contact reads CLEARED'); assert.equal(viewC.reward, '140 CR · +60 XP', '…and the console row’s reward names both payouts (the credits + the XP)'); 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)'); assert.deepEqual(xpGrants, [{ n: 60, src: 'sidequest' }], '…and the claim banks the XP reward (the patrol’s defeated contacts’ worth) in the run’s XP bank, under the ‘sidequest’ source, beside the credits'); assert.ok(sceneC.state.toasts.some((t) => t.label.includes('+140') && t.label.includes('+60 XP')), '…and the console’s claim toast names both payouts (the credits + the XP)'); // 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 + encounters, the ledger) + the QUESTS console/tracker seams');