orbit/dev/bounty-hunts.test.mjs

610 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// dev/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 ✔');