1006 lines
65 KiB
JavaScript
1006 lines
65 KiB
JavaScript
// dev/sidequests.test.mjs — the JOB BOARD’s model, verified headless
|
||
// (node dev/sidequests.test.mjs — no browser, no Phaser render).
|
||
//
|
||
// What this pins (the board’s contract — data/sidequests.json):
|
||
// - the CATEGORY GATE — the four types (patrol / delivery / bounty /
|
||
// escort) are all accounted for; 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');
|