orbit/dev/sidequests.test.mjs

1006 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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; 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 + BOUNTY HUNT live — the two categories
// whose rules landed; DELIVERY and ESCORT stay OFFLINE until theirs do).
const restoreDefaultGates = () => setEnabled({ patrol: true, bounty: true });
// ===========================================================================
console.log('== the category gate — patrol + bounty hunt 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', 'bounty'], 'PATROL and the BOUNTY HUNT are the live categories (the gates that landed)');
assert.deepEqual(enabledSideQuestCategories('faction'), ['patrol'], 'a FACTION board never draws a hunt (categories.bounty.contexts is ["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}: live iff its gate says so (patrol + bounty 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];
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 — delivery/escort 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' || p.category === 'bounty'), '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);
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 patrol’s ENCOUNTERS — derivePatrolEncounters (the contacts’ spec) ==');
{
const { patrolEncounterCounts, patrolEncounterWave, patrolEnemyRoster, patrolEncounterPositions, derivePatrolEncounters, patrolEncounterScript, patrolRouteComplete } = 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'], '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)');
// --- THE BAND’S ROSTER (level × hops from home) ----------------------
assert.deepEqual(patrolEnemyRoster(1, 1), ['raider'], 'band: level 1 at 1 hop → the established type');
assert.deepEqual(patrolEnemyRoster(5, 2), ['raider'], 'band: level 5 at 2 hops → the established type');
assert.deepEqual(patrolEnemyRoster(3, 0), ['raider'], 'band: level 3 at home (0 hops — out of the band’s 1–2) → the fallback (the established type)');
assert.deepEqual(patrolEnemyRoster(6, 1), ['raider'], 'band: level 6 (above the low band’s 1–5) → the fallback (the established type, until the higher band’s types land)');
assert.deepEqual(patrolEnemyRoster(0, 1), ['raider'], 'band: level 0 (below the low band’s 1) → the fallback');
// --- 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)');
}
// ===========================================================================
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 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 } };
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'], 'the band’s roster is fixed on the route (the established type)');
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.
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', '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-<systemId>-patrol-<slot>), 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 === '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.
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)');
// --- 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');