287 lines
17 KiB
JavaScript
287 lines
17 KiB
JavaScript
// 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)');
|