orbit/dev/sidequests.test.mjs

287 lines
17 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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