// 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, all OFFLINE by default, and a // category’s jobs appear on the board ONLY when its `enabled` flag // flips (flip one → only that type’s postings; flip all → the // per-system count; 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 LEDGER (SideQuestState) — the accepted/completed marks: the // idempotent accept, the completed ⊆ accepted invariant, the save // round-trip (toJSON/fromJSON), the malformed-record degradation // (a save predating the feature loads empty; a foreign id drops // against the save’s galaxy job space), and the job space itself // (allSideQuestJobIds = systems × slots × the four types). // // 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) 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'; 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, } = 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]; } }; // =========================================================================== console.log('== the category gate — the four types, all OFFLINE by default =='); { 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(), [], 'all four OFFLINE until their rules land — the board’s face is standby'); assert.deepEqual(sideQuestPostingsFor(SEED, REC_NEUTRAL), [], 'the default board is the standby (no postings)'); 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, false, `${cat}: 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`); } } } } // =========================================================================== console.log('== the gate flips — one type live → only that type posts =='); { setEnabled({ patrol: true }); try { 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)'); } } finally { setEnabled({}); } } // =========================================================================== console.log('== all four live — the count, the determinism, the voice =='); { 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 { setEnabled({}); } } // =========================================================================== 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 ledger — SideQuestState (the run’s accepted/completed 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.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 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'] }, 'the toJSON shape'); const back = SideQuestState.fromJSON(json); assert.deepEqual(back.toJSON(), json, 'fromJSON(toJSON) is the identity'); // A save predating the feature — no field → an empty ledger, no throw. assert.deepEqual(SideQuestState.fromJSON(null).toJSON(), { accepted: [], completed: [] }, 'a pre-feature save loads empty (old saves load)'); assert.deepEqual(SideQuestState.fromJSON('garbage').toJSON(), { accepted: [], completed: [] }, 'a malformed record degrades, not throws'); assert.deepEqual(SideQuestState.fromJSON({ accepted: 'x', completed: 42 }).toJSON(), { accepted: [], completed: [] }, 'wrong shapes are dropped'); // completed ⊆ accepted is RE-ESTABLISHED on restore. assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-z-bounty-9'] }).toJSON(), { accepted: ['sq-a-patrol-0'], completed: [] }, 'a completed id with no accept drops'); assert.deepEqual(SideQuestState.fromJSON({ accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'] }).toJSON(), { accepted: ['sq-a-patrol-0'], completed: ['sq-a-patrol-0'] }, 'a completed-inside-accepted entry survives'); // The job-space guard — ids the save’s galaxy never posted are dropped // (a hand-edited or foreign ledger degrades instead of sticking). const known = allSideQuestJobIds(RECORDS); const [lo, hi] = sideQuestCountRange(); assert.equal(known.size, RECORDS.length * hi * 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: [] }, known); assert.deepEqual(guarded.toJSON().accepted, ['sq-sys-zenith-patrol-0'], 'the foreign id drops; the real one stands'); assert.deepEqual(SideQuestState.fromJSON(null, known).toJSON(), { accepted: [], completed: [] }, 'a pre-feature save loads empty even with a job space'); } console.log('\nsidequests: OK — the board’s model (the gate, the determinism, the voice, the ledger)');