// dev/escort.test.mjs — the ESCORT contract’s model, verified headless // (node dev/escort.test.mjs — no browser, no Phaser render). // // What this pins (the escort’s contract — data/sidequests.json → escort): // - The GATE — escort is LIVE by default, neutral-only (a faction board // never posts it), and carries a minLevel. // - The DESTINATION — escortDestinationFor’s seeded 1–2 hop walk: a // neutral, gate-reachable system, a real planet/station object, the // same world across calls (a signed contract is a signed contract), a // different seed draws its own, and a dead-end origin degrades to null. // - The WAVE ROSTER — escortWaveRoster’s mixed fighters (valid types, // finite wave shape, a sensible concurrent/replace band). // - The XP — escortEncounterXp’s positive payout. // - The LEDGER — SideQuestState.setRoute’s escort record (phase, // destination, the transport’s armor) + the save round-trip (the // phase + armor ride the save; a malformed record degrades to a fresh // 'rendezvous' on the same destination). 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)); // --- Load the real config (data/*.json) into the config singleton ------ const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href); const fs = await import('node:fs'); const dataDir = join(__dirname, '../data'); const configData = {}; for (const f of fs.readdirSync(dataDir)) { if (!f.endsWith('.json') || f === 'manifest.json') continue; configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); } config.init(configData); const DATA = configData; const { SIDE_QUEST_CATEGORIES, sideQuestCategoryMeta, enabledSideQuestCategories, sideQuestAcceptable, sideQuestPostingsFor, gateAccessLevel, gateLockCopy, postingGateLocked, escortDestinationFor, escortWaveRoster, escortEncounterXp, SideQuestState, } = await import(pathToFileURL(join(__dirname, '../js/quests/SideQuests.js')).href); const SEED = 77031; const JOB = 'sq-sys-home-p1-escort-0'; // A small gate graph (the neutral-only scope): // sys-home (neutral) — the posting’s system (where the convoy stands) // ├─ sys-a (neutral) — one hop away // │ └─ sys-b (neutral) — two hops away // └─ sys-zenith (zenith) — a FACTION neighbor (must be excluded) const RECS = { 'sys-home': { id: 'sys-home', name: 'Hearth', faction: null, d: 5, zone: 'near', type: 'main' }, 'sys-a': { id: 'sys-a', name: 'Aldrin', faction: 'neutral', d: 35, zone: 'mid', type: 'void' }, 'sys-b': { id: 'sys-b', name: 'Basalt', faction: null, d: 65, zone: 'far', type: 'void' }, 'sys-zenith': { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 55, zone: 'mid', type: 'void' }, }; const ADJ = { 'sys-home': ['sys-a', 'sys-zenith'], 'sys-a': ['sys-home', 'sys-b'], 'sys-b': ['sys-a'], 'sys-zenith': ['sys-home'], }; const CONTENT = { 'sys-a': { planets: [{ id: 'sys-a-p1', name: 'Aldrin One', ordinal: 1, x: 100, y: 200 }], settlements: [], }, 'sys-b': { planets: [], settlements: [{ id: 'sys-b-s1', name: 'Basalt Relay', anchor: { type: 'space' }, x: 300, y: 400 }], }, }; const makeGalaxy = () => ({ seed: SEED, byId: new Map(Object.entries(RECS).map(([k, v]) => [k, v])), records: Object.values(RECS), jumpGatesFor: (id) => (ADJ[id] ?? []).map((t) => RECS[t]).filter(Boolean), ensureContent: (id) => { const c = CONTENT[id]; if (!c) throw new Error(`no content for ${id}`); return c; }, contentOf: (id) => makeGalaxy().ensureContent(id), }); const galaxy = makeGalaxy(); // =========================================================================== console.log('== the gate — escort is LIVE, neutral + faction boards, level-gated =='); { const meta = sideQuestCategoryMeta('escort'); assert.ok(meta, 'the escort meta stands'); assert.equal(meta.enabled, true, 'escort is LIVE by default (the data’s enabled flag)'); assert.ok(meta.minLevel >= 1, 'escort carries a minLevel gate (the data’s minLevel)'); assert.deepEqual(meta.contexts, ['neutral', 'faction'], 'escort posts on NEUTRAL boards (the classic contract) AND FACTION boards (the war-model rescue)'); assert.ok(enabledSideQuestCategories('neutral').includes('escort'), 'a NEUTRAL board posts escort'); assert.ok(enabledSideQuestCategories('faction').includes('escort'), 'a FACTION board posts escort (the war-model rescue)'); // The level gate: below the minLevel the contract is locked; at/above it, open. const locked = sideQuestAcceptable({ category: 'escort' }, meta.minLevel - 1); const open = sideQuestAcceptable({ category: 'escort' }, meta.minLevel); assert.equal(locked.locked, true, 'below the minLevel the contract is LOCKED'); assert.equal(open.locked, false, 'at the minLevel the contract is open'); assert.equal(open.ok, true, 'at the minLevel the contract is acceptable'); } // =========================================================================== console.log('== the posting — neutral boards stand the classic escort; faction boards stand the war-model rescue =='); { const neutral = { id: 'sys-home', name: 'Hearth', faction: null, d: 5, zone: 'near', type: 'main' }; const faction = { id: 'sys-zenith', name: 'Zenith Verge', faction: 'zenith', d: 55, zone: 'mid', type: 'void' }; // Neutral board: an escort posting stands (the category is live + neutral). const nPosts = sideQuestPostingsFor(SEED, neutral, 'p1', galaxy, 99); assert.ok(nPosts.some((p) => p.category === 'escort'), 'a NEUTRAL board stands an escort (the live category)'); const e = nPosts.find((p) => p.category === 'escort'); assert.equal(e.faction, null, 'the escort posting carries no faction'); assert.equal(e.reward.credits >= 5, true, 'the escort reward meets the 5 CR floor'); assert.ok(e.description.length > 20, 'the escort posting’s body reads'); // Faction board: no escort stands WHEN NO RIVAL HOLDS GATE-REACHABLE GROUND // (sys-zenith’s only neighbor here is the neutral sys-home — the rescue // needs a hostile system to stand in). A hostile neighbor flips it on. const fPosts = sideQuestPostingsFor(SEED, faction, 'p1', galaxy, 99); assert.ok(!fPosts.some((p) => p.category === 'escort'), 'a FACTION board stands no escort without a hostile neighbor (the slot stands empty)'); // THE WAR-MODEL RESCUE — with a rival’s gate-reachable neighbor, the // faction board posts the escort: the transport is trapped in that hostile // system (the rendezvous), the DELIVERY is the board’s own (friendly) system. const rival = { id: 'sys-rival', name: 'Apex Verge', faction: 'apex', d: 60, zone: 'mid', type: 'void' }; const wgalaxy = { ...galaxy, byId: new Map([...galaxy.byId, [rival.id, rival]]), jumpGatesFor: (id) => (id === 'sys-zenith' ? [rival, RECS['sys-home']] : (ADJ[id] ?? []).map((t) => RECS[t]).filter(Boolean)), }; // The escort occupies a SEEDED slot (a seeded shuffle), so pool a few // worlds to stand it (any of them is the same contract shape). let fe = null; for (const wk of ['p1', 'p2', 'p3', 'p4', 'p5', 'p6']) { fe = sideQuestPostingsFor(SEED, faction, wk, wgalaxy, 99).find((p) => p.category === 'escort') ?? null; if (fe) break; } assert.ok(fe, 'a FACTION board stands the war-model escort WITH a hostile neighbor'); assert.equal(fe.enemySystemId, 'sys-rival', 'the transport is trapped in the hostile neighbor (the rendezvous ground)'); assert.equal(fe.destSystemId, 'sys-zenith', '…and the DELIVERY is the board’s own friendly system'); assert.equal(fe.enemyFaction, 'apex', '…hunted by its captors (that rival)'); assert.ok(/Apex/.test(fe.description), 'the rescue copy names the captors (the {enemy} resolved)'); // THE GATE LOCK — the rescue is LOCKED until the player’s jump-gate // capability opens the hop into that hostile faction system (the // existing factionLock’s minLevel — the posting’s gateLevel). const gl = gateAccessLevel(faction, rival); assert.ok(typeof fe.gateLevel === 'number' && fe.gateLevel >= gl && gl >= 1, `the rescue carries a GATE lock (target is faction-held) — gateLevel=${fe.gateLevel} rule=${gl}`); assert.equal(sideQuestAcceptable(fe, gl - 1).locked, true, 'the rescue is LOCKED below the gate level'); assert.equal(sideQuestAcceptable(fe, gl).ok, true, 'the rescue OPENS at the gate level'); // The gate lock speaks the GATE's own words — naming the system the // player needs access to FIRST (the transport's ground here, not the // delivery's friendly destination), in the row's badge, the detail's // line, and the scene's refusal (data → ui.escortGate*). assert.equal(postingGateLocked(fe), true, 'the escort’s gate lock is the binding door'); for (const w of ['badge', 'sub', 'deny']) { const copy = gateLockCopy(fe, w); assert.ok(/ACCESS TO APEX VERGE/.test(copy?.text ?? ''), `the escort gate ${w} names the transport's system`, copy?.text); assert.ok(!/ZENITH VERGE/.test(copy?.text ?? ''), `the escort gate ${w} never names the delivery's own system`, copy?.text); } // The badge carries the system; the sub + the refusal add the level that opens it. for (const w of ['sub', 'deny']) { const copy = gateLockCopy(fe, w); assert.ok(new RegExp(`LEVEL ${gl}`).test(copy?.text ?? ''), `the escort gate ${w} names the opening level`, copy?.text); } assert.ok(gateLockCopy(fe, 'deny')?.glyph !== '', 'the escort gate refusal carries its own glyph'); } // =========================================================================== console.log('== the DESTINATION — the seeded 1–2 hop neutral walk =='); { const d = escortDestinationFor(SEED, JOB, 'sys-home', galaxy); assert.ok(d, 'a destination stands for a connected origin'); assert.ok(d.hops >= 1 && d.hops <= 2, `hops in [1,2] (got ${d.hops})`); assert.equal(d.path[0], 'sys-home', 'the path starts at the origin'); assert.equal(d.path.length, d.hops + 1, 'the path is the hop count + 1 systems'); // The destination is a NEUTRAL system (not the faction neighbor). const dest = RECS[d.destSystemId]; assert.ok(dest, 'the destination system is a known record'); assert.ok( typeof dest.faction !== 'string' || dest.faction === '' || dest.faction === 'neutral', 'the destination system is NEUTRAL (the faction neighbor is excluded)' ); assert.notEqual(d.destSystemId, 'sys-home', 'the destination is not the origin itself'); // The destination OBJECT exists and is a planet or a free-space station. assert.equal(typeof d.destObjectType, 'string', 'the destination object type stands'); assert.ok(['planet', 'station'].includes(d.destObjectType), `the destination is a planet or station (got ${d.destObjectType})`); assert.ok(d.destObjectId.length > 0, 'the destination object has an id'); assert.ok(d.destObjectName.length > 0, 'the destination object has a name'); // Determinism — the same contract points at the same world across calls. const d2 = escortDestinationFor(SEED, JOB, 'sys-home', galaxy); assert.deepEqual(d2, d, 'the same destination stands across calls (a signed contract is a signed contract)'); // A different job id draws its own (the fork is per-contract). const dOther = escortDestinationFor(SEED, 'sq-sys-home-p1-escort-3', 'sys-home', galaxy); assert.ok(dOther, 'another contract on the same board stands its own destination'); // A dead-end origin (a system with no neutral neighbors) degrades to null. const lonely = { ...makeGalaxy(), jumpGatesFor: () => [], }; assert.equal(escortDestinationFor(SEED, JOB, 'sys-home', lonely), null, 'a dead-end origin (no neighbors) → null (no destination stands)'); } // =========================================================================== console.log('== the WAVE ROSTER — mixed fighters, a sane wave shape =='); { for (const level of [0, 3, 7, 12]) { const w = escortWaveRoster(level); assert.ok(Object.keys(w.roster).length >= 1, `level ${level}: at least one fighter type`); for (const t of Object.keys(w.roster)) { assert.match(t, /^[a-z]+$/, `level ${level}: a type id (${t})`); assert.ok(w.roster[t] > 0, `level ${level}: positive weights`); } assert.ok(w.total >= 1, `level ${level}: a wave total`); assert.ok(w.maxConcurrent >= 1 && w.maxConcurrent <= w.total, `level ${level}: maxConcurrent within [1,total]`); assert.ok(w.replacePerKill >= 0, `level ${level}: replacePerKill is non-negative`); } // Higher level draws the wider roster (the data’s band). const low = escortWaveRoster(0); const high = escortWaveRoster(99); assert.ok(Object.keys(high.roster).length >= Object.keys(low.roster).length, 'a higher level’s roster is at least as wide (the data’s bands)'); // THE WAR-MODEL ROSTER — a faction escort’s lane is that faction’s OWN // fighters (the captors' escort detail), not the pirate pool. const zf = escortWaveRoster(5, 'zenith'); const ztypes = Object.keys(zf.roster); assert.ok(ztypes.length >= 1, 'a faction escort’s roster stands'); assert.ok(ztypes.every((t) => t.startsWith('zenith_')), `a faction escort’s lane is the faction’s own fighters (got ${ztypes.join(',')})`); assert.ok(!ztypes.includes('pirate_fighter'), '…and no pirate type (the war-model lane is faction, not pirate)'); const af = escortWaveRoster(5, 'apex'); assert.ok(Object.keys(af.roster).every((t) => t.startsWith('apex_')), `a different faction draws ITS OWN fighters (got ${Object.keys(af.roster).join(',')})`); // The neutral roster (no faction) still stands (unchanged classic contract). const neutral = escortWaveRoster(5); assert.ok(Object.keys(neutral.roster).length >= 1, 'the neutral (no-faction) roster still stands'); } // =========================================================================== console.log('== the XP — a positive payout, scaling with the lane =='); { const flat = escortEncounterXp({ hops: 1, level: 3 }); const deep = escortEncounterXp({ hops: 2, level: 3 }); assert.ok(flat > 0, 'a positive XP payout'); assert.ok(deep >= flat, 'the deeper lane pays at least as much XP'); assert.ok(Number.isInteger(flat), 'the XP payout is whole'); } // =========================================================================== console.log('== the LEDGER — the escort route record + the save round-trip =='); { const st = new SideQuestState(); st.accept(JOB); const dest = escortDestinationFor(SEED, JOB, 'sys-home', galaxy); const escort = { phase: 'transit', originSystemId: 'sys-home', originSystemName: 'Hearth', destSystemId: dest.destSystemId, destSystemName: dest.destSystemName, destObjectType: dest.destObjectType, destObjectId: dest.destObjectId, destObjectName: dest.destObjectName, hops: dest.hops, transportHull: 900, transportShields: 300, escortCount: 2, name: 'Convoy Night', }; const ok = st.setRoute(JOB, { kind: 'escort', targetSystemId: dest.destSystemId, credits: 140, xp: escortEncounterXp({ hops: dest.hops, level: 3 }), waypoints: [[12, 34]], // the single RENDEZVOUS waypoint visited: [0], escort, }); assert.equal(ok, true, 'the escort route stands'); const route = st.routeFor(JOB); assert.equal(route.kind, 'escort', 'the route is typed escort'); assert.equal(route.escort.phase, 'transit', 'the phase rides the record'); assert.equal(route.escort.transportHull, 900, 'the transport’s hull rides the record'); assert.equal(route.escort.transportShields, 300, 'the transport’s shields ride the record'); assert.equal(route.escort.escortCount, 2, 'the escort count rides the record'); assert.equal(route.escort.destSystemId, dest.destSystemId, 'the destination rides the record'); assert.equal(route.targetSystemId, dest.destSystemId, 'the route’s target = the destination system'); assert.ok(route.xp > 0, 'the XP payout rides the record'); assert.equal(route.credits, 140, 'the credits ride the record'); // The save round-trip (toJSON → fromJSON) preserves the escort record. const restored = SideQuestState.fromJSON(st.toJSON(), new Set([JOB])); const r2 = restored.routeFor(JOB); assert.equal(r2.kind, 'escort', 'the save round-trip keeps the escort type'); assert.equal(r2.escort.phase, 'transit', '…the phase'); assert.equal(r2.escort.transportHull, 900, '…the transport’s hull'); assert.equal(r2.escort.destSystemId, dest.destSystemId, '…the destination'); // A malformed record degrades to a fresh 'rendezvous' on the same destination. const st2 = new SideQuestState(); st2.accept(JOB); st2.setRoute(JOB, { kind: 'escort', targetSystemId: dest.destSystemId, credits: 140, waypoints: [[12, 34]], visited: [], escort: { phase: 'BROKEN', destSystemId: dest.destSystemId, destObjectName: dest.destObjectName }, }); const r3 = st2.routeFor(JOB); assert.equal(r3.escort.phase, 'rendezvous', 'a broken phase degrades to a fresh rendezvous'); assert.equal(r3.escort.destSystemId, dest.destSystemId, '…on the same destination (the contract’s signature)'); assert.ok(r3.escort.transportHull >= 1, '…with a fresh transport (full armor)'); } console.log('\nescort: OK — the gate, the destination walk, the wave roster, the XP, the ledger');