216 lines
9.7 KiB
JavaScript
216 lines
9.7 KiB
JavaScript
/**
|
|
* The 'Special Delivery' payoff seam test (dev tool, run with Node — no
|
|
* browser):
|
|
*
|
|
* node dev/delivery-launch.test.mjs
|
|
*
|
|
* Two seams, pinned here:
|
|
*
|
|
* A — the launch-after-sale beat (data/comms.json → trigger
|
|
* 'deliveredLaunch' — carol-delivered-1/2/3 → oac-delivered-1, the four
|
|
* ack comms the player gets when they LAUNCH after selling the special
|
|
* package on the home world): the scene-side seam is
|
|
* GameScene.onSurfaceDeparture (SurfaceScene.finishTakeoff → it), which
|
|
* arms the chain when the run's delivery witness (story.delivered — set
|
|
* by the shop's CONFIRM of the package's sale, GameScene.onQuestPackageSold)
|
|
* stands. The comms' own once-ledger (js/comms/CommsHub.js) keeps the
|
|
* chain a one-shot, so the seam may arm on every post-delivery departure
|
|
* — a later re-arm is a no-op. The capital departure also records the
|
|
* story trail (launchedFromCapital) — but NO LONGER sets the
|
|
* destination (the route moved to the bar conversation — B). Pinned:
|
|
* - a post-delivery departure arms 'deliveredLaunch' exactly once
|
|
* (from any world — the sale's world is the home world, not the
|
|
* capital);
|
|
* - a pre-delivery departure arms nothing;
|
|
* - the capital departure still records the trail (launchedFromCapital)
|
|
* and refreshes the quest ledger — and sets NO destination (a
|
|
* post-quest visit to the capital — a 'Pirate Problems' return leg —
|
|
* leaves the player's destination alone);
|
|
* - the defensive no-op (already at home) still leaves the arm in
|
|
* place.
|
|
*
|
|
* B — the delivery's ROUTE (the destination → the home world): owned by
|
|
* the quest's bar conversation (data/vignettes.json →
|
|
* special_delivery_bar_meeting — the DONE press,
|
|
* GameScene.onVignetteComplete's beat 3): it lands only while the quest
|
|
* is HELD (granted, unclaimed — the scene's own gate already keeps it
|
|
* playing only then, GameScene._specialDeliveryHeld re-reads it as
|
|
* belt-and-braces). Pinned:
|
|
* - the bar scene's DONE press routes home (and stands the hold +
|
|
* handoff beats + the ledger refresh) while the quest is held;
|
|
* - the same DONE press sets NO destination once the quest is claimed
|
|
* (or was never granted, or belongs to another quest).
|
|
*
|
|
* (The config side of the same beat — the roster, the chain's order, and
|
|
* the verbatim text — is pinned in dev/comms.test.mjs.)
|
|
*/
|
|
// GameScene.onSurfaceDeparture arms 'deliveredLaunch' when story.delivered
|
|
// stands, records the capital's trail without routing; GameScene.
|
|
// onVignetteComplete routes the delivery (home) only while the quest is
|
|
// held.
|
|
import { pathToFileURL } from 'node:url';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import fs from 'node:fs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = join(__dirname, '..');
|
|
|
|
class RectStub {
|
|
constructor(x = 0, y = 0, w = 0, h = 0) { this.x = x; this.y = y; this.width = w; this.height = h; }
|
|
contains(px, py) { return px >= this.x && px <= this.x + this.width && py >= this.y && py <= this.y + this.height; }
|
|
static Contains(r, px, py) { return r.contains(px, py); }
|
|
}
|
|
class ContainerStub {
|
|
constructor(scene) { this.scene = scene; this.childrenList = []; }
|
|
add() { return this; } setVisible() { return this; } setAlpha() { return this; }
|
|
setScale() { return this; } setDepth() { return this; } setScrollFactor() { return this; }
|
|
setPosition() { return this; } destroy() { return this; }
|
|
}
|
|
const ClassStub = class {};
|
|
const PhaserStub = {
|
|
Scene: ClassStub,
|
|
Physics: { Arcade: { Sprite: ClassStub } },
|
|
GameObjects: { Sprite: ClassStub, Container: ContainerStub, Image: ClassStub, Text: ClassStub },
|
|
Geom: { Rectangle: RectStub },
|
|
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).slice(1), 16) }) } },
|
|
Filters: { Controller: class {} },
|
|
Renderer: { WebGL: { RenderNodes: { BaseFilterShader: class {} } } },
|
|
Math: { Linear: (a, b, t) => a + (b - a) * t, FloatBetween: (a, b) => a, Angle: { Wrap: (a) => a }, Clamp: (v, lo, hi) => Math.min(hi, Math.max(lo, v)) },
|
|
BlendModes: { ADD: 2 },
|
|
};
|
|
globalThis.window = { Phaser: PhaserStub };
|
|
|
|
const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href);
|
|
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 { GameScene } = await import(pathToFileURL(join(root, 'js/scenes/GameScene.js')).href);
|
|
|
|
let failures = 0;
|
|
const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; };
|
|
|
|
function makeScene({ delivered, capital = true }) {
|
|
const scene = Object.create(GameScene.prototype);
|
|
scene.triggers = [];
|
|
scene.comms = { trigger: (n) => { scene.triggers.push(n); } };
|
|
scene.galaxy = capital
|
|
? { homeSystemId: 'sys-home', neutralCapitalTarget: () => ({ systemId: 'sys-cap', objectId: 'CapitalWorld' }) }
|
|
: { homeSystemId: 'sys-home', neutralCapitalTarget: () => ({ systemId: 'sys-cap', objectId: 'CapitalWorld' }) };
|
|
scene.systemRecord = { id: 'sys-elsewhere' };
|
|
scene.story = delivered ? { delivered: true, launchedFromCapital: false } : { delivered: false, launchedFromCapital: false };
|
|
scene.setDestination = (id, via) => { scene.destination = { id, via }; };
|
|
scene.questRefreshes = 0;
|
|
scene._questRefresh = () => { scene.questRefreshes++; };
|
|
return scene;
|
|
}
|
|
|
|
// A — 1: post-delivery departure (any world): the payoff chain arms
|
|
{
|
|
const s = makeScene({ delivered: true });
|
|
s.onSurfaceDeparture('SomePlanet');
|
|
check('delivered departure arms deliveredLaunch', JSON.stringify(s.triggers) === JSON.stringify(['deliveredLaunch']));
|
|
check('delivered departure (non-capital) does not claim the capital leg', s.story.launchedFromCapital === false);
|
|
}
|
|
|
|
// A — 2: not delivered: no arm
|
|
{
|
|
const s = makeScene({ delivered: false });
|
|
s.onSurfaceDeparture('SomePlanet');
|
|
check('pre-delivery departure arms nothing', s.triggers.length === 0);
|
|
}
|
|
|
|
// A — 3: capital departure still records the trail — but NO LONGER
|
|
// routes (the delivery's route moved to the bar conversation — B)
|
|
{
|
|
const s = makeScene({ delivered: true });
|
|
s.onSurfaceDeparture('CapitalWorld');
|
|
check('capital departure still arms deliveredLaunch (once)', JSON.stringify(s.triggers) === JSON.stringify(['deliveredLaunch']));
|
|
check('capital departure still records the trail', s.story.launchedFromCapital === true);
|
|
check('capital departure sets no destination (the bar scene owns the route)', s.destination === undefined);
|
|
check('capital departure still refreshes the quest ledger', s.questRefreshes === 1);
|
|
}
|
|
|
|
// A — 4: already home: the arm still fires (the player left home), the leg is a no-op
|
|
{
|
|
const s = makeScene({ delivered: true });
|
|
s.systemRecord = { id: 'sys-home' };
|
|
s.onSurfaceDeparture('CapitalWorld');
|
|
check('home-departure arms deliveredLaunch', JSON.stringify(s.triggers) === JSON.stringify(['deliveredLaunch']));
|
|
check('home-departure sets no destination (defensive no-op)', s.destination === undefined);
|
|
}
|
|
|
|
// B — the bar scene's DONE press (GameScene.onVignetteComplete) is what
|
|
// routes the delivery — and only while the quest is HELD.
|
|
function makeBarScene({ granted, claimed }) {
|
|
const s = makeScene({ delivered: false });
|
|
s.questState = {
|
|
isGranted: (id) => id === 'special_delivery' && granted,
|
|
isClaimed: (id) => id === 'special_delivery' && claimed,
|
|
};
|
|
const items = [];
|
|
s.ship = {
|
|
stats: { cargoHold: 0 },
|
|
cargo: {
|
|
items,
|
|
room: 4,
|
|
has: (id) => items.includes(id),
|
|
add: (id) => { items.push(id); return 1; },
|
|
},
|
|
setCargoHold: (n) => { s.ship.stats.cargoHold = n; return n; },
|
|
};
|
|
s.refreshCreditsHud = () => {};
|
|
return s;
|
|
}
|
|
const barVig = {
|
|
id: 'special_delivery_bar_meeting',
|
|
location: 'bar',
|
|
quest: 'special_delivery',
|
|
step: 0,
|
|
effects: { cargoHold: 4 },
|
|
lines: [],
|
|
};
|
|
|
|
// B — 1: quest held (granted, unclaimed): the DONE press routes home,
|
|
// alongside the hold + handoff beats
|
|
{
|
|
const s = makeBarScene({ granted: true, claimed: false });
|
|
const beat = s.onVignetteComplete(barVig);
|
|
check('bar scene (quest held) stands the hold + handoff beats', beat.upgradedTo === 4 && beat.handoff === true);
|
|
check('bar scene (quest held) routes home', JSON.stringify(s.destination) === JSON.stringify({ id: 'sys-home', via: 'home' }));
|
|
check('bar scene (quest held) marks the routed beat', beat.routed === true);
|
|
check('bar scene (quest held) refreshes the quest ledger', s.questRefreshes === 1);
|
|
}
|
|
|
|
// B — 2: quest claimed (the scene outlived the quest — belt-and-braces):
|
|
// the beats still stand, but the destination is left alone
|
|
{
|
|
const s = makeBarScene({ granted: true, claimed: true });
|
|
const beat = s.onVignetteComplete(barVig);
|
|
check('bar scene (quest claimed) still stands the beats', beat.upgradedTo === 4 && beat.handoff === true);
|
|
check('bar scene (quest claimed) sets no destination', s.destination === undefined && beat.routed === false);
|
|
}
|
|
|
|
// B — 3: quest not granted (a drift the gate should have caught): same
|
|
// no-routing answer
|
|
{
|
|
const s = makeBarScene({ granted: false, claimed: false });
|
|
s.onVignetteComplete(barVig);
|
|
check('bar scene (quest not granted) sets no destination', s.destination === undefined);
|
|
}
|
|
|
|
// B — 4: a scene for ANOTHER quest never routes the delivery
|
|
{
|
|
const s = makeBarScene({ granted: true, claimed: false });
|
|
s.onVignetteComplete({ ...barVig, id: 'other_quest_bar', quest: 'pirate_problems' });
|
|
check('a non-delivery scene sets no destination', s.destination === undefined);
|
|
}
|
|
|
|
if (failures > 0) { console.error(`\n${failures} delivery-launch check(s) failed`); process.exit(1); }
|
|
console.log('\ndelivery-launch: all checks passed');
|