610 lines
41 KiB
JavaScript
610 lines
41 KiB
JavaScript
// dev/bounty-hunts.test.mjs — the BOUNTY HUNT side quest, verified headless
|
||
// (node dev/bounty-hunts.test.mjs). The JOB BOARD's second live type: a
|
||
// level-2+ contract posted only in Neutral systems, flown as a strictly
|
||
// sequential 3-waypoint patrol of a pirate leader's lanes, resolving to a
|
||
// HIDDEN encounter chain (bounty at leg 1 ≈20% → else leg 2 ≈16% → else
|
||
// guaranteed at leg 3; an independent 50% picket wave on each pre-bounty leg)
|
||
// and ending on the mark's DEATH — a rolling wave of nine, three at a time,
|
||
// eight raiders plus the dossier's own frame flown double-strength.
|
||
//
|
||
// What this pins (the design's contract):
|
||
// - THE GATE — the hunt posts at combat level 2+ (data → categories.bounty)
|
||
// and below it the posting still stands in its seeded slot but reads
|
||
// LOCKED and the SCENE refuses the accept (no ledger entry, no route).
|
||
// - NEUTRAL-ONLY — `contexts:['neutral']` keeps hunts off Apex / MindWeave /
|
||
// Zenith boards, and descriptions.faction.bounty stays the documented
|
||
// empty stub (a faction board could otherwise fall back to the pirate list).
|
||
// - THE WANTED LIST — 20 dossiers: unique leaders/epithets/titles, every
|
||
// declared `craft` a real data/combat.json enemyTypes id, all seven frames
|
||
// used, and the posting's dossier carrying name/epithet/crime/craft.
|
||
// - THE CHAIN'S ODDS — a seeded distribution over the plan fork: ≈20% / 16%
|
||
// / 64%, pickets on PRE-bounty legs only at ≈50%, never on the mark's leg
|
||
// or behind it, and always exactly one mark.
|
||
// - DETERMINISM + THE SAVE — the same (seed, jobId, system, level) rolls the
|
||
// same plan; the plan is FIXED on the route record and survives a save
|
||
// round-trip (toJSON → fromJSON) with its runtime marks intact.
|
||
// - THE WAVES — skirmish: one rolling wave, six frame-0 raiders, three at a
|
||
// time, replace-per-kill. Bounty: one rolling wave of nine (8 raiders + the
|
||
// dossier's frame), three at a time, the elite deployed with the opening
|
||
// group at ×2 hull/shields and ×2 damage and NAMED.
|
||
// - THE PAYOUT — bountyHuntXp is the worth of everything the hunt throws
|
||
// (mark × its scale + escort + every rolled picket wave), fixed at the
|
||
// accept and paid ONCE by the claim.
|
||
// - SEQUENTIAL LAW — only the NEXT leg logs (a patrol's any-order law stays);
|
||
// the compass/beacon track that one leg only.
|
||
// - COMPLETION ONLY BY THE KILL — flying every leg finishes nothing; the
|
||
// mark's death checks the legs off, and only after the last escort stops
|
||
// shooting does the contract go COMPLETE (then a single claim).
|
||
// - NO SPOILERS — no player-facing string, posting field or copy line says
|
||
// which leg holds the bounty or a picket wave.
|
||
//
|
||
// The model is js/quests/SideQuests.js; the flying is js/scenes/GameScene.js
|
||
// (driven here off its prototype over stubs, the dev/delivery-launch.test.mjs
|
||
// pattern) and the combat seam is js/combat/Waves.js + CombatSystem.js.
|
||
|
||
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)
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const root = join(__dirname, '..');
|
||
|
||
// --- The real data (data/*.json) in the config singleton -----------------
|
||
const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href);
|
||
const fs = await import('node:fs');
|
||
const dataDir = join(root, '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 SQ = configData.sidequests; // data/sidequests.json (categories / bounty / descriptions / ui)
|
||
|
||
const S = await import(pathToFileURL(join(root, 'js/quests/SideQuests.js')).href);
|
||
const { SideQuestState } = S;
|
||
const { GameScene } = await import(pathToFileURL(join(root, 'js/scenes/GameScene.js')).href);
|
||
const { QuestState } = await import(pathToFileURL(join(root, 'js/quests/QuestState.js')).href);
|
||
|
||
let failures = 0;
|
||
const check = (label, cond, extra = '') => {
|
||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${extra && !cond ? ` — ${extra}` : ''}`);
|
||
if (!cond) failures++;
|
||
};
|
||
const section = (t) => console.log(`\n== ${t} ==`);
|
||
|
||
// ==========================================================================
|
||
section('THE CATEGORY — BOUNTY HUNT is live, level 2+, Neutral boards only');
|
||
{
|
||
const meta = S.sideQuestCategoryMeta('bounty');
|
||
check('the category is enabled with its own label + accent', meta?.enabled === true && meta.label === 'BOUNTY HUNT' && /^#/.test(meta.accent));
|
||
check('the gate is combat level 2', meta?.minLevel === 2 && S.bountyMinLevel() === 2, `minLevel=${meta?.minLevel}`);
|
||
check('it posts in Neutral systems only', JSON.stringify(meta?.contexts) === JSON.stringify(['neutral']));
|
||
const neutral = S.enabledSideQuestCategories('neutral');
|
||
const faction = S.enabledSideQuestCategories('faction');
|
||
check('a Neutral board draws it', neutral.includes('bounty'), neutral.join(','));
|
||
check('a FACTION board never does', !faction.includes('bounty'), faction.join(','));
|
||
check("the faction wanted-list stays the documented empty stub", JSON.stringify(SQ.descriptions.faction.bounty) === '[]');
|
||
check('the hunt carries no contact-red beacon color (patrol law, deliberately)', SQ.bounty?.marker?.contactColor === undefined);
|
||
const bandIds = SQ.bounty.encounters.bands.map((b) => b.id);
|
||
check('the bands stand: the live hunter + the stubbed marksman inheriting it', JSON.stringify(bandIds) === JSON.stringify(['hunter', 'marksman']) && SQ.bounty.encounters.fallbackBand === 'hunter');
|
||
const mm = S.bountyRules(9);
|
||
check('level 6+ lands on the stub band and inherits the hunter numbers (no content cliff)', mm.id === 'marksman' && mm.bountyChance === 0.2 && mm.bountyWave.total === 9, JSON.stringify(mm).slice(0, 160));
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('THE WANTED LIST — 20 Neutral dossiers');
|
||
{
|
||
const pool = SQ.descriptions.neutral.bounty;
|
||
const valid = new Set(Object.keys(DATA.combat.enemyTypes).filter((k) => typeof DATA.combat.enemyTypes[k] === 'object'));
|
||
check('twenty dossiers', Array.isArray(pool) && pool.length === 20, `len=${pool?.length}`);
|
||
check('every leader is unique', new Set(pool.map((e) => e.name)).size === pool.length);
|
||
check('every epithet is unique', new Set(pool.map((e) => e.epithet)).size === pool.length);
|
||
check('every title is unique', new Set(pool.map((e) => e.title)).size === pool.length);
|
||
const fields = ['title', 'issuer', 'name', 'epithet', 'crime', 'craft', 'craftLabel', 'body'];
|
||
const missing = pool.filter((e) => !fields.every((k) => typeof e[k] === 'string' && e[k].trim() !== '')).map((e) => e.title);
|
||
check('every dossier carries title/issuer/name/epithet/crime/craft/craftLabel/body', missing.length === 0, missing.join(','));
|
||
const bad = pool.filter((e) => !valid.has(e.craft)).map((e) => `${e.name}:${e.craft}`);
|
||
check("every declared craft is a real combat.enemyTypes id", bad.length === 0, bad.join(','));
|
||
const frames = new Set(pool.map((e) => e.craft));
|
||
const eliteFrames = new Set(SQ.bounty.encounters.bountyWave.eliteFrames);
|
||
check('the stories spread across all seven frames', [...eliteFrames].every((f) => frames.has(f)), [...frames].join(','));
|
||
check('every frame a story names is deployable by the band', [...frames].every((f) => eliteFrames.has(f)));
|
||
const longBodies = pool.filter((e) => e.body.length < 60).map((e) => e.title);
|
||
check('every dossier reads like a wanted notice, not a placeholder', longBodies.length === 0, longBodies.join(','));
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('THE BOARD — rows on Neutral boards, LOCKED below level 2');
|
||
{
|
||
const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40, zone: 'mid', type: 'void', capital: false };
|
||
const at = (level) => S.sideQuestPostingsFor(1234, REC, null, level);
|
||
const low = at(1), high = at(2), noLevel = at(null);
|
||
const huntsLow = low.filter((p) => p.category === 'bounty');
|
||
check('a Neutral board posts BOUNTY HUNT contracts', huntsLow.length > 0 || at(7).some((p) => p.category === 'bounty'), `low=${huntsLow.length} high=${high.filter((p) => p.category === 'bounty').length}`);
|
||
const anyHunt = [...low, ...high, ...at(3), ...at(4), ...at(5)].find((p) => p.category === 'bounty');
|
||
check('the hunt row carries the category label + accent', anyHunt?.label === 'BOUNTY HUNT' && /^#/.test(anyHunt?.accent));
|
||
for (const p of huntsLow) {
|
||
check(`locked at level 1 (${p.title})`, p.locked === true && p.minLevel === 2);
|
||
}
|
||
const lockedCopy = S.bountyLockCopy();
|
||
const subCopy = S.bountyLockCopy(true);
|
||
check('the lock badge reads REQUIRES LEVEL 2', /REQUIRES LEVEL 2/.test(lockedCopy?.text ?? ''), lockedCopy?.text);
|
||
check('the detail line names the level too', /LEVEL 2/.test(subCopy?.text ?? ''), subCopy?.text);
|
||
const deny = S.bountyDenyCopy();
|
||
check('the scene’s refusal exists and names the level', /LEVEL 2/.test(deny?.text ?? ''), deny?.text);
|
||
const huntHigh = high.filter((p) => p.category === 'bounty');
|
||
for (const p of huntHigh) check(`open at level 2 (${p.title})`, p.locked === false && p.minLevel === 2);
|
||
check('an unknown level stands the board open (the gate is the SCENE’s read)', noLevel.every((p) => p.locked === false));
|
||
check('sideQuestAcceptable refuses below and opens at the gate', S.sideQuestAcceptable({ category: 'bounty' }, 1).locked === true && S.sideQuestAcceptable({ category: 'bounty' }, 2).ok === true);
|
||
check('patrol stays level-1 (untouched)', S.sideQuestAcceptable({ category: 'patrol' }, 1).ok === true);
|
||
|
||
// The dossier rides the posting (the readout's source) and the hunt's own
|
||
// plan does NOT.
|
||
for (const p of huntHigh.slice(0, 3)) {
|
||
const d = p.dossier;
|
||
check(`the posting carries its dossier (${p.title})`, !!d && !!d.name && !!d.epithet && !!d.crime && !!d.craft && !!d.craftLabel);
|
||
const json = JSON.stringify(p);
|
||
check(`no plan leaks into the posting (${p.title})`, !/bountyAt|pickets|eliteType|"legs"|skirmish/i.test(json));
|
||
}
|
||
check('the hunt flies its OWN system (no DESTINATION line on the board)', huntHigh.every((p) => p.targetSystemId === p.systemId && p.targetSystemName === p.systemName));
|
||
|
||
// The faction boards.
|
||
for (const f of ['apex', 'mindweave', 'zenith']) {
|
||
const rec = { id: `sys-${f}`, name: `${f} Reach`, faction: f, d: 30, zone: 'mid', type: 'void', capital: false };
|
||
for (const lvl of [1, 2, 5]) {
|
||
const posts = S.sideQuestPostingsFor(99, rec, null, lvl);
|
||
check(`${f} boards never post a hunt (level ${lvl})`, posts.every((p) => p.category !== 'bounty'), posts.map((p) => p.category).join(','));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('THE CHAIN — seeded distribution reads 20% / 16% / 64%');
|
||
{
|
||
const N = 20000;
|
||
const at = [0, 0, 0];
|
||
let picketRolls = 0, pickets = 0, legsTotal = 0, onAfterMark = 0, marks = 0;
|
||
for (let i = 0; i < N; i++) {
|
||
// Every draw rides its OWN contract in its OWN system: the chain's fork is
|
||
// keyed by (seed, jobId, target), so a repeated pair would repeat its plan.
|
||
const plan = S.deriveBountyPlan(20260920 + (i % 97), `sq-sys-${i}-bounty-${i % 4}`, `sys-${i}`, 3);
|
||
legsTotal += plan.legs;
|
||
at[plan.bountyAt]++;
|
||
marks += plan.pickets.filter((v, idx) => v && idx < plan.bountyAt).length;
|
||
for (let k = 0; k < plan.pickets.length; k++) {
|
||
if (k >= plan.bountyAt) {
|
||
if (plan.pickets[k]) onAfterMark++;
|
||
continue;
|
||
}
|
||
picketRolls++;
|
||
if (plan.pickets[k]) pickets++;
|
||
}
|
||
}
|
||
const p1 = at[0] / N, p2 = at[1] / N, p3 = at[2] / N, rate = pickets / picketRolls;
|
||
check('leg 1 holds the mark ≈20% of contracts', Math.abs(p1 - 0.2) < 0.012, `${(p1 * 100).toFixed(2)}%`);
|
||
check('leg 2 ≈16% (20% of what leg 1 missed)', Math.abs(p2 - 0.16) < 0.012, `${(p2 * 100).toFixed(2)}%`);
|
||
check('leg 3 takes every remaining contract (the guarantee)', Math.abs(p3 - 0.64) < 0.012, `${(p3 * 100).toFixed(2)}%`);
|
||
check('exactly three legs every time', legsTotal === N * 3, `${legsTotal / N}`);
|
||
check('pickets roll ≈50% on each pre-bounty leg', Math.abs(rate - 0.5) < 0.01, `${(rate * 100).toFixed(2)}%`);
|
||
check('NO picket ever rolls on the mark’s leg or behind it', onAfterMark === 0, `${onAfterMark}`);
|
||
const p = S.deriveBountyPlan(7, 'sq-x-bounty-0', 'sys-free', 3);
|
||
check('the picket list is one mark per leg BEFORE the bounty', p.pickets.length === p.bountyAt || p.pickets.length === p.legs - 1);
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('DETERMINISM — the roll is a function of its keys, not of history');
|
||
{
|
||
const a = S.deriveBountyPlan(4242, 'sq-sys-free-bounty-0', 'sys-free', 3, { craft: 'gunner', mark: { name: 'A', epithet: 'E' } });
|
||
// Interleave other work (a different job, system, level) — the fork is labelled.
|
||
S.deriveBountyPlan(11, 'sq-other-bounty-2', 'other-system', 5);
|
||
S.deriveBountyPlan(99, 'sq-third-bounty-1', 'third', 2);
|
||
const b = S.deriveBountyPlan(4242, 'sq-sys-free-bounty-0', 'sys-free', 3, { craft: 'gunner', mark: { name: 'A', epithet: 'E' } });
|
||
check('the same keys roll the same plan (scene restarts, re-reads)', JSON.stringify(a) === JSON.stringify(b));
|
||
const otherSystem = S.deriveBountyPlan(4242, 'sq-sys-free-bounty-0', 'other-system', 3);
|
||
const otherJob = S.deriveBountyPlan(4242, 'sq-sys-free-bounty-1', 'sys-free', 3);
|
||
check('another system / another contract rolls its own plan', JSON.stringify(a) !== JSON.stringify({ ...otherSystem, mark: a.mark }) || JSON.stringify(a) !== JSON.stringify(otherJob));
|
||
const declared = S.deriveBountyPlan(5, 'sq-dossier-bounty-0', 'sys-free', 3, { craft: 'barrage' });
|
||
check('the dossier’s own frame is the mark’s frame (story and fight agree)', declared.eliteType === 'barrage');
|
||
const typo = S.deriveBountyPlan(5, 'sq-typo-bounty-0', 'sys-free', 3, { craft: 'not-a-craft' });
|
||
check('a hand-edited craft falls back to a deployable frame, never a broken fight', SQ.bounty.encounters.bountyWave.eliteFrames.includes(typo.eliteType), typo.eliteType);
|
||
const band = S.bountyRules(1); // under the gate: the fallback band still stands
|
||
check('a level below the band list still resolves (the fallback band)', typeof band.id === 'string' && band.legs[0] === 3);
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('THE WAVES — six raiders three at a time; nine with the mark');
|
||
{
|
||
const plan = S.deriveBountyPlan(31, 'sq-wave-bounty-0', 'sys-free', 3, { craft: 'hunter', mark: { name: 'Test Mark', epithet: 'THE TEST' } });
|
||
const sk = S.bountySkirmishScript(plan).waves[0];
|
||
check('the picket wave is ONE rolling wave', S.bountySkirmishScript(plan).waves.length === 1 && sk.kind === 'rolling');
|
||
check('the picket wave is six deep, three at a time, one replaced per kill', sk.total === 6 && sk.maxConcurrent === 3 && sk.replacePerKill === 1, JSON.stringify(sk));
|
||
check('the picket wave is raiders only (no elite rides it)', Object.keys(sk.roster).join(',') === 'raider' && !sk.elite);
|
||
|
||
const en = S.bountyEngagementScript(plan).waves[0];
|
||
check('the bounty wave is ONE rolling wave', S.bountyEngagementScript(plan).waves.length === 1 && en.kind === 'rolling');
|
||
check('nine deep, three at a time', en.total === 9 && en.maxConcurrent === 3, JSON.stringify(en));
|
||
check('exactly eight raiders plus the dossier’s frame', en.roster.raider === 8 && en.roster.hunter === 1 && Object.values(en.roster).reduce((a, b) => a + b, 0) === 9, JSON.stringify(en.roster));
|
||
check('the mark is the declared frame and carries ×2 hull / ×2 damage', en.elite?.type === 'hunter' && en.elite.healthScale === 2 && en.elite.damageScale === 2, JSON.stringify(en.elite));
|
||
check('the mark is NAMED (the toast/comms can say who fell)', en.elite?.name === 'Test Mark');
|
||
for (const frame of SQ.bounty.encounters.bountyWave.eliteFrames) {
|
||
const p = S.deriveBountyPlan(88, `sq-frame-bounty-0`, 'sys-free', 3, { craft: frame });
|
||
const w = S.bountyEngagementScript(p).waves[0];
|
||
check(`frame ${frame} deploys as the named elite beside eight raiders`, w.elite.type === frame && w.roster[frame] === 1 && w.roster.raider === 8 && w.total === 9);
|
||
}
|
||
|
||
// The combat seam itself (js/combat/Waves.js): the mark rides the OPENING group.
|
||
const { normalizeWaves, resolveRoster, waveDefaults } = await import(pathToFileURL(join(root, 'js/combat/Waves.js')).href);
|
||
const norm = normalizeWaves({ waves: [en], kind: 'rolling' }, waveDefaults());
|
||
check('the wave survives normalization with its elite block', norm.ok === true && norm.waves[0].elite?.type === 'hunter');
|
||
const order = resolveRoster(norm.waves[0]);
|
||
check('the force is still nine deep', order.length === 9, `${order.length}`);
|
||
const headIsMark = typeof order[0] === 'object' && order[0].type === 'hunter';
|
||
check('the mark deploys WITH the opening group (head of the spawn order)', headIsMark, JSON.stringify(order[0]));
|
||
const marks = order.filter((u) => typeof u === 'object');
|
||
check('exactly ONE mark in the force', marks.length === 1);
|
||
check('the escort is the eight raiders', order.filter((u) => u === 'raider').length === 8);
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('THE PAYOUT — bountyHuntXp is the worth of the whole hunt');
|
||
{
|
||
// The worth the payout reads is the RUNTIME enemy config (js/combat/EnemyTypes.js
|
||
// merges combat.json's standing enemy block with each type's own — a raider
|
||
// carries only its frame in the data and takes its xp from the shared block).
|
||
const { enemyTypeConfig } = await import(pathToFileURL(join(root, 'js/combat/EnemyTypes.js')).href);
|
||
const raider = enemyTypeConfig('raider').xp;
|
||
const gunner = enemyTypeConfig('gunner').xp;
|
||
const mk = (pickets) => S.deriveBountyPlan(12, `sq-xp-${pickets.join('')}`, 'sys-free', 3, { craft: 'gunner' });
|
||
const plan = mk([]);
|
||
const withPicket = S.bountyHuntXp({ ...plan, pickets: [true, false], bountyAt: 2 });
|
||
const without = S.bountyHuntXp({ ...plan, pickets: [false, false], bountyAt: 2 });
|
||
const expectedBase = 8 * raider + gunner * 2;
|
||
check('the base pays the escort plus the mark at its scale', without === Math.round(expectedBase), `${without} vs ${expectedBase}`);
|
||
check('every rolled picket wave adds its own worth', withPicket - without === 6 * raider, `Δ=${withPicket - without}`);
|
||
const eliteTypes = SQ.bounty.encounters.bountyWave.eliteFrames;
|
||
const spread = eliteTypes.map((f) => S.bountyHuntXp({ ...plan, eliteType: f }));
|
||
check('a beefier frame is worth more than a cheap one', Math.max(...spread) > Math.min(...spread), spread.join(','));
|
||
check('the payout is finite and positive', spread.every((v) => Number.isFinite(v) && v > 0));
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('SEQUENTIAL LAW — only the NEXT leg logs (patrol law untouched)');
|
||
{
|
||
const waypoints = [[0, 0], [1000, 0], [2000, 0]];
|
||
const plan = S.deriveBountyPlan(3, 'sq-seq-bounty-0', 'sys-free', 3, { craft: 'warden' });
|
||
const st = new SideQuestState();
|
||
st.accept('sq-seq-bounty-0');
|
||
st.setActive('sq-seq-bounty-0');
|
||
check('a hunt route stands on the ledger', st.setRoute('sq-seq-bounty-0', { kind: 'bounty', targetSystemId: 'sys-free', waypoints, credits: 500, xp: 200, bounty: plan }));
|
||
const route = st.routeFor('sq-seq-bounty-0');
|
||
check("the record keeps the hunt's kind", route.kind === 'bounty' && route.bounty);
|
||
check('the first leg is the only target', S.nextBountyWaypoint(route)?.index === 0);
|
||
check('a later leg cannot be logged out of order', st.visitWaypoint('sq-seq-bounty-0', 2) === false && st.routeFor('sq-seq-bounty-0').visited.length === 0);
|
||
check('legs log in order', st.visitWaypoint('sq-seq-bounty-0', 0) === true && st.visitWaypoint('sq-seq-bounty-0', 1) === true);
|
||
check('a double visit does not double-log', st.visitWaypoint('sq-seq-bounty-0', 1) === false);
|
||
const r2 = st.routeFor('sq-seq-bounty-0');
|
||
check('the beacon now points at leg 3', S.nextBountyWaypoint(r2)?.index === 2 && S.nextBountyWaypoint(r2).leg === 3 && S.nextBountyWaypoint(r2).of === 3);
|
||
check('flying every leg completes NOTHING by itself', (st.visitWaypoint('sq-seq-bounty-0', 2), S.bountyRouteComplete(st.routeFor('sq-seq-bounty-0')) === false));
|
||
check('every leg is logged', st.routeFor('sq-seq-bounty-0').visited.length === 3);
|
||
|
||
// The patrol's own law, beside it (the regression guard).
|
||
const pst = new SideQuestState();
|
||
pst.accept('sq-patrol');
|
||
pst.setRoute('sq-patrol', { targetSystemId: 'sys-free', waypoints });
|
||
check('a patrol still logs in ANY order', pst.visitWaypoint('sq-patrol', 2) === true && pst.visitWaypoint('sq-patrol', 0) === true);
|
||
check('S.bountyRouteComplete falls back to patrol law for a patrol record', S.bountyRouteComplete(pst.routeFor('sq-patrol')) === false);
|
||
pst.visitWaypoint('sq-patrol', 1);
|
||
check('…and completes on the last leg', S.bountyRouteComplete(pst.routeFor('sq-patrol')) === true);
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('THE SAVE — a signed contract is a signed contract');
|
||
{
|
||
const JOB = 'sq-sys-free-bounty-0'; // a real posting id (sq-<sysId>-<cat>-<slot>) — the save's job space
|
||
const plan = S.deriveBountyPlan(606, JOB, 'sys-free', 3, { craft: 'spitter', mark: { name: 'Save Mark', epithet: 'OF THE SAVE' } });
|
||
const st = new SideQuestState();
|
||
st.accept(JOB);
|
||
st.setActive(JOB);
|
||
st.setRoute(JOB, { kind: 'bounty', targetSystemId: 'sys-free', waypoints: [[0, 0], [900, 0], [1800, 0]], credits: 640, xp: 300, bounty: plan });
|
||
st.visitWaypoint(JOB, 0);
|
||
if (plan.pickets[0]) st.bountySkirmishCleared(JOB, 0);
|
||
|
||
const known = S.allSideQuestJobIds([{ id: 'sys-free', name: 'Free Reach', faction: null, d: 40 }]);
|
||
check('the contract stands inside the save’s own job space', known.has(JOB));
|
||
const round = SideQuestState.fromJSON(JSON.parse(JSON.stringify(st.toJSON())), known);
|
||
const r = round.routeFor(JOB);
|
||
check('the hunt route survives JSON with its kind', r?.kind === 'bounty' && round.isAccepted(JOB) && round.isActive(JOB));
|
||
check("the hidden plan rides the save unchanged", r.bounty.bountyAt === plan.bountyAt && JSON.stringify(r.bounty.pickets) === JSON.stringify(plan.pickets) && r.bounty.eliteType === plan.eliteType, `${r.bounty.bountyAt}/${plan.bountyAt}`);
|
||
check('the mark’s identity rides the save (the comms still know who it was)', r.bounty.mark?.name === 'Save Mark');
|
||
check('the leg marks ride the save', JSON.stringify(r.visited) === JSON.stringify([0]));
|
||
check("the picket's cleared mark rides the save", JSON.stringify(r.bounty.skirmishCleared) === JSON.stringify(plan.pickets[0] ? [0] : []));
|
||
check('the payout rides the save (credits + XP fixed at the accept)', r.credits === 640 && r.xp === 300);
|
||
const rePlan = S.deriveBountyPlan(606, JOB, 'sys-free', 3, { craft: 'spitter', mark: { name: 'Save Mark', epithet: 'OF THE SAVE' } });
|
||
// The chain itself, field by field (the record normalizes its wave SHAPES —
|
||
// what must not move is the rolled plan: which leg, which pickets, which frame).
|
||
const chain = (p) => [p.legs, p.bountyAt, JSON.stringify(p.pickets), p.band, p.raiders, p.raiderType, p.eliteType, p.mark?.name, p.skirmishWave.total, p.bountyWave.total, p.bountyWave.maxConcurrent, p.visitRadius];
|
||
check('the restored plan is the rolled one (a load never re-draws it)', chain(r.bounty).join('|') === chain(plan).join('|'), `${chain(r.bounty).join('|')} vs ${chain(plan).join('|')}`);
|
||
|
||
// A hand-edited record must not manufacture a fight.
|
||
const waypoints = [[0, 0], [900, 0], [1800, 0]];
|
||
const bad = new SideQuestState();
|
||
bad.accept('sq-bad');
|
||
bad.setRoute('sq-bad', { kind: 'bounty', waypoints, bounty: { legs: 3, bountyAt: 9, pickets: [], eliteType: 'raider' } });
|
||
check('a bounty index outside the legs degrades (no plan, no fight)', bad.routeFor('sq-bad').waypoints.length === 3 && bad.routeFor('sq-bad').bounty === null);
|
||
bad.setRoute('sq-bad', { kind: 'bounty', waypoints, bounty: { legs: 3, bountyAt: 2, pickets: [false, false], eliteType: 'not-a-frame' } });
|
||
check('an unknown frame degrades the plan rather than crashing one', bad.routeFor('sq-bad').bounty === null);
|
||
const preHunt = SideQuestState.fromJSON({ accepted: ['sq-sys-free-patrol-1'], routes: { 'sq-sys-free-patrol-1': { waypoints, visited: [0] } } }, known);
|
||
check('a save written before hunts existed loads as a patrol', preHunt.routeFor('sq-sys-free-patrol-1').kind === 'patrol' && preHunt.routeFor('sq-sys-free-patrol-1').bounty === null);
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('THE FLYING — accept → legs → mark → kill → claim (the real scene wiring)');
|
||
{
|
||
const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40, zone: 'mid', type: 'void', capital: false };
|
||
function makeScene({ level, seed = 20260920 }) {
|
||
const s = Object.create(GameScene.prototype);
|
||
s.galaxy = { seed, byId: new Map([[REC.id, REC]]), contentOf: () => null, jumpNetwork: { depth: new Map([[REC.id, 0]]) } };
|
||
s.systemRecord = REC;
|
||
s.systemPlanets = [{ x: -400, y: 0 }, { x: 400, y: 0 }];
|
||
s.systemStations = [];
|
||
s.systemGates = [];
|
||
s.planet = null;
|
||
s.tetherField = { tethers: [{ x: 0, y: 0, radius: 12000 }, { x: -400, y: 0, radius: 9000 }, { x: 400, y: 0, radius: 9000 }] };
|
||
s.sideQuestState = new SideQuestState();
|
||
s.questState = new QuestState(); // no story grants — the tracker's side row stands alone
|
||
s.sfx = [];
|
||
s.playSfx = (n) => { s.sfx.push(n); };
|
||
s.toasts = [];
|
||
s.consoleToast = (text, o = {}) => { s.toasts.push({ text, ...o }); };
|
||
s.credits = 0;
|
||
s.ship = { x: -99999, y: -99999, addCredits(n) { s.credits += n; } };
|
||
s.refreshCreditsHud = () => {};
|
||
s.grants = [];
|
||
s.combat = {
|
||
xp: { level, grant(n, src) { s.grants.push({ n, src }); } },
|
||
waveRun: null,
|
||
started: [],
|
||
halted: 0,
|
||
enemies: [],
|
||
startWaves(script, opts = {}) {
|
||
s.combat.started.push({ script, opts });
|
||
s.combat.waveRun = { state: 'running', complete() { s.combat.waveRun.state = 'done'; opts.onComplete?.(); } };
|
||
},
|
||
haltWaves() {
|
||
s.combat.halted++;
|
||
if (s.combat.waveRun) s.combat.waveRun.state = 'halted';
|
||
},
|
||
cancelWaves() { s.combat.waveRun = null; },
|
||
// CombatSystem._waveTick's HALTED rule (js/combat/CombatSystem.js): the
|
||
// run stays up while the survivors fight, and reads 'done' with the last
|
||
// of them down — which is what the scene waits on to complete the hunt.
|
||
tickWaves() {
|
||
const run = s.combat.waveRun;
|
||
if (run?.state === 'halted' && !s.combat.enemies.some((e) => e.wave && !e.dead)) run.state = 'done';
|
||
},
|
||
};
|
||
return s;
|
||
}
|
||
const huntOf = (s) => S.sideQuestPostingsFor(s.galaxy.seed, REC, s.galaxy, s.combatLevel()).filter((p) => p.category === 'bounty');
|
||
|
||
// ---- the gate refuses at level 1 -------------------------------------------------
|
||
{
|
||
const s = makeScene({ level: 1 });
|
||
const hunts = huntOf(s);
|
||
check('the board lists hunts at level 1 (LOCKED)', hunts.length > 0 && hunts.every((p) => p.locked === true), `${hunts.length}`);
|
||
const res = s.acceptSideQuest(hunts[0].id);
|
||
check('the SCENE refuses the locked accept', res.ok === false && res.locked === true);
|
||
check('nothing was taken and no route stands', s.sideQuestState.acceptedList().length === 0 && s.sideQuestState.routeFor(hunts[0].id) === null);
|
||
check('the refusal says why, from the data', /LEVEL 2/.test(s.toasts.at(-1)?.text ?? ''), s.toasts.at(-1)?.text);
|
||
}
|
||
|
||
// ---- level 2 accepts and stands the lanes ---------------------------------------
|
||
let s, id, route;
|
||
{
|
||
s = makeScene({ level: 2 });
|
||
const hunts = huntOf(s);
|
||
check('the board opens at level 2', hunts.length > 0 && hunts.every((p) => p.locked === false));
|
||
id = hunts[0].id;
|
||
const res = s.acceptSideQuest(id);
|
||
check('the contract is taken', res.ok === true && s.sideQuestState.isAccepted(id));
|
||
route = s.sideQuestState.routeFor(id);
|
||
check('the lanes are exactly three waypoints', route?.waypoints?.length === 3, `${route?.waypoints?.length}`);
|
||
check('the record is a HUNT with its hidden plan', route.kind === 'bounty' && !!route.bounty && Number.isInteger(route.bounty.bountyAt));
|
||
check('the payout is fixed on the record', route.credits > 0 && route.xp === S.bountyHuntXp(route.bounty), `${route.credits}/${route.xp}`);
|
||
check('a second accept never double-takes', s.acceptSideQuest(id).ok === false || s.sideQuestState.routeFor(id).waypoints.length === 3);
|
||
const again = makeScene({ level: 2 });
|
||
again.acceptSideQuest(id);
|
||
check('the same contract rolls the same lanes and plan (deterministic accept)', JSON.stringify(again.sideQuestState.routeFor(id).waypoints) === JSON.stringify(route.waypoints) && again.sideQuestState.routeFor(id).bounty.bountyAt === route.bounty.bountyAt);
|
||
|
||
// The readout seam keeps the hunt's shape: legs + payout, never a contact count.
|
||
const prog = s.patrolProgress(id);
|
||
check('the progress seam reports legs and XP', prog.visited === 0 && prog.total === 3 && prog.xp === route.xp);
|
||
check('a hunt has no contact count to leak (no red line on the board)', prog.contacts === null, JSON.stringify(prog));
|
||
|
||
// ---- sequential check-ins + the hidden engagements --------------------------------
|
||
s.sideQuestState.setActive(id);
|
||
const wp = route.waypoints;
|
||
const at = (i) => { s.ship.x = wp[i][0]; s.ship.y = wp[i][1]; };
|
||
const clearRun = () => { if (s.combat.waveRun?.state === 'running') s.combat.waveRun.complete(); s.combat.enemies = []; };
|
||
|
||
// Park at the LAST leg first: a hunt flies its legs in order, so nothing logs.
|
||
at(2);
|
||
s.updateSideQuestRoute();
|
||
check('flying to leg 3 first logs nothing', s.sideQuestState.routeFor(id).visited.length === 0);
|
||
const compass = s.activeRouteCompassTarget();
|
||
check('the compass points at the NEXT leg only', compass?.kind === 'waypoint' && compass.idx === 0, JSON.stringify(compass));
|
||
check('the hunt’s compass carries its own label/color (never a contact)', /hunt/i.test(String(compass?.typeLabel ?? '')) && String(compass?.color).toLowerCase() === String(SQ.bounty.compass.color).toLowerCase());
|
||
|
||
// Leg 1.
|
||
at(0);
|
||
s.updateSideQuestRoute();
|
||
route = s.sideQuestState.routeFor(id);
|
||
check('leg 1 logs', route.visited.includes(0));
|
||
const plan = route.bounty;
|
||
if (plan.pickets[0]) {
|
||
check('a rolled picket fires on its own leg', s.combat.started.length === 1 && s.combat.started[0].script.waves[0].total === 6, JSON.stringify(s.combat.started.map((x) => x.script.waves[0].total)));
|
||
const toast = s.toasts.at(-1).text;
|
||
check('the picket toast counts hostiles but never names the mark', /INBOUND/.test(toast) && !new RegExp(String(plan.mark?.name ?? '@NOPE@')).test(toast), toast);
|
||
clearRun();
|
||
s.updateSideQuestRoute();
|
||
check('a cleared picket does not re-fire (its witness was the completion)', s.combat.started.length === 1);
|
||
} else {
|
||
check('an unrolled leg fires nothing', s.combat.started.length === 0);
|
||
check('the quiet lane toasts the leg only', /LOGGED/.test(s.toasts.at(-1)?.text ?? ''), s.toasts.at(-1)?.text);
|
||
}
|
||
|
||
// Leg 2 (a mark standing here is sighted; otherwise it is another quiet/picket leg).
|
||
at(1);
|
||
s.updateSideQuestRoute();
|
||
route = s.sideQuestState.routeFor(id);
|
||
check('leg 2 logs after leg 1', route.visited.includes(1) && route.bounty === plan);
|
||
if (plan.bountyAt === 1) {
|
||
const w = s.combat.started.at(-1).script.waves[0];
|
||
check('a bounty on leg 2 fires the nine-deep wave', w.total === 9 && w.roster.raider === 8);
|
||
} else if (plan.bountyAt > 1 && plan.pickets[1]) {
|
||
check('leg 2’s picket fired at ≈six raiders', s.combat.started.at(-1).script.waves[0].total === 6);
|
||
} else {
|
||
check('a quiet leg 2 fires nothing', s.combat.started.length <= 1);
|
||
}
|
||
clearRun();
|
||
|
||
// Leg 3 — the guarantee: whichever leg held the mark, it is now visited.
|
||
at(2);
|
||
s.updateSideQuestRoute();
|
||
route = s.sideQuestState.routeFor(id);
|
||
check('leg 3 logs', route.visited.includes(2));
|
||
const bountyRun = s.combat.started.find((x) => x.script.waves[0].total === 9);
|
||
check('the mark’s wave fired on its own leg, never earlier', !!bountyRun && (plan.bountyAt === 2 ? s.combat.started.indexOf(bountyRun) > 0 : true));
|
||
const sighted = s.toasts.find((t) => /THE MARK IS HERE/.test(t.text));
|
||
check('the sighting names the leader only once the fight is here', !!sighted && sighted.text.includes(String(route.bounty.mark?.name ?? '').toUpperCase()));
|
||
check('every leg logged still does NOT complete the contract', s.sideQuestState.isCompleted(id) === false);
|
||
|
||
// The kill: halt everything, book the mark, finish only when the escort stops shooting.
|
||
const w = bountyRun.script.waves[0];
|
||
// killEnemy marks the craft DOWN before it calls onKill, and a wave ship's
|
||
// kill never reforms — so the witness sees a dead enemy.
|
||
const markEnemy = { elite: true, dead: true, wave: true, respawn: false, markName: w.elite.name };
|
||
const escort = Array.from({ length: 3 }, () => ({ dead: false, wave: true }));
|
||
s.combat.enemies = [markEnemy, ...escort];
|
||
s.combat.waveRun = { state: 'running' };
|
||
s.handleCombatKill(markEnemy);
|
||
check('the mark’s death halts every further deployment', s.combat.halted === 1 && s.combat.waveRun.state === 'halted');
|
||
check('the kill books the mark (every leg checked off)', s.sideQuestState.routeFor(id).bounty.down === true);
|
||
check('the brought-to-book line names the leader', /BROUGHT TO BOOK/.test(s.toasts.at(-1)?.text ?? '') && s.toasts.at(-1).text.includes(String(route.bounty.mark?.name).toUpperCase()));
|
||
check("a second elite kill can't book it twice", (s.combat.waveRun.state = 'halted', s.handleCombatKill({ elite: true, dead: true, wave: true, markName: w.elite.name }) === undefined && s.sideQuestState.routeFor(id).bounty.down === true));
|
||
at(plan.bountyAt); // stand in the lane so the field is read here
|
||
s.updateSideQuestRoute();
|
||
check('survivors keep it OUT of complete (the escort must stop shooting)', s.sideQuestState.isCompleted(id) === false);
|
||
// The tracker's view of the same frames (the upper-left quest summary):
|
||
// the kill has ALREADY checked every leg off — 100% painted — while the
|
||
// contract is still out, and the COMPLETE mark lands a frame LATER with
|
||
// no check change (the tracker's fingerprint must carry the bit —
|
||
// dev/quest-tracker.test.mjs pins the repaint; this pins the ORDER).
|
||
const snapRow = () => s.questSnapshot().quests.find((q) => q.id === id);
|
||
const rowOut = snapRow();
|
||
check('the summary shows every leg checked while the contract is still out',
|
||
rowOut.checks.length > 0 && rowOut.checks.every((c) => c.done) && rowOut.complete === false && rowOut.claimed === false,
|
||
JSON.stringify(rowOut));
|
||
for (const e of escort) e.dead = true;
|
||
s.combat.tickWaves(); // the last survivor drops — the run reads 'done'
|
||
s.updateSideQuestRoute();
|
||
const rowReady = snapRow();
|
||
check('the COMPLETE mark lands with the legs STILL checked (no check change)',
|
||
rowReady.complete === true && rowReady.claimed === false &&
|
||
JSON.stringify(rowReady.checks.map((c) => c.done)) === JSON.stringify(rowOut.checks.map((c) => c.done)) &&
|
||
rowReady.checks.every((c) => c.done),
|
||
JSON.stringify(rowReady));
|
||
check('with the last escort down the contract is COMPLETE', s.sideQuestState.isCompleted(id) === true, JSON.stringify(s.toasts.at(-1) ?? {}));
|
||
const completeToast = s.toasts.filter((t) => /COMPLETE/.test(t.text)).length;
|
||
check('the completion toasts once', completeToast === 1);
|
||
|
||
// The claim pays credits + XP exactly once.
|
||
const pay = s.claimSideQuest(id);
|
||
check('the claim pays the contract’s credits and XP', pay.ok === true && pay.credits === route.credits && pay.xp === route.xp, JSON.stringify(pay));
|
||
check('the credits land in the hold and the XP in the run’s bank (sidequest source)', s.credits === pay.credits && s.grants.some((g) => g.n === pay.xp && g.src === 'sidequest'));
|
||
const second = s.claimSideQuest(id);
|
||
check('a second claim pays nothing', second.ok === false && s.credits === pay.credits && s.grants.filter((g) => g.src === 'sidequest').length === 1);
|
||
check('no further wave fires once the hunt is done', (s.combat.started.length, s.updateSideQuestRoute(), s.combat.waveRun?.state !== 'running'));
|
||
|
||
// The whole hunt rides the save.
|
||
const known = S.allSideQuestJobIds([REC]);
|
||
const back = SideQuestState.fromJSON(JSON.parse(JSON.stringify(s.sideQuestState.toJSON())), known);
|
||
check('the finished hunt reloads claimed with its plan', back.isClaimed(id) && back.routeFor(id).bounty.down === true && back.routeFor(id).xp === route.xp);
|
||
}
|
||
|
||
// ---- the wp1 case and the wp3 guarantee, end to end -------------------------------
|
||
for (const wantAt of [0, 2]) {
|
||
let found = null;
|
||
for (let i = 0; i < 400 && !found; i++) {
|
||
const cand = makeScene({ level: 3, seed: 4000 + i }); // the board AND the plan ride the scene's own seed
|
||
const hunts = S.sideQuestPostingsFor(4000 + i, REC, cand.galaxy, 3).filter((p) => p.category === 'bounty');
|
||
for (const h of hunts) {
|
||
cand.acceptSideQuest(h.id);
|
||
const r = cand.sideQuestState.routeFor(h.id);
|
||
if (r && r.bounty.bountyAt === wantAt) { found = { s: cand, id: h.id, r }; break; }
|
||
}
|
||
}
|
||
check(`a contract with the mark on leg ${wantAt + 1} exists to be flown`, !!found);
|
||
if (!found) continue;
|
||
const { s: fs2, id: fid, r: fr } = found;
|
||
fs2.sideQuestState.setActive(fid);
|
||
const wps = fr.waypoints;
|
||
for (let i = 0; i < wps.length; i++) {
|
||
fs2.ship.x = wps[i][0]; fs2.ship.y = wps[i][1];
|
||
fs2.updateSideQuestRoute();
|
||
if (fs2.combat.waveRun?.state === 'running') { fs2.combat.waveRun.complete(); fs2.combat.enemies = []; }
|
||
}
|
||
const run9 = fs2.combat.started.filter((x) => x.script.waves[0].total === 9);
|
||
check(`leg ${wantAt + 1} fired the nine-deep mark wave (exactly once)`, run9.length === 1, `${run9.length}`);
|
||
check(`no mark wave fired before its leg`, fs2.combat.started.slice(0, fs2.combat.started.indexOf(run9[0])).every((x) => x.script.waves[0].total !== 9));
|
||
const mk = { elite: true, dead: true, wave: true, respawn: false, markName: run9[0].script.waves[0].elite.name };
|
||
fs2.combat.enemies = [mk];
|
||
fs2.combat.waveRun = { state: 'halted' };
|
||
fs2.handleCombatKill(mk);
|
||
fs2.combat.tickWaves(); // nothing of the hunt left shooting — the run reads 'done'
|
||
fs2.ship.x = wps[wps.length - 1][0]; fs2.ship.y = wps[wps.length - 1][1];
|
||
fs2.updateSideQuestRoute();
|
||
check(`the mark’s death alone completes the contract (leg ${wantAt + 1})`, fs2.sideQuestState.isCompleted(fid) === true);
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
section('NO SPOILERS — nothing the player reads says where the mark stands');
|
||
{
|
||
const REC = { id: 'sys-free', name: 'Free Reach', faction: null, d: 40 };
|
||
const posts = S.sideQuestPostingsFor(777, REC, null, 3).filter((p) => p.category === 'bounty');
|
||
const planOf = (p) => S.deriveBountyPlan(777, p.id, p.targetSystemId, 3, { craft: p.dossier?.craft });
|
||
const copyKeys = ['bountyLockBadge', 'bountyLockSub', 'bountyAcceptDenyToast', 'bountyLegToast', 'bountySkirmishToast', 'bountySightedToast', 'bountyBroughtToBookToast', 'bountyCompleteToast', 'bountyRouteProgress'];
|
||
for (const k of copyKeys) {
|
||
const raw = SQ.ui[k];
|
||
const text = typeof raw === 'string' ? raw : raw?.text ?? '';
|
||
check(`ui.${k} exists and never speaks a leg index`, text !== '' && !/\b(waypoint|leg)\s*[123]\b/i.test(text), text);
|
||
}
|
||
for (const p of posts.slice(0, 4)) {
|
||
const plan = planOf(p);
|
||
const prog = S.bountyProgressCopy({ waypoints: [[0, 0], [1, 1], [2, 2]], visited: [0] });
|
||
check('the progress line counts legs only', /WAYPOINT 1 OF 3 LOGGED/.test(prog?.text ?? ''), prog?.text);
|
||
const dossier = S.bountyDossierCopy(p.dossier).map((l) => l.text).join(' | ');
|
||
check(`the readout’s dossier shows the leader/charges/craft and no lane plan (${p.title})`, /WANTED/.test(dossier) && /CHARGES/.test(dossier) && /CRAFT/.test(dossier) && !/WAYPOINT|LEG [123]|bountyAt/i.test(dossier), dossier.slice(0, 90));
|
||
check(`no posting field carries the plan (${p.title})`, !('bountyAt' in p) && !('pickets' in p) && !JSON.stringify(p).toLowerCase().includes('skirmish'));
|
||
void plan;
|
||
}
|
||
const src = fs.readFileSync(join(root, 'js/ui/JobBoardWindow.js'), 'utf8');
|
||
check('the board never renders a bountyAt/pickets field', !/bountyAt|plan\.pickets/.test(src));
|
||
const sceneSrc = fs.readFileSync(join(root, 'js/scenes/GameScene.js'), 'utf8');
|
||
check('the hunt’s beacon style comes from the data with no contact color', /_drawBountyMarkers/.test(sceneSrc) && !/bounty\.marker\.contactColor/.test(sceneSrc));
|
||
}
|
||
|
||
// ==========================================================================
|
||
if (failures > 0) {
|
||
console.error(`\n${failures} bounty hunt check(s) FAILED`);
|
||
process.exit(1);
|
||
}
|
||
console.log('\nAll BOUNTY HUNT checks passed ✔');
|