// 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-system count // spreads; flip the feature’s master switch → an empty board). // - DETERMINISM — the same contract stands on the same system across // calls (seed + record → the same postings — visits, scene restarts, // saves), a different seed draws its own board, and different // systems draw different boards (no global call-order dependence). // - The per-system COUNT (3–4 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, 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 live — the first category whose // rules landed; the other three OFFLINE until theirs do). const restoreDefaultGates = () => setEnabled({ patrol: true }); // =========================================================================== console.log('== the category gate — patrol LIVE by default, the rest 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'], 'PATROL is the first live category — the board stands live'); 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}: live iff its gate says so (patrol live, the rest 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]; 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 category’s postings // (patrol — the other three gates stay OFFLINE). const def = sideQuestPostingsFor(SEED, REC_NEUTRAL); 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'), 'the default board posts only the live type (patrol)'); } // =========================================================================== console.log('== the gate flips — patrol live → only patrol posts; all live → the spread =='); { restoreDefaultGates(); { assert.deepEqual(enabledSideQuestCategories(), ['patrol'], 'one live category'); const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL); const [lo, hi] = sideQuestCountRange(); assert.ok(lo >= 1 && hi >= lo, 'the per-system count range stands (3–4 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.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-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)'); } } setEnabled({ patrol: true, delivery: true, bounty: true, escort: true }); try { const [lo, hi] = sideQuestCountRange(); for (const rec of RECORDS) { const posts = sideQuestPostingsFor(SEED, rec); 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)`); assert.ok(new Set(posts.map((p) => p.category)).size >= Math.min(3, posts.length), `${rec.id}: the slots spread across the live types`); } // DETERMINISM — same seed + record → the same board across calls // (visits / scene restarts / saves); a different seed draws its own. const a = sideQuestPostingsFor(SEED, REC_ZENITH); const b = sideQuestPostingsFor(SEED, REC_ZENITH); assert.deepEqual(b, a, 'the same contract stands across calls'); assert.notDeepEqual(sideQuestPostingsFor(SEED + 7, REC_ZENITH), a, 'a different seed → a different board'); // …and the systems don’t share one ad (the variant pools + the // per-posting draws stand per system). assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX), 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); 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); 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), [], '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, 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, 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 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), true, 'a waypoint logs (any order — the route is a set of points)'); assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), false, 'the idempotent visit (a per-frame pass can’t double-log)'); assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), true, 'another waypoint logs'); assert.deepEqual(rtr.routeFor('sq-r-patrol-0').visited, [1, 0], '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 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. const known = allSideQuestJobIds(RECORDS); const [lo2, hi2] = sideQuestCountRange(); assert.equal(known.size, RECORDS.length * hi2 * SIDE_QUEST_CATEGORIES.length, 'the space = systems × the slot range × the four types'); assert.ok(known.has('sq-sys-zenith-patrol-0'), 'a real job id stands in the space'); assert.ok(!known.has('sq-nowhere-patrol-0'), 'a foreign system id is outside the space'); const guarded = SideQuestState.fromJSON( { accepted: ['sq-sys-zenith-patrol-0', 'sq-nowhere-patrol-0'], completed: [], claimed: [], activeId: null, routes: { 'sq-sys-zenith-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-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-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 =='); { // 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']) { 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--patrol-), and its deterministic reward. const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL); 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.includes('+77')), '…with the console’s claim toast (the reward in the 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. const job2 = posts.find((p) => p.id !== job.id); 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(); const a = posts.find((p) => p.category === 'patrol'); const b = posts.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 = posts.find((p) => p.id !== a.id && p.id !== b.id); 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)'); } console.log('\nsidequests: OK — the board’s model (the gate, the voice, the patrol’s target + route, the ledger) + the QUESTS console/tracker seams');