orbit/dev/escort.test.mjs

266 lines
13 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,
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-only, 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'], 'escort is neutral-only (a faction board never posts it)');
assert.ok(enabledSideQuestCategories('neutral').includes('escort'), 'a NEUTRAL board posts escort');
assert.ok(!enabledSideQuestCategories('faction').includes('escort'), 'a FACTION board never posts escort');
// 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 — the neutral board stands an escort; the faction board does not ==');
{
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 (neutral-only).
const fPosts = sideQuestPostingsFor(SEED, faction, 'p1', galaxy, 99);
assert.ok(!fPosts.some((p) => p.category === 'escort'), 'a FACTION board never stands an escort');
}
// ===========================================================================
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)');
}
// ===========================================================================
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');