orbit/dev/escort.test.mjs

311 lines
16 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/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,
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');
}
// ===========================================================================
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');