// 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, wk = 'p1') => S.sideQuestPostingsFor(1234, REC, wk, null, level); // The board is PER-WORLD now (1–3 postings): no single world is guaranteed // a bounty slot — the neutral voice's categories (patrol / bounty / the // escort, once it stands) share a world's seeded slots — so these // assertions pool a WIDER slice of the record's worlds (the model's // contract is that the NEUTRAL voice posts the hunt — on SOME world's // board, not a fixed one). const _worlds = [...Array(12).keys()].map((i) => `p${i + 1}`).concat(['s1', 's2', 's3', 's4']); const scan = (level) => _worlds.flatMap((wk) => at(level, wk)); const low = at(1), high = at(2), noLevel = at(null); const huntsLow = scan(1).filter((p) => p.category === 'bounty'); check('a Neutral board posts BOUNTY HUNT contracts', huntsLow.length > 0, `low=${huntsLow.length}`); const anyHunt = scan(2).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 = scan(2).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, 'p1', 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-p1-bounty-0'; // a real posting id (sq----) — 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({ records: [{ id: 'sys-free', name: 'Free Reach', faction: null, d: 40 }], byId: new Map([['sys-free', { id: 'sys-free', name: 'Free Reach', faction: null, d: 40 }]]), ensureContent: () => ({ planets: [{ id: 'sys-free-p1', name: 'Free One', ordinal: 1 }], settlements: [], jumps: [] }), }); 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-p1-patrol-1'], routes: { 'sq-sys-free-p1-patrol-1': { waypoints, visited: [0] } } }, known); check('a save written before hunts existed loads as a patrol', preHunt.routeFor('sq-sys-free-p1-patrol-1').kind === 'patrol' && preHunt.routeFor('sq-sys-free-p1-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, 'p1', 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. (Whichever leg the rolled plan put its fight on stands here: // the mark on this leg, a picket, or the quiet lane.) at(0); s.updateSideQuestRoute(); route = s.sideQuestState.routeFor(id); check('leg 1 logs', route.visited.includes(0)); const plan = route.bounty; if (plan.bountyAt === 0) { check('the mark stands on leg 1 — its nine-deep wave fires here', s.combat.started.length === 1 && s.combat.started[0].script.waves[0].total === 9, JSON.stringify(s.combat.started.map((x) => x.script.waves[0].total))); const toast = s.toasts.at(-1).text; check('the sighting names the leader (the mark is the fight)', /THE MARK IS HERE/.test(toast) && toast.includes(String(plan.mark?.name ?? '').toUpperCase()), toast); clearRun(); } else 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({ records: [REC], byId: new Map([[REC.id, REC]]), ensureContent: () => ({ planets: [{ id: 'sys-free-p1', name: 'Free One', ordinal: 1 }], settlements: [], jumps: [] }), }); 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, 'p1', 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, 'p1', 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)); } // ========================================================================== section('THE MARK\'S VOICE — the bounty attack comm (data/comms.json → pirate-bounty-attack)'); { // The mark's fight stands up ONLY in the bounty branch of // GameScene._fireBountyEngagements (the picket skirmish throws no mark), // and it is there that the 'pirateBountyAttack' trigger is armed — the // box shows assets/videos/enemies/pirate-bounty.mp4 with one of the four // bounty voices. Run headless over a stub `this` (the jobboard pattern): // the REAL prototype function, its internal `this.*` seams on stubs. const makeHuntScene = (shipPos) => { const state = { started: [], triggered: [], toasts: [], sfx: [] }; const scene = { combat: { waveRun: null, startWaves: (script, opts) => state.started.push({ script, opts }), }, ship: { x: shipPos[0], y: shipPos[1] }, comms: { trigger: (t) => state.triggered.push(t) }, playSfx: (n) => state.sfx.push(n), _huntToast: (line) => state.toasts.push(String(line)), _huntMarkName: GameScene.prototype._huntMarkName, _fireBountyEngagements: GameScene.prototype._fireBountyEngagements, state, }; return scene; }; const markPlan = { down: false, bountyAt: 0, pickets: [false], skirmishCleared: [], raiders: 8, eliteType: 'gunner', mark: { name: 'Vex Korrath', epithet: 'the Wrecked' }, }; // THE MARK — its leg is logged and the ship stands on it: the fight // stands up (the elite deploys with the opening group) and the bounty // attack comm is armed in the same breath. const markRoute = { kind: 'bounty', targetSystemId: 'sys', visited: [0], waypoints: [[100, 100]], bounty: markPlan }; const scene = makeHuntScene([100, 100]); const fired = scene._fireBountyEngagements({ bountySkirmishCleared: () => {} }, 'sq-bounty-0', markRoute, 420); check('the mark branch stands up the fight (one rolling wave through startWaves)', fired === true && scene.state.started.length === 1); check('the mark deploys inside the wave (the elite block is the dossier frame)', !!scene.state.started[0]?.script?.waves?.[0]?.elite); check('the bounty attack comm is armed the moment the mark stands up', scene.state.triggered.includes('pirateBountyAttack')); check('…exactly once per engagement that stands', scene.state.triggered.filter((t) => t === 'pirateBountyAttack').length === 1); // THE PICKET WAVE — a pre-bounty leg's skirmish throws no mark (the mark // is the fight): its fight stands up WITHOUT the bounty attack comm. const picketRoute = { kind: 'bounty', targetSystemId: 'sys', visited: [0], waypoints: [[100, 100], [900, 900]], bounty: { ...markPlan, bountyAt: 1, pickets: [true] } }; const sceneP = makeHuntScene([100, 100]); const firedP = sceneP._fireBountyEngagements({ bountySkirmishCleared: () => {} }, 'sq-bounty-0', picketRoute, 420); check('the picket branch stands up its skirmish (startWaves)', firedP === true && sceneP.state.started.length === 1); check('the picket skirmish carries no elite (the mark is the fight)', !sceneP.state.started[0]?.script?.waves?.[0]?.elite); check('…and it does NOT arm the bounty attack comm', !sceneP.state.triggered.includes('pirateBountyAttack')); // THE COMMS ITSELF — the trigger arms exactly the bounty comm (the box's // clip is the bounty clip, the voice one of the four bounty clips, the // line the bounty line — pinned in dev/comms.test.mjs's content checks). const armed = (configData.comms?.comms ?? []).filter((c) => c.trigger === 'pirateBountyAttack'); check('the trigger arms exactly one comm (pirate-bounty-attack, the bounty face)', armed.length === 1 && armed[0].id === 'pirate-bounty-attack' && armed[0].from === 'pirate_bounty'); } // ========================================================================== if (failures > 0) { console.error(`\n${failures} bounty hunt check(s) FAILED`); process.exit(1); } console.log('\nAll BOUNTY HUNT checks passed ✔');