311 lines
16 KiB
JavaScript
311 lines
16 KiB
JavaScript
// 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');
|