orbit/dev/sidequests.test.mjs

1350 lines
89 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-world count
// spreads; flip the feature’s master switch → an empty board).
// - DETERMINISM — the same contract stands on the same WORLD across
// calls (seed + record + world → the same postings — visits, scene
// restarts, saves), a different seed draws its own board, and
// different worlds (another planet in the same system, another
// system) draw different boards (no global call-order dependence).
// - The per-world COUNT (1–3 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,
parseSideQuestId,
resolveSideQuestText,
rollSideQuestReward,
allSideQuestJobIds,
SideQuestState,
patrolTargetSystem,
buildPatrolRoute,
seededOpposingFaction,
hostileNeighborSystem,
sideQuestFactionLabels,
} = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href);
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
const VOICE = await import(pathToFileURL(join(__dirname, 'lib/faction-voice-lint.mjs')).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 + ESCORT live — the three
// categories whose rules landed; DELIVERY stays OFFLINE until its rules do).
const restoreDefaultGates = () => setEnabled({ patrol: true, bounty: true, escort: true });
// ===========================================================================
console.log('== the category gate — patrol + bounty hunt + escort LIVE by default, delivery 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', 'escort'], 'PATROL, the BOUNTY HUNT, and ESCORT are the live categories (the gates that landed)');
assert.deepEqual(enabledSideQuestCategories('faction'), ['patrol', 'bounty', 'escort'], 'a FACTION board draws the war model (patrol at home, the bounty + escort on hostile ground)');
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 === 'escort', `${cat}: live iff its gate says so (patrol + bounty + escort live, delivery 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 WAR MODEL — the faction wanted-list is the generic war-voice
// (three dossiers, {enemy}-resolved, the mark’s frame + pickets drawn
// from the mark’s OWN faction roster — deriveBountyPlan’s opts.faction).
assert.ok(Array.isArray(pool) && pool.length >= 3, 'bounty.faction: the war-model wanted-list stands (3+ dossiers)');
const names = pool.map((v) => v.name);
assert.equal(new Set(names).size, names.length, 'bounty.faction: every dossier names a UNIQUE leader (the one-leader-once rule needs raw-unique names)');
for (const v of pool) {
assert.ok(typeof v.name === 'string' && v.name.length > 0, 'bounty.faction: a leader name per dossier');
assert.ok(typeof v.epithet === 'string' && v.epithet.length > 0, 'bounty.faction: an epithet per dossier');
assert.ok(typeof v.crime === 'string' && v.crime.length > 0, 'bounty.faction: a charge per dossier');
assert.ok(v.body.includes('{enemy}'), 'bounty.faction: the body names the sighted rival ({enemy})');
assert.ok(!/\{[a-z]+\}/.test(v.name), 'bounty.faction: the leader NAME is placeholder-free (the usedDossier dedup keys on the raw name)');
}
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 + escort — delivery stays OFFLINE).
const def = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1');
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' || p.category === 'escort'), '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, 'p1');
const [lo, hi] = sideQuestCountRange();
assert.ok(lo >= 1 && hi >= lo, 'the per-world count range stands (1–3 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.worldKey, 'p1', 'the posting’s world key (the board’s world)');
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-p1-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)');
}
// The id’s split (parseSideQuestId — the scene’s posting lookups +
// the claim’s reward read parse the ledger’s id back to its board +
// slot, then regenerate the board): planet AND station worlds stand,
// and the OLD per-system shape (no world key) stands no world — null.
assert.deepEqual(parseSideQuestId('sq-sys-free-p1-patrol-2'), { systemId: 'sys-free', worldKey: 'p1', category: 'patrol', slot: 2 }, 'a planet board’s id splits back apart');
assert.deepEqual(parseSideQuestId('sq-sys-zenith-s3-bounty-0'), { systemId: 'sys-zenith', worldKey: 's3', category: 'bounty', slot: 0 }, 'a station board’s id splits back apart');
assert.equal(parseSideQuestId('sq-sys-zenith-patrol-0'), null, 'an OLD per-system id (no world key) is not a live id — null');
assert.equal(parseSideQuestId('sq-sys-zenith-p1-noclue-0'), null, 'a category the board never posts is not a live id — null');
assert.equal(parseSideQuestId('nope'), null, 'a non-posting id → null');
assert.equal(parseSideQuestId(42), null, 'a non-string → null');
}
setEnabled({ patrol: true, delivery: true, bounty: true, escort: true });
try {
const [lo, hi] = sideQuestCountRange();
for (const rec of RECORDS) {
const posts = sideQuestPostingsFor(SEED, rec, 'p1');
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)`);
// The slots can’t spread past the board’s LIVE categories for that
// context (a faction board never draws a neutral-only type — bounty
// and escort stand off it), nor past the posting count itself.
const ctx = rec.faction ? 'faction' : 'neutral';
const liveInCtx = enabledSideQuestCategories(ctx).length;
assert.ok(new Set(posts.map((p) => p.category)).size >= Math.min(liveInCtx, posts.length), `${rec.id}: the slots spread across the live types for the board’s context (${liveInCtx} live in ${ctx})`);
}
// DETERMINISM — same seed + record + world → the same board across
// calls (visits / scene restarts / saves); a different seed draws its
// own; a different WORLD (another planet in the same system) stands
// its own board too.
const a = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1');
const b = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1');
assert.deepEqual(b, a, 'the same contract stands across calls');
assert.notDeepEqual(sideQuestPostingsFor(SEED + 7, REC_ZENITH, 'p1'), a, 'a different seed → a different board');
// …and the worlds don’t share one ad (the variant pools + the
// per-posting draws stand per world).
assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_ZENITH, 'p2'), a, 'a different WORLD in the same system draws its own board');
assert.notDeepEqual(sideQuestPostingsFor(SEED, REC_APEX, 'p1'), 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, 'p1');
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 — THE WAR MODEL (every patrol/bounty/escort variant
// names the ONE sighted rival via {enemy}; the resolved copy names the
// board’s own faction’s ISSUER + at least one of the two rivals).
for (const cat of ['patrol', 'bounty', 'escort']) {
const pool = DATA.sidequests.descriptions.faction[cat];
assert.ok(pool.every((v) => v.body.includes('{enemy}')), `faction ${cat}: every variant carries {enemy} (the ONE sighted rival)`);
}
const z = sideQuestPostingsFor(SEED, REC_ZENITH, 'p1');
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)');
assert.ok(/Apex|MindWeave/.test(p.description), 'a rival stands in the war copy (the {enemy} resolved)');
assert.ok(!/\{system\}|\{faction\}|\{issuer\}|\{enemies\}|\{enemy\}/.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, 'p1'), [], '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 FACTION VOICE POOLS — 9 pools × 20 variants, every listing naming the rival it fights ==');
{
const FAC = ['apex', 'mindweave', 'zenith'];
const WAR_CATS = ['patrol', 'bounty', 'escort'];
const PH = /\{(?!system\}|faction\}|issuer\}|enemies\}|enemy\})[a-zA-Z]+\}/; // any UNSUPPORTED key
const words = (s) => String(s).trim().split(/\s+/).length;
const facPools = DATA.sidequests.descriptions.faction;
// Snapshot the pools so the fallback-chain surgery below restores them exactly.
const snapshot = JSON.parse(JSON.stringify(facPools));
const dossierNames = [];
for (const f of FAC) {
assert.ok(facPools[f] && typeof facPools[f] === 'object', `${f}: the faction has a pool of its OWN (descriptions.faction.${f})`);
for (const cat of WAR_CATS) {
const pool = facPools[f][cat];
assert.ok(Array.isArray(pool) && pool.length === 20, `${f}.${cat}: exactly 20 variants (3 factions × 3 types × 20 = 180)`);
const titles = pool.map((v) => v.title);
assert.equal(new Set(titles).size, titles.length, `${f}.${cat}: titles are unique inside the pool`);
const named = titles.filter((t) => t.includes('{enemy}')).length;
assert.ok(named >= 10, `${f}.${cat}: ${named}/20 titles name the rival ({enemy}) — the board reads its war at a glance`);
for (const v of pool) {
const tag = `${f}.${cat} “${String(v.title).slice(0, 28)}”`;
assert.ok(typeof v.body === 'string' && v.body.length > 0, `${tag}: a contract body`);
assert.ok(v.body.includes('{enemy}'), `${tag}: the BODY names the rival the player fights ({enemy})`);
// THE PANEL'S REAL BUDGET (js/ui/JobBoardWindow.js): the readout fits the
// copy through fitText() at floor(wrapW / 5.4) ≈ 147 characters a line and
// five lines when nothing else has spent the space — but a HUNT's dossier
// block (WANTED / epithet / CHARGES / CRAFT + DESTINATION) leaves it TWO
// lines, ~294 characters. Copy that overruns ships an ellipsis mid-sentence,
// so each pool is written to the space its readout actually has.
const CAP = cat === 'bounty' ? 294 : 735;
const w = words(v.body);
assert.ok(v.body.length <= CAP, `${tag}: ${v.body.length} chars exceeds the readout's ${CAP}-char budget (the copy would ship clipped)`);
assert.ok(w >= (cat === 'bounty' ? 28 : 45), `${tag}: ${w} words (a contract still reads as a paragraph, not a slogan)`);
assert.ok(!PH.test(JSON.stringify(v)), `${tag}: only the resolver's placeholders ({system}/{faction}/{issuer}/{enemies}/{enemy})`);
if (cat === 'bounty') {
for (const k of ['name', 'epithet', 'crime', 'craftLabel']) {
assert.ok(typeof v[k] === 'string' && v[k].trim() !== '', `${tag}: a dossier speaks ${k}`);
}
assert.ok(!/\{[a-z]+\}/.test(v.name), `${tag}: the leader NAME is placeholder-free (the one-leader-once rule keys on the raw name)`);
// THE WAR MODEL'S FRAME RULE — a declared `craft` id OVERRIDES the
// engine's pick, and a hard-coded rival airframe would then fly for a
// differently-seeded opponent. Faction dossiers leave it unset so the
// mark flies ITS OWN faction's roster (deriveBountyPlan opts.faction).
assert.ok(v.craft === undefined || String(v.craft).trim() === '', `${tag}: no craft id — the frame rides the mark’s faction roster`);
dossierNames.push(v.name);
}
}
}
}
// The WANTED LIST never posts the same leader twice — and a board that reads
// one faction's pool must not echo another's (or the pirates') names.
assert.equal(new Set(dossierNames).size, dossierNames.length, 'the 60 faction dossiers name 60 distinct leaders');
const pirateNames = new Set(DATA.sidequests.descriptions.neutral.bounty.map((v) => v.name));
assert.ok(dossierNames.every((n) => !pirateNames.has(n)), 'no faction hunt reuses a pirate wanted-leader name');
// THE FALLBACK CHAIN (js/quests/SideQuests.js → descriptionPool): the
// faction's own pool, then the shared war-voice pool, then the neutral voice.
const REC_MINDWEAVE = { id: 'sys-weave', name: 'Weave Cradle', faction: 'mindweave', d: 45, zone: 'mid', type: 'void', capital: false };
const ALL = [REC_APEX, REC_MINDWEAVE, REC_ZENITH];
const galaxy = { byId: new Map(ALL.map((r) => [r.id, r])), jumpGatesFor: (id) => ALL.filter((r) => r.id !== id) };
const firstTitle = (rec, seedBase, cat) => {
for (let s = 0; s < 40; s++) {
const p = sideQuestPostingsFor(seedBase + s * 13, rec, 'p1', galaxy, 10).find((x) => x.category === cat);
if (p) return p.title;
}
return '';
};
const ownZenith = firstTitle(REC_ZENITH, 700, 'patrol');
assert.ok(
facPools.zenith.patrol.some((v) => v.title === ownZenith),
'a Zenith board draws the Zenith pool (the faction’s OWN voice wins)',
);
config.data.sidequests.descriptions.faction.zenith = undefined;
const fallback = firstTitle(REC_ZENITH, 700, 'patrol');
assert.ok(
snapshot.patrol.some((v) => v.title === fallback),
'a faction with no pool of its own falls back to the shared war-voice pool',
);
config.data.sidequests.descriptions.faction = { patrol: undefined, bounty: undefined, escort: undefined };
const lastResort = firstTitle(REC_ZENITH, 700, 'patrol');
assert.ok(
DATA.sidequests.descriptions.neutral.patrol.some((v) => v.title === lastResort),
'with no faction copy at all the neutral voice posts (the board never blanks)',
);
config.data.sidequests.descriptions.faction = snapshot;
// THE OTHER HALF OF THE CHAIN — a NEUTRAL board never reads war copy. Its
// pirate-safety voice names no power it has no quarrel with, so the context
// picks the START of the chain (a faction pool must never shadow it).
{
const neutralPool = new Set(DATA.sidequests.descriptions.neutral.patrol.map((v) => v.title));
for (let s = 0; s < 12; s++) {
for (const p of sideQuestPostingsFor(9100 + s * 5, REC_NEUTRAL, 'p1', galaxy, 10)) {
if (p.category !== 'patrol') continue;
assert.ok(neutralPool.has(p.title), `a neutral board reads the NEUTRAL pool (“${p.title}”)`);
}
}
for (const rec of ALL) {
for (let s = 0; s < 12; s++) {
const t = firstTitle(rec, 9500 + s * 3, 'patrol');
assert.ok(!neutralPool.has(t), `${rec.faction}: a faction board never falls through to the neutral voice while its own pool stands`);
}
}
}
// THE READ-OUT — every faction board, many seeds: the copy is PLACEHOLDER-FREE
// and it names the rival the combat will actually field (the posting’s
// enemyFaction), never some other power.
for (const rec of ALL) {
const labels = sideQuestFactionLabels();
const rivals = new Set(FAC.filter((f) => f !== rec.faction).map((f) => labels[f]));
const seenTitles = new Set();
const seedTitles = new Set();
for (let s = 0; s < 24; s++) {
// DETERMINISM, actually measured: TWO independent generations from the same
// seed must agree — and a DIFFERENT seed must not keep agreeing, or the
// comparison above proves nothing.
const postsA = sideQuestPostingsFor(3100 + s * 7, rec, 'p1', galaxy, 10);
const postsB = sideQuestPostingsFor(3100 + s * 7, rec, 'p1', galaxy, 10);
assert.deepEqual(JSON.parse(JSON.stringify(postsA)), JSON.parse(JSON.stringify(postsB)), `${rec.faction}: a repeat read of the same seed is identical`);
seedTitles.add(postsA.map((p) => `${p.category}:${p.title}`).join('|'));
// The RESOLVED text is linted too: an article that only disagrees once a
// rival’s name is dropped in (“an Apex hull”) is a shipped copy bug.
for (const p of postsA) VOICE.assertVoiceClean(`${rec.faction}: resolved ${p.category} copy`, p.title, [
`${p.title} ${p.description} ${p.dossier?.craftLabel ?? ''}`,
]);
for (const p of postsA) {
const allText = [p.title, p.issuer, p.description, p.dossier?.epithet, p.dossier?.crime, p.dossier?.craftLabel].join(' ');
assert.ok(!/\{[a-z]+\}/.test(allText), `${rec.faction}: ${p.category} copy ships no brace pair`);
assert.match(p.issuer, new RegExp(labels[rec.faction]), `${rec.faction}: the ISSUER is the faction’s own board`);
const rival = p.enemyFaction ? labels[p.enemyFaction] : null;
assert.ok(rival && rivals.has(rival), `${rec.faction}: ${p.category} posts a seeded rival`);
assert.ok(allText.includes(rival), `${rec.faction}: ${p.category} “${p.title}” names the rival it fights (${rival})`);
if (p.category === 'bounty') {
assert.ok(p.dossier?.name, `${rec.faction}: a faction hunt stands a dossier`);
assert.equal(p.dossier.craft ?? null, null, `${rec.faction}: the hunt declares no craft id (the frame rides the mark’s roster)`);
}
seenTitles.add(`${p.category}:${p.title}`);
}
}
assert.ok(seenTitles.size > 3, `${rec.faction}: the board varies across seeds (${seenTitles.size} distinct listings read)`);
assert.ok(seedTitles.size > 1, `${rec.faction}: the pool draw actually moves with the seed (not one fixed board)`);
}
}
// ===========================================================================
console.log('== EVERY RESOLUTION — each of the 180 faction listings, linted against BOTH rivals it can name ==');
{
// A pool entry is written ONCE and read on a board whose rival is SEEDED. So the
// grammar has to hold for every resolution, not the one this seed drew: resolve
// each entry as EACH rival the board could name, then lint the result (article
// agreement, doubled words, unresolved braces) with dev/lib/faction-voice-lint.mjs.
const labels = sideQuestFactionLabels();
const ids = Object.keys(DATA.sidequests.descriptions.faction).filter((k) => Array.isArray(DATA.sidequests.descriptions.faction[k]?.patrol));
assert.equal(ids.length, 3, `three faction voice pools are linted (${ids.join(', ')})`);
let resolutions = 0;
for (const f of ids) {
const issuer = String(DATA.sidequests.factions?.[f] ?? '').trim() || `${labels[f]} Authority`;
for (const r of ids.filter((x) => x !== f)) {
const ctx = {
context: 'faction',
system: 'Kestrel Verge',
faction: labels[f],
issuer,
enemies: labels[ids.find((x) => x !== f && x !== r)] ? `${labels[r]} and ${labels[ids.find((x) => x !== f && x !== r)]}` : labels[r],
enemy: labels[r],
};
for (const cat of ['patrol', 'bounty', 'escort']) {
for (const v of DATA.sidequests.descriptions.faction[f][cat]) {
for (const field of ['title', 'body', 'name', 'epithet', 'crime', 'craftLabel']) {
if (!v[field]) continue;
resolutions++;
VOICE.assertVoiceClean(`${f}.${cat} “${v.title}” → ${field}, resolved as ${labels[r]}`, v[field], [
resolveSideQuestText(v[field], ctx),
]);
}
}
}
}
}
assert.ok(resolutions >= 1000, `every pool entry linted in every resolution (${resolutions} resolutions checked)`);
}
// ===========================================================================
console.log('== the patrol’s WAR MODEL — the target is always the posting’s OWN system + the sighted rival is ONE seeded faction ==');
{
// A stand-in GALAXY (the model’s interface: byId Map + jumpGatesFor):
// sys-zenith (zenith) holds a gate to sys-apex (apex) — the HOSTILE GROUND
// (the bounty’s mark’s home, the escort’s trapped transport) — and a gate to
// sys-neutral2 (neutral: never hostile ground for a faction board).
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_APEX.id]: [REC_ZENITH.id],
};
return (links[id] ?? []).map((to) => galaxy.byId.get(to)).filter(Boolean);
},
};
const rec = galaxy.byId.get(REC_ZENITH.id);
// THE TARGET RULE — the patrol is ALWAYS the posting’s own system (the old
// neighbor draw is gone — the sighted rival is the enemy, the posting’s
// system is the ground; patrolTargetSystem stands home, always null).
assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 1), null), null, 'no galaxy → null (the posting’s own system stands — ALWAYS, the rule is fixed)');
assert.equal(patrolTargetSystem(rec, Rng.derive(SEED, 't', 2), galaxy), null, 'a galaxy with a hostile neighbor → still null (the patrol stands home — the neighbor rule is gone)');
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’s patrol stands home too');
// THE SIGHTED RIVAL — ONE seeded opposing faction, never both, never a
// pirate: across seeds both rivals stand (the draw is real, not pinned),
// each posting’s pick is stable across calls (the same posting holds the
// same enemy for the whole run — accepts, restarts, saves).
const seen = new Set();
for (let i = 0; i < 128; i++) {
const f = seededOpposingFaction('zenith', Rng.derive(SEED, 't', 100 + i));
assert.ok(f === 'apex' || f === 'mindweave', `seed ${100 + i}: the sighted rival is one of the two rivals (never zenith, never a pirate)`);
seen.add(f);
}
assert.deepEqual([...seen].sort(), ['apex', 'mindweave'], 'both rivals stand across seeds (the draw is live — not pinned to one)');
const a1 = seededOpposingFaction('zenith', Rng.derive(SEED, 't', 42));
const a2 = seededOpposingFaction('zenith', Rng.derive(SEED, 't', 42));
assert.equal(a1, a2, 'a fixed stream → the same rival (deterministic per posting)');
// THE POSTING’S FIELDS — the patrol’s target = its OWN system, and its
// enemyFaction names the sighted rival (the board’s DESTINATION line + the
// scene’s roster read them).
const posts = sideQuestPostingsFor(SEED, rec, 'p1', galaxy).filter((p) => p.category === 'patrol');
assert.ok(posts.length >= 1, 'a faction system posts patrols');
for (const p of posts) {
assert.equal(p.targetSystemId, rec.id, 'the patrol’s target is the POSTING’S OWN system (the war model — in-system)');
assert.equal(p.targetSystemName, rec.name, '…on the posting’s own name');
assert.ok(p.enemyFaction === 'apex' || p.enemyFaction === 'mindweave', 'the sighted rival is ONE of the two rivals (enemyFaction stands)');
}
// Determinism — the same posting holds the same rival + target across calls.
const p1 = sideQuestPostingsFor(SEED, rec, 'p1', galaxy).find((p) => p.category === 'patrol');
const p2 = sideQuestPostingsFor(SEED, rec, 'p1', galaxy).find((p) => p.category === 'patrol');
assert.deepEqual(p2, p1, 'the patrol posting (target + enemyFaction) is stable across calls');
// THE HOSTILE GROUND — the bounty’s mark’s home + the escort’s transport’s
// ground: a NEIGHBORING system one of the two rivals holds (sys-apex here;
// sys-neutral2 is never a rival). A faction board with hostile ground posts
// the bounty + escort; without it, those slots stand EMPTY.
for (const f of ['bounty', 'escort']) {
const g = hostileNeighborSystem(rec, Rng.derive(SEED, 'g', f), galaxy);
assert.ok(g, `a hostile ground stands for a ${f} (a rival’s gate-reachable neighbor)`);
assert.equal(g.id, REC_APEX.id, `the ${f}'s ground is the rival’s system (sys-apex — the neutral neighbor is excluded)`);
assert.equal(g.faction, 'apex', '…held by that rival');
}
// A board whose only neighbor is neutral: the bounty + escort slots stand EMPTY.
const lonely = {
byId: new Map([
[REC_ZENITH.id, { ...REC_ZENITH, d: 0.6 }],
['sys-neutral2', { id: 'sys-neutral2', name: 'Drift Line', faction: null, d: 0.7 }],
]),
jumpGatesFor: () => [galaxy.byId.get('sys-neutral2')],
};
assert.equal(hostileNeighborSystem(rec, Rng.derive(SEED, 'g', 'lonely'), lonely), null, 'no rival holds a gate-reachable neighbor → no ground (the slot is empty)');
const lposts = sideQuestPostingsFor(SEED, rec, 'p1', lonely);
assert.ok(!lposts.some((p) => p.category === 'bounty' || p.category === 'escort'), '…and the board posts NEITHER a bounty NOR an escort (the slots stand empty — the patrol stands alone)');
assert.ok(lposts.some((p) => p.category === 'patrol'), '…the patrol still stands (its ground is the posting’s own system)');
// A board WITH hostile ground posts all three (the war model’s full set).
const full = sideQuestPostingsFor(SEED, rec, 'p1', galaxy);
for (const cat of ['patrol', 'bounty', 'escort']) {
assert.ok(full.some((p) => p.category === cat), `a hostile-ground board posts a ${cat}`);
}
const fb = full.find((p) => p.category === 'bounty');
assert.equal(fb.targetSystemId, REC_APEX.id, 'the bounty’s target = the mark’s home (the hostile neighbor)');
assert.equal(fb.enemyFaction, 'apex', '…its mark is a member of that rival');
const fe = full.find((p) => p.category === 'escort');
assert.equal(fe.enemySystemId, REC_APEX.id, 'the escort’s transport is trapped in the hostile neighbor');
assert.equal(fe.destSystemId, rec.id, '…and its DELIVERY is the board’s own system (the friendly territory)');
assert.equal(fe.enemyFaction, 'apex', '…hunted by its captors');
// The non-patrol categories (the stubs) carry no target fields.
setEnabled({ patrol: true, delivery: true });
try {
const all = sideQuestPostingsFor(SEED, rec, 'p1', 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, patrolXpScale } = 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: 1 }, '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)');
assert.equal(a.xpScale, 1, 'tier 1 (level 3): the low band carries no scale — the payout pays FULL worth');
// --- THE BAND’S ROSTER + WAVE TIERS (level × hops from home) ---------
assert.deepEqual(patrolEnemyRoster(1, 1), { raider: 1 }, 'tier 1: level 1 at 1 hop → the established type');
assert.deepEqual(patrolEnemyRoster(5, 2), { raider: 1 }, 'tier 1: level 5 at 2 hops → the established type');
assert.deepEqual(patrolEnemyRoster(3, 0), { raider: 1 }, 'tier 1: level 3 at home (0 hops — out of the band’s 1–2) → the fallback (the established type)');
assert.deepEqual(patrolEnemyRoster(0, 1), { raider: 1 }, 'band: level 0 (below tier 1’s 1) → the fallback');
// THE LEVEL-6 JUMP — a mixed roster + a fatter wave (4 at a time, ten deep).
assert.deepEqual(patrolEnemyRoster(6, 1), { raider: 5, spitter: 3, bomber: 2 }, 'tier 2 (6–8): the jump mix (raider anchor + spitter + bomber)');
assert.deepEqual(patrolEncounterWave(6, 1), { kind: 'rolling', total: 10, maxConcurrent: 4, replacePerKill: 1 }, 'tier 2: the wave grows 6→10 with four at a time');
assert.ok('stinger' in patrolEnemyRoster(9, 3), 'tier 3 (9–12) brings in the stinger');
assert.deepEqual(patrolEnemyRoster(9, 3), { raider: 4, spitter: 2, stinger: 2, gunner: 2, bomber: 1 }, 'tier 3: the mix widens (stinger + gunner join)');
assert.deepEqual(patrolEncounterWave(9, 3), { kind: 'rolling', total: 14, maxConcurrent: 5, replacePerKill: 1 }, 'tier 3: 14 at five');
assert.deepEqual(patrolEnemyRoster(13, 2), { raider: 3, spitter: 2, stinger: 2, gunner: 2, hunter: 2, bomber: 1 }, 'tier 4 (13–18): the hunter (missiles) joins');
assert.deepEqual(patrolEncounterWave(13, 2), { kind: 'rolling', total: 18, maxConcurrent: 6, replacePerKill: 1 }, 'tier 4: 18 at six');
assert.deepEqual(patrolEnemyRoster(19, 4), { raider: 3, spitter: 2, stinger: 2, gunner: 2, hunter: 2, barrage: 2 }, 'tier 5 (19+, the cap): the barrage rounds the mix out');
assert.deepEqual(patrolEncounterWave(40, 4), { kind: 'rolling', total: 20, maxConcurrent: 6, replacePerKill: 1 }, 'tier 5: the seats + total CAP at 20 / six (past here the mix is the pressure)');
assert.ok(Object.keys(patrolEnemyRoster(99, 1)).length === 6, 'the top tier’s mix holds its six types at any level past it');
// The tier’s counts override (the higher tiers skew toward three contacts).
assert.deepEqual(patrolEncounterCounts(5, 1), [[1, 0.2], [2, 0.2], [3, 0.6]], 'tier 1: the shared odds stand (20 / 20 / 60)');
assert.deepEqual(patrolEncounterCounts(14, 2), [[1, 0.1], [2, 0.3], [3, 0.6]], 'tier 4: the band’s own odds (10 / 30 / 60)');
assert.deepEqual(patrolEncounterCounts(6, 1), [[1, 0.2], [2, 0.2], [3, 0.6]], 'tier 2: no band odds → the shared table');
// The tier’s XP SCALE (the subtle dial — the fatter wave’s worth, trimmed
// so the payout’s pace stays with the curve; the low band pays full).
assert.equal(patrolXpScale(3, 1), 1, 'tier 1: full worth (no scale)');
assert.equal(patrolXpScale(6, 1), 0.7, 'tier 2 (the jump): 0.7');
assert.equal(patrolXpScale(9, 3), 0.55, 'tier 3: 0.55');
assert.equal(patrolXpScale(14, 2), 0.5, 'tier 4: 0.5');
assert.equal(patrolXpScale(20, 4), 0.45, 'tier 5 (the cap): 0.45');
const a6 = derivePatrolEncounters(SEED, 'sq-x-patrol-6', 'sys-x', wps5, 6, 1);
assert.equal(a6.xpScale, 0.7, 'a level-6 spec fixes the tier’s scale on the route (1, never re-drawn)');
// --- THE WAR MODEL — the sighted rival’s OWN roster (the posting’s
// enemyFaction) — not the pirate pool, not both rivals, never neutral. ---
const zc = derivePatrolEncounters(SEED, 'sq-x-patrol-z', 'sys-x', wps5, 6, 1, 'zenith');
const zkeys = Object.keys(zc.roster);
assert.ok(zkeys.length >= 1, 'a sighted-rival contact’s roster stands');
assert.ok(zkeys.every((t) => t.startsWith('zenith_')), `the contact’s roster is the SIGHTED rival’s own fighters (got ${zkeys.join(',')})`);
assert.ok(!zkeys.includes('raider') && !zkeys.includes('bomber'), '…and NO pirate type (the war-model contact is faction, not pirate)');
const ac = derivePatrolEncounters(SEED, 'sq-x-patrol-a', 'sys-x', wps5, 6, 1, 'apex');
assert.ok(Object.keys(ac.roster).every((t) => t.startsWith('apex_')), `a different rival draws ITS OWN roster (got ${Object.keys(ac.roster).join(',')})`);
assert.equal(zc.enemyFaction, 'zenith', 'the spec records the sighted rival (the roster’s law rides the save)');
// A neutral patrol (no enemyFaction) keeps the pirate roster (unchanged).
const nc = derivePatrolEncounters(SEED, 'sq-x-patrol-n', 'sys-x', wps5, 6, 1, null);
assert.ok(!Object.keys(nc.roster).some((t) => /^(apex|zenith|mindweave)_/.test(t)), 'a neutral patrol’s roster carries NO faction type (the classic pirate mix)');
// --- 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)');
assert.equal(patrolEncounterXp(enc([1], { xpScale: 0.5 })), Math.round(6 * XP.raider * 0.5), 'a spec’s xpScale trims the payout (round(worth × scale))');
assert.equal(patrolEncounterXp(enc([1], { xpScale: 0 })), 0, 'a zero scale pays nothing (the dial’s floor)');
}
// ===========================================================================
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), false, 'a LATER waypoint doesn’t log (the route is a path — 1, 2, 3, …)');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), true, 'the FIRST waypoint logs');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 0), false, 'the idempotent visit (a per-frame pass can’t double-log)');
assert.equal(rtr.visitWaypoint('sq-r-patrol-0', 1), true, 'the next waypoint logs once the first stands logged');
assert.deepEqual(rtr.routeFor('sq-r-patrol-0').visited, [0, 1], '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 }, xpScale: 0.7 };
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: 1 }, 'the legacy array roster (old saves) normalizes to the weighted table (the established type)');
assert.equal(enc.xpScale, 0.7, 'the spec’s xpScale is fixed on the route (the payout’s dial)');
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. The space is PER-WORLD now: each system’s SURFACE
// WORLDS (the fake galaxy’s planets + free-space stations) × the slot
// range × the four types.
const fakeGalaxy = {
records: RECORDS,
byId: new Map(RECORDS.map((r) => [r.id, r])),
ensureContent: (id) => {
const rec = RECORDS.find((r) => r.id === id) ?? null;
if (!rec) return null;
return {
planets: [1, 2].map((n) => ({ id: `${id}-p${n}`, name: `${rec.name ?? id} ${n}`, ordinal: n })),
settlements: [{ id: `${id}-s1`, name: `${rec.name ?? id} Outpost`, anchor: { type: 'space' } }],
jumps: [],
};
},
};
const known = allSideQuestJobIds(fakeGalaxy);
const [lo2, hi2] = sideQuestCountRange();
const WORLDS_PER_SYSTEM = 3; // 2 planets + 1 free-space station (the fake galaxy’s content)
assert.equal(known.size, RECORDS.length * WORLDS_PER_SYSTEM * hi2 * SIDE_QUEST_CATEGORIES.length, 'the space = worlds × the slot range × the four types');
assert.ok(known.has('sq-sys-zenith-p1-patrol-0'), 'a real job id stands in the space');
assert.ok(known.has('sq-sys-zenith-s1-patrol-0'), 'a free-space STATION’s job id stands in the space');
assert.ok(!known.has('sq-sys-zenith-patrol-0'), 'an OLD per-system id (no world key) is outside the space (Path A — it drops)');
assert.ok(!known.has('sq-nowhere-patrol-0'), 'a foreign system id is outside the space');
const guarded = SideQuestState.fromJSON(
{
accepted: ['sq-sys-zenith-p1-patrol-0', 'sq-nowhere-patrol-0'],
completed: [],
claimed: [],
activeId: null,
routes: { 'sq-sys-zenith-p1-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-p1-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-p1-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 ==');
{
// This section pins the PATROL’s seams (setRoute / updatePatrol /
// patrolProgress / the checklist). Stand PATROL alone so the board’s
// seeded single slot on the neutral record is guaranteed to be a patrol
// (with the live set wider, that one slot could land on bounty/escort).
setEnabled({ patrol: true });
// 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>-p1-patrol-<slot>), and its deterministic reward.
const posts = sideQuestPostingsFor(SEED, REC_NEUTRAL, 'p1');
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. (job2 comes
// from a DIFFERENT world’s board — the per-world count is 1–3, so this
// world’s single patrol isn’t guaranteed a sibling; another world’s
// board always stands its own patrols.)
let job2 = null;
for (const wk of ['p2', 'p3', 'p4']) {
job2 = sideQuestPostingsFor(SEED, REC_NEUTRAL, wk).find((p) => p.category === 'patrol' && p.id !== job.id);
if (job2) break;
}
assert.ok(job2, 'a sibling contract stands on another world’s board');
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();
// The per-world count is 1–3, so one world may not stand three distinct
// contracts — pool the world’s own boards in order (p1, p2, …). The
// ledger doesn’t care which world posted a contract (the id is the key).
const pool = [];
for (const wk of ['p1', 'p2', 'p3', 'p4']) {
for (const p of sideQuestPostingsFor(SEED, REC_NEUTRAL, wk)) {
if (!pool.some((q) => q.id === p.id)) pool.push(p);
}
if (pool.length >= 3) break;
}
const a = pool.find((p) => p.category === 'patrol');
const b = pool.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 = pool.find((p) => p.id !== a.id && p.id !== b.id);
assert.ok(other, 'a third distinct contract stands (the world boards pool)');
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');