Move Special Delivery package handoff to the bar scene's DONE beat

- Ship now starts with cargoHold 0; the bar vignette's DONE press builds
  out 4 bays (effects.cargoHold) and then places the special-package,
  making the parcel aboard the "cargoUpgraded" step's witness instead of
  a build-state check.
- Remove the old capital-landing handoff and SurfaceScene package flag;
  onVignetteComplete in GameScene applies effects + handoff in order and
  refreshes the quest checklist.
- Persist cargoHold through saves and gate jumps so the upgraded hold and
  its goods survive a scene restart or load (old saves fall back to the
  carried goods count).
- Add arid-land/takeoff 02 assets, fix landing.json slot 3 mapping, and
  extend dev tests (jump-cargo probe, saves round-trip, vignette flow) to
  cover the new beat ordering and persistence.
This commit is contained in:
Brian Fertig 2026-09-13 14:09:16 -06:00
parent bd8b161584
commit 55db379a93
19 changed files with 488 additions and 113 deletions

Binary file not shown.

Binary file not shown.

View File

@ -21,7 +21,7 @@
"jitter": 0.10
},
"cargo": {
"_comment": "The hold is a set of fixed BAYS — one good per bay (unitPerGood=1). `capacitySource` is the UNLOCKED-bay count (ship.stats.cargoHold, starts at 4; cargo upgrades raise it). `maxSlots` is the fixed grid the shop UI always shows (16 slots) — locked bays are just capacity the ship hasn't bought yet. You CARRY goods between shops; that's the whole game loop.",
"_comment": "The hold is a set of fixed BAYS — one good per bay (unitPerGood=1). `capacitySource` is the UNLOCKED-bay count (ship.stats.cargoHold, starts at 0 — the run's ship arrives with an empty hold; the bar scene's DONE beat or a cargo build unlocks bays). `maxSlots` is the fixed grid the shop UI always shows (16 slots) — locked bays are just capacity the ship hasn't unlocked yet. You CARRY goods between shops; that's the whole game loop.",
"capacitySource": "ship.stats.cargoHold",
"maxSlots": 16,
"unitPerGood": 1

View File

@ -74,7 +74,7 @@
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "lava-shop-02.mp4" },
{ "land": "lava-land-03.mp4", "surface": "lava-surface-03.mp4", "takeoff": "lava-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "arid-land-01.mp4", "surface": "arid-surface-01.mp4", "takeoff": "arid-takeoff-01.mp4", "shop": "ice-shop-01.mp4" },
{ "land": "ice-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "arid-land-02.mp4", "surface": "ice-surface-02.mp4", "takeoff": "arid-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },
{ "land": "ice-land-03.mp4", "surface": "ice-surface-03.mp4", "takeoff": "ice-takeoff-03.mp4", "shop": "ice-shop-03.mp4" },
{ "land": "capital-land-neutral.mp4", "surface": "capital-surface-neutral.mp4", "takeoff": "capital-takeoff-neutral.mp4", "shop": "ice-shop-01.mp4", "bar": "capital-bar-neutral.mp4" },
{ "land": "lava-land-02.mp4", "surface": "lava-surface-02.mp4", "takeoff": "lava-takeoff-02.mp4", "shop": "ice-shop-02.mp4" },

View File

@ -148,7 +148,7 @@
"title": "Special Delivery",
"description": "The mail routes are dead, and a sealed package that must reach the home world is sitting at the neutral capital's bar. Carol is paying for the run, and Trestle will see it off — the only question is whether your ship can hold the cargo. Meet the two of them at the bar, upgrade the hold, and deliver the package to the home world.",
"issuer": "Carol",
"_comment": "The run's THIRD quest (the 'Leaving the Nest' follow-up — the bar at the neutral capital). It GRANTS the moment 'Leaving the Nest' is COMPLETED AND CLAIMED (unlocks.claimed — the same chain mechanic as leaving_the_nest). The three checks: step 1 'vignettePlayed' (check.id 'special_delivery_bar_meeting' — the bar's dialogue scene in data/vignettes.json: Trestle + Carol, the run's played ledger registry 'vignettesPlayed' is the witness — VignetteHub marks it when the scene's DONE press completes it; the gate — the quest granted, unclaimed, this step open — is what plays it when the bar is entered). The CAPITAL LANDING ITSELF still runs the story ledger (registry 'story', saved as record.story — GameScene._recordLanding): the shop gate lifts (SurfaceScene reads GameScene.isShopUnlocked) and the SPECIAL PACKAGE — packageGood 'special-package', data/goods.json, sheet frame 114, tradeable:false so no shop touches it — lands in the hold (GameScene._handoffSpecialPackage); step 2 'cargoUpgraded' — a cargo-bay SHIP build installed (data/builds.json → effects.cargo.bays — the record is ship-scoped, so any world's build counts); step 3 'packageDelivered' — the delivery's proof (story.delivered, MONOTONIC): a real launch off the capital (SurfaceScene.finishTakeoff → GameScene.onSurfaceDeparture, which also sets the destination back to the home world) followed by a landing ON the home world (the proof is recorded at that landing and stands — a later landing elsewhere can't un-deliver it). Reward: 200 credits (claimQuest's credit seam — the same as 'Leaving the Nest').",
"_comment": "The run's THIRD quest (the 'Leaving the Nest' follow-up — the bar at the neutral capital). It GRANTS the moment 'Leaving the Nest' is COMPLETED AND CLAIMED (unlocks.claimed — the same chain mechanic as leaving_the_nest). The three checks: step 1 'vignettePlayed' (check.id 'special_delivery_bar_meeting' — the bar's dialogue scene in data/vignettes.json: Trestle + Carol, the run's played ledger registry 'vignettesPlayed' is the witness — VignetteHub marks it when the scene's DONE press completes it; the gate — the quest granted, unclaimed, this step open — is what plays it when the bar is entered). The CAPITAL LANDING runs the story ledger (registry 'story', saved as record.story — GameScene._recordLanding): the shop gate lifts (SurfaceScene reads GameScene.isShopUnlocked). The scene's DONE press (GameScene.onVignetteComplete) runs the handoff: FIRST the ship's hold is built out (the scene's effects.cargoHold — the run's ship starts with cargoHold 0, data/ship.json), THEN the SPECIAL PACKAGE — packageGood 'special-package', data/goods.json, sheet frame 114, tradeable:false so no shop touches it — lands in the hold (GameScene._handoffSpecialPackage); step 2 'cargoUpgraded' (check.id 'special-package') — the witness is the PACKAGE ABOARD: it only fits once the hold is upgraded, so its presence IS the upgrade's proof (the scene's lines are the truth — Carol's crew built the bays); step 3 'packageDelivered' — the delivery's proof (story.delivered, MONOTONIC): a real launch off the capital (SurfaceScene.finishTakeoff → GameScene.onSurfaceDeparture, which also sets the destination back to the home world) followed by a landing ON the home world (the proof is recorded at that landing and stands — a later landing elsewhere can't un-deliver it). Reward: 200 credits (claimQuest's credit seam — the same as 'Leaving the Nest').",
"unlocks": {
"_comment": "The grant condition: the listed quests' rewards CLAIMED — the run's chain (leaving_the_nest paid out → this one grants).",
"claimed": ["leaving_the_nest"]
@ -156,7 +156,7 @@
"packageGood": "special-package",
"checks": [
{ "type": "vignettePlayed", "id": "special_delivery_bar_meeting", "label": "Meet with Trestle and Carol at the neutral capital's bar" },
{ "type": "cargoUpgraded", "label": "Upgrade Ship to hold Cargo" },
{ "type": "cargoUpgraded", "id": "special-package", "label": "Upgrade Ship to hold Cargo" },
{ "type": "packageDelivered", "label": "Deliver Package for Carol" }
],
"reward": {

View File

@ -17,10 +17,10 @@
"arriveRadius": 8,
"arriveSpeed": 50,
"stats": {
"_comment": "Base stats — the ship's starting condition, before any upgrades (builds/research layer deltas on top). hullIntegrity = the hull's max health (damage it can take); shields = damage absorbed in front of the hull; cargoHold = UNLOCKED cargo bays (starts at 4; cargo upgrades raise it toward the 16-slot grid, data/economy.json → cargo.maxSlots — one good per bay); mineralStorage = minerals capacity (the hold's max load — the player's current minerals live in ship.minerals, loaded by the mining sequence); miningSpeed = multiplier on mining rate (1 = baseline). Exposed as ship.stats (js/entities/Ship.js); combat/trading/mining systems will read these as capacities.",
"_comment": "Base stats — the ship's starting condition, before any upgrades (builds/research layer deltas on top). hullIntegrity = the hull's max health (damage it can take); shields = damage absorbed in front of the hull; cargoHold = UNLOCKED cargo bays (starts at 0 — the ship arrives with an empty hold; the capital's bar scene's DONE beat builds it out to 4 (data/vignettes.json → effects.cargoHold, GameScene.onVignetteComplete) and cargo upgrades raise it further toward the 16-slot grid, data/economy.json → cargo.maxSlots — one good per bay); mineralStorage = minerals capacity (the hold's max load — the player's current minerals live in ship.minerals, loaded by the mining sequence); miningSpeed = multiplier on mining rate (1 = baseline). Exposed as ship.stats (js/entities/Ship.js); combat/trading/mining systems will read these as capacities.",
"hullIntegrity": 100,
"shields": 0,
"cargoHold": 4,
"cargoHold": 0,
"mineralStorage": 250,
"miningSpeed": 1
}

View File

@ -23,7 +23,11 @@
"location": "bar",
"quest": "special_delivery",
"step": 0,
"$comment": "'Special Delivery' step 1 — the neutral capital's bar: Trestle's welcome (trestle-q3-01) + Carol's four beats (carol-q3-01..04). It plays when the quest is held and the bar is entered on the capital — the package already joined the hold on the landing itself (GameScene._recordLanding / _handoffSpecialPackage), so Carol's 'the cargo has been loaded' line is the truth the moment the scene opens. COMPLETING it (the DONE press) marks the step: the quest's check is 'vignettePlayed' (check.id = this id) and reads the run's played ledger (registry 'vignettesPlayed' — VignetteHub marks it on completion, SaveData persists it).",
"$comment": "'Special Delivery' step 1 — the neutral capital's bar: Trestle's welcome (trestle-q3-01) + Carol's four beats (carol-q3-01..04). It plays when the quest is held and the bar is entered on the capital. COMPLETING it (the DONE press) runs the scene's story beat (effects + the quest's package handoff — GameScene.onVignetteComplete): the ship's hold is built out FIRST (effects.cargoHold — the run's ship starts with cargoHold 0, data/ship.json) and THEN the 'special-package' joins the hold — Carol's 'I've asked some of my men to build out 4 cargo bays' / 'the cargo I need transported has been loaded' lines are the truth the moment the scene ENDS. The completion also marks the step: the quest's check is 'vignettePlayed' (check.id = this id) and reads the run's played ledger (registry 'vignettesPlayed' — VignetteHub marks it on completion, SaveData persists it); the 'cargoUpgraded' step stands done with the package aboard (its witness — check.id 'special-package').",
"effects": {
"$comment": "The story beat the DONE press runs (GameScene.onVignetteComplete — BEFORE the quest's package handoff, the hold must fit the package): cargoHold = the ship's unlocked cargo bays, MAX-wins (never a downgrade — the run's ship starts at 0, data/ship.json). The scene's lines are the truth: Carol's crew built out the bays.",
"cargoHold": 4
},
"lines": [
{
"from": "trestle",

35
dev/jump-cargo.html Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<!-- Headless probe page for the JUMP cargo-persistence test (dev only —
not part of the game build). See dev/jump-cargo.mjs. -->
<html>
<head>
<meta charset="utf-8" />
<base href="../" />
<title>orbit — jump cargo persistence test</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #04060d;
overflow: hidden;
}
#game {
display: grid;
place-items: center;
}
</style>
</head>
<body>
<div id="game"></div>
<script>
window.__BOOT_ERRORS__ = [];
window.addEventListener('error', (e) =>
window.__BOOT_ERRORS__.push(String(e.message) + ' @ ' + (e.filename || '') + ':' + e.lineno));
window.addEventListener('unhandledrejection', (e) =>
window.__BOOT_ERRORS__.push('reject: ' + ((e.reason && e.reason.stack) || e.reason)));
</script>
<script src="lib/phaser.min.js"></script>
<script type="module" src="dev/jump-cargo.mjs"></script>
</body>
</html>

144
dev/jump-cargo.mjs Normal file
View File

@ -0,0 +1,144 @@
/**
* Dev: the UPGRADED CARGO HOLD survives a gate JUMP (v0.5):
*
* The bar scene's DONE beat builds the hold out (GameScene.
* onVignetteComplete: effects.cargoHold, then the 'special-package'
* in it). The hold is SHIP STATE — so it must ride every transport.
* A gate jump is its own transport: jumpThroughGate re-stages the run
* through the save pipeline (captureState → rec.ship → prepareLoad →
* scene.restart → applyRestore on a FRESH ship, which starts at the
* 0-bay baseline — data/ship.json).
*
* This test proves the beat survives that hop end-to-end with the
* REAL jumpThroughGate (no stubs):
*
* 1. fresh VESTRA run → 0 bays, empty hold (the baseline)
* 2. stage the beat → 4 bays, 'special-package' aboard
* 3. real jump through an ACTIVE gate → the scene restarts onto the
* destination system
* 4. the FRESH ship after the jump keeps the bays (cargoHold 4,
* capacity 4) AND the package (the jump's rec.ship carries
* cargoHold; without it the restore would re-seed the 0 baseline
* and shed the goods)
*
* node dev/server.mjs 8099
* node dev/cdp-firefox.mjs http://localhost:8099/dev/jump-cargo.html \
* 'return window.__JUMP_CARGO__;' 90000
*/
import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
// 'VESTRA's starting system holds three gates (the gate-comms seed).
globalThis.__ORBIT_DEV_SEED = 'VESTRA';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene]; // GameScene boots first
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
const game = new Phaser.Game(gameConfig);
window.game = game;
/* ---- helpers (the gate-comms harness pattern) ------------------------- */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Pump the loop until `ok()` or the budget elapses. */
async function pump(s, ok, ms = 4000) {
const t0 = Date.now();
while (!ok() && Date.now() - t0 < ms) {
s.game.loop.step(performance.now());
await sleep(0);
}
return ok();
}
async function ready() {
const t0 = Date.now();
for (;;) {
const g = window.game;
const s = g && g.scene?.getScene?.('GameScene');
if (s && s.sys?.isActive && s.ship && (s.systemGates?.length ?? 0) >= 1) return s;
if (Date.now() - t0 > 45000) throw new Error('game never became ready');
await sleep(50);
}
}
/* ---- the test ---------------------------------------------------------- */
async function run() {
const out = { steps: [] };
const step = (name, pass, extra = {}) => out.steps.push({ name, pass, ...extra });
const s = await ready();
s.cameraFollowShip = false;
s.hideHint?.();
s.ship.stop();
const gateA = s.systemGates[0];
// ---- 1. the fresh baseline ---------------------------------------------
step('a fresh ship starts at the 0-bay baseline (data/ship.json)',
s.ship.stats.cargoHold === 0 && s.ship.cargo.capacity === 0 && s.ship.cargo.items.length === 0);
// ---- 2. stage the beat (the bar scene's DONE press, verbatim) -----------
s.ship.setCargoHold(4); // GameScene.onVignetteComplete: effects.cargoHold
s.ship.cargo.add('special-package'); // then the quest's package
step('the beat is live (4 bays, the package aboard)',
s.ship.stats.cargoHold === 4 && s.ship.cargo.capacity === 4
&& s.ship.cargo.has('special-package') === true);
// Keep the ship OUT of the gate's keepout (solid — the ship can't
// overlap it) before the jump re-stages the run.
s.ship.x = gateA.x + 2500;
s.ship.y = gateA.y + 2000;
// ---- 3. the REAL jump (no stubs) -----------------------------------------
gateA.active = true; // (the research tech would do this — same flag)
const destId = gateA.gate?.to;
const beforeShip = s.ship;
step('the jump target is a known system',
!!destId && s.galaxy.byId.has(destId) === true);
const fromSystem = s.galaxy.currentSystemId;
s.jumpThroughGate(gateA);
// The cut: jump clip (or its stall guard) → _finishJump → scene.restart
// → a FRESH scene on the destination. The scene INSTANCE survives
// (restart), but this.ship is a new object — that is the restart tell.
// applyRestore runs at the END of create(), so wait for the beat to
// be back on the fresh ship (cargoHold restored + package aboard).
const restored = await pump(
s,
() => s.ship !== beforeShip
&& s.ship.stats.cargoHold === 4
&& s.ship.cargo.has('special-package') === true,
30000,
);
step('the jump completed (the scene restarted onto the destination)',
s.ship !== beforeShip && game.scene.isActive('GameScene'));
step('the ship is in the destination system',
s.galaxy.currentSystemId === destId && s.galaxy.currentSystemId !== fromSystem);
// ---- 4. the beat survived the hop ----------------------------------------
step('THE BEAT SURVIVES THE JUMP: the fresh ship keeps the bays (cargoHold 4, capacity 4)',
!!restored && s.ship.stats.cargoHold === 4 && s.ship.cargo.capacity === 4,
{ cargoHold: s.ship?.stats?.cargoHold, capacity: s.ship?.cargo?.capacity });
step('THE BEAT SURVIVES THE JUMP: the package is still aboard',
!!restored && s.ship.cargo.has('special-package') === true
&& JSON.stringify(s.ship.cargo.items) === JSON.stringify(['special-package']),
{ items: s.ship?.cargo?.items });
out.pass = out.steps.every((st) => st.pass);
return out;
}
window.__JUMP_CARGO__ = null;
run()
.then((out) => { window.__JUMP_CARGO__ = out; console.log(out.pass ? 'JUMP-CARGO PASS' : 'JUMP-CARGO FAIL'); })
.catch((err) => {
console.error(err);
window.__JUMP_CARGO__ = { pass: false, steps: [{ name: `DRIVER CRASHED: ${err.message}`, pass: false }] };
});

View File

@ -226,6 +226,13 @@ for (const c of starter?.checks ?? []) {
check('delivery: the package is NOT tradeable (the shops refuse it)', pkg?.tradeable === false);
check('delivery: the package has no producer/demand (the economy does not price it)',
Array.isArray(pkg?.producer) && pkg.producer.length === 0 && Array.isArray(pkg?.demand) && pkg.demand.length === 0);
// The cargo step's WITNESS — the package ABOARD (check.id = the package
// good): it only fits once the hold is upgraded (the run's ship starts
// with cargoHold 0, data/ship.json), and the bar scene's DONE press is
// what upgrades + loads it (onVignetteComplete) — the parcel aboard is
// the upgrade's proof (GameScene._questCheckEval reads the check's id).
check('delivery: the cargo step stands on the package (check.id = packageGood)',
byType?.cargoUpgraded?.id === pkgId && typeof pkgId === 'string' && pkgId.length > 0);
}
// ----------------------------------------------------------------------

View File

@ -14,7 +14,12 @@
* fresh GameScene — ship back where it was, the session
* time, the tether field, and the hold: the minerals the
* save carried show on the ship AND the upper-right
* mineral readout (the load seam the HUD refresh guards).
* mineral readout (the load seam the HUD refresh guards),
* and the CARGO BEAT rides the round-trip too: the
* unlocked bays (ship.cargoHold — the bar scene's DONE
* build-out) + the 'special-package' in them (the
* 'cargoUpgraded' witness) survive a save on one system
* and a load on another (the hold is ship state).
*
* Served by dev/saves-ui-test.html; the results land in
* `window.__SAVES_UI__` for the CDP runner (dev/cdp-firefox.mjs):
@ -130,6 +135,20 @@ const run = async () => {
scene.ship.minerals === 37 && scene.mineralHud.value === 37
&& scene.mineralHud.label.text === scene.mineralHud.format(37));
// ---- the cargo hold (the 'Special Delivery' beat) ----------------------
// The ship starts with 0 bays (data/ship.json) and the bar scene's DONE
// press builds it out (GameScene.onVignetteComplete: the scene's
// effects.cargoHold, then the quest's package). Stage that exact end
// state here, so both saves below carry the UNLOCKED bays AND the
// package in them — the hold is ship state, and a save on one system
// must load on another with the beat intact (the round-trip assertions
// ride the doSave/capture + menu-load/applyRestore seams).
scene.ship.setCargoHold(4); // the scene's beat (effects.cargoHold)
scene.ship.cargo.add('special-package'); // the quest's package (the cargoUpgraded witness)
check('the beat state is live (4 bays, the package aboard)',
scene.ship.stats.cargoHold === 4 && scene.ship.cargo.capacity === 4
&& scene.ship.cargo.has('special-package') === true);
// ---- the sub-bar folds up out of MENU --------------------------------
scene.menuAction();
await wait(450);
@ -213,6 +232,7 @@ const run = async () => {
const rec2 = scene.saveManager.get(2);
check('a pointer click on slot 2 saves the game',
rec2 !== null && rec2.seed === scene.galaxy.seed && rec2.ship.x === scene.ship.x);
check('the click-path save carries the bays (ship.cargoHold = 4)', rec2?.ship?.cargoHold === 4);
// The SAVE toast lives INSIDE the panel, on the subtitle line — the
// old spot above the panel collided with the O.A.C. comms box.
@ -231,6 +251,9 @@ const run = async () => {
const rec1 = scene.saveManager.get(1);
check('doSave(1) writes the bank', rec1 !== null && rec1.seed === scene.galaxy.seed && rec1.ship.x === scene.ship.x);
check('the bank captured the hold (37)', rec1.ship.minerals === 37);
check('the bank captured the UNLOCKED bays (ship.cargoHold = 4)', rec1.ship.cargoHold === 4);
check('the bank captured the package aboard (ship.cargo)',
JSON.stringify(rec1.ship.cargo) === JSON.stringify(['special-package']));
// Capture fidelity: the bank's session time is the scene's at capture
// (a frame or two may have ticked since — small tolerance).
check('the bank captured the session time',
@ -290,6 +313,16 @@ const run = async () => {
// seam this test guards; without it the corner reads 0 until the next
// mining run). The count-up settles (420 ms) on the bank's value.
check('the restored hold is on the ship (37)', gs.ship.minerals === 37);
// THE BEAT SURVIVES THE ROUND-TRIP: the unlocked bays are restored on
// the fresh ship (applyRestore sets them BEFORE the goods go back in —
// a 0-bay ship would shed the package) and the package is still
// aboard — save on one system, load on another, the 'Special Delivery'
// state is intact.
check('the restored ship keeps the UNLOCKED bays (cargoHold 4, capacity 4)',
gs.ship.stats.cargoHold === 4 && gs.ship.cargo.capacity === 4);
check('the restored hold keeps the package (special-package aboard)',
gs.ship.cargo.has('special-package') === true
&& JSON.stringify(gs.ship.cargo.items) === JSON.stringify(['special-package']));
await wait(700);
check('the HUD reads the RESTORED hold (upper right)',
gs.mineralHud.value === 37

View File

@ -61,7 +61,7 @@ const fakeScene = () => {
registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } },
galaxy,
systemRecord: { name: system.name },
ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37, credits: 1250, cargo },
ship: { x: 123.5, y: -77, rotation: 0.72, minerals: 37, credits: 1250, cargo, stats: { cargoHold: 4 } },
discovery: new Discovery(540),
depletedClusters: new Set([`${system.id}:c-depleted`]), // a mined-out field
tetherField: {
@ -218,6 +218,8 @@ const makeStorage = (fail = false) => {
check('capture: the credit ledger is carried (ship.credits)', rec.ship.credits === 1250);
check('capture: the trade hold is carried (ship.cargo, fill order)',
JSON.stringify(rec.ship.cargo) === JSON.stringify(['frost-diamond', 'iron-ore']));
check('capture: the unlocked bays are carried (ship.cargoHold — the DONE beat is ship state)',
rec.ship.cargoHold === 4);
check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0'));
check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
check('capture: playtime carried', rec.playTimeMs === 123456);

View File

@ -119,17 +119,17 @@ const integrate = (ship) => {
ship.stats.shields === s.shields &&
ship.stats.cargoHold === s.cargoHold &&
ship.stats.mineralStorage === s.mineralStorage);
check('base stats are the shipped values (100 / 0 / 4 / 250)',
check('base stats are the shipped values (100 / 0 / 0 / 250)',
ship.stats.hullIntegrity === 100 &&
ship.stats.shields === 0 &&
ship.stats.cargoHold === 4 &&
ship.stats.cargoHold === 0 && // the run starts with NO bays — the bar scene's DONE beat (or a cargo build) unlocks them
ship.stats.mineralStorage === 250);
check('cargo hold is wired (CargoHold, capacity = unlocked bays, 4→16 grid)',
check('cargo hold is wired (CargoHold, capacity = unlocked bays, 0→16 grid)',
Array.isArray(ship.cargo.items) &&
ship.cargo.capacity === ship.stats.cargoHold &&
ship.cargo.capacity === 4 &&
ship.cargo.capacity === 0 &&
ship.maxCargoSlots === 16 &&
ship.cargoRoom() === 4);
ship.cargoRoom() === 0);
}
// --- Test 0b: cargo build upgrades (data/builds.json → ship hold) -----------
{
@ -154,8 +154,8 @@ const integrate = (ship) => {
const bays = d.bays;
if (bays > ship.stats.cargoHold) ship.setCargoHold(bays);
}
check('building all three cargo bays expands the hold to 16',
before === 4 && ship.stats.cargoHold === 16 && ship.cargo.capacity === 16);
check('building all three cargo bays expands the hold 0 → 16',
before === 0 && ship.stats.cargoHold === 16 && ship.cargo.capacity === 16);
// MAX-wins: a smaller bay never downgrades the already-expanded hold
// (GameScene._applyCargoUpgrade guards with `bays > current` before applying).

View File

@ -28,12 +28,15 @@
* 6. NEXT ×4 — each of Carol's four beats (the face stays CAROL; the
* voice is the line's own clip, ducking the hum), verbatim words,
* and the window re-sizes to each (taller) line
* 7. the last line reads DONE; the press completes: the ledger marks
* (completing), the quest's bar step stands DONE (the witness),
* the quest is not yet claimable (its other steps still stand),
* the bar's own lock returns — and LEAVE BAR, which the scene's
* lock had disabled, re-arms (the press leaves the bar), and the
* music duck is restored (it was ducked per-sound under every voice)
* 7. the last line reads DONE; the press completes: the STORY BEAT
* runs (the hold builds out to 4 bays — the scene's effects — and
* the 'special-package' joins the hold; its presence stands the
* 'cargoUpgraded' step done), the ledger marks (completing), the
* quest's bar step stands DONE (the witness), the quest is not yet
* claimable (its delivery step still stands), the bar's own lock
* returns — and LEAVE BAR, which the scene's lock had disabled,
* re-arms (the press leaves the bar), and the music duck is
* restored (it was ducked per-sound under every voice)
* 8. re-enter the bar — the scene stays spent (the once-ledger), a
* plain visit with no window
*/
@ -188,6 +191,14 @@ const ID = 'special_delivery_bar_meeting';
ss.toggleBar();
step('bar-mode', ss.barMode === true, { barMode: ss.barMode });
step('armed', hub.busy === true, { pending: !!hub.pending, active: hub.active });
// THE SHIP BEFORE THE SCENE (the beat's ground truth): the run's ship
// starts with cargoHold 0 (data/ship.json) and the package is NOT
// aboard yet — both ride the DONE press (onVignetteComplete).
step(
'hold-0-before',
gs.ship?.stats?.cargoHold === 0 && gs.ship?.cargo?.has?.('special-package') === false,
{ hold: gs.ship?.stats?.cargoHold, items: [...(gs.ship?.cargo?.items ?? [])] },
);
await sleep(150);
step('beat-holds', hub.active === false, { active: hub.active });
@ -323,6 +334,27 @@ const ID = 'special_delivery_bar_meeting';
step('gate-consumed', hub._dueAt('bar') === null, { due: hub?._dueAt?.('bar')?.id ?? null });
step('bar-still-mode', ss.barMode === true, { barMode: ss.barMode });
// THE STORY BEAT (the DONE press ran it, in order — upgrade, then
// load): the ship's hold is BUILT OUT to the scene's effects
// (cargoHold 4 — the run's ship started at 0) and the quest's
// 'special-package' is ABOARD; its presence is the 'cargoUpgraded'
// step's witness, so the cargo step stands done too. The quest is
// still not claimable (the delivery leg stands open).
step(
'hold-upgraded',
gs.ship?.stats?.cargoHold === 4 && gs.ship?.cargo?.capacity === 4,
{ hold: gs.ship?.stats?.cargoHold, capacity: gs.ship?.cargo?.capacity },
);
step('package-aboard', gs.ship?.cargo?.has?.('special-package') === true, { items: [...(gs.ship?.cargo?.items ?? [])] });
step(
'quest-cargo-step-done',
(() => {
const c = quest?.checks?.find((x) => x?.label === 'Upgrade Ship to hold Cargo');
return !!c && c.done === true && quest?.complete === false;
})(),
{ checks: quest?.checks?.map((c) => `${c.label}:${c.done}`), complete: quest?.complete },
);
// THE WAY-OUT COMES BACK: the scene's lock disabled LEAVE BAR (the
// drawer's bar item) along with everything else; DONE must re-enable
// it — the bar's OWN lock returns (deck stays standby, LEAVE BAR is

View File

@ -46,6 +46,7 @@ const {
charEntry,
normalizeLine,
normalizeVignette,
normalizeEffects,
listVignettes,
vignettesForLocation,
vignetteAvailable,
@ -105,7 +106,7 @@ check(
(first?.lines ?? []).every((l) => typeof l.audio === 'string' && l.audio.length > 0 && existsSync(join(root, l.audio))),
);
check(
"the first scene: its gate is the quest's bar step (vignettePlayed → this id)",
'the first scene: its gate is the quest\u2019s bar step (vignettePlayed \u2192 this id)',
(() => {
const quests = JSON.parse(readFileSync(join(root, 'data/quests.json'), 'utf8'));
const q = (quests.quests ?? []).find((x) => x.id === first?.quest);
@ -113,6 +114,19 @@ check(
return c?.type === 'vignettePlayed' && c?.id === first?.id;
})(),
);
check(
"the first scene: its DONE beat builds the hold out to 4 bays (effects.cargoHold — the ship starts at 0)",
first?.effects?.cargoHold === 4,
);
check(
"the first scene: the beat precedes the package (the quest's cargo step stands on the package aboard)",
(() => {
const quests = JSON.parse(readFileSync(join(root, 'data/quests.json'), 'utf8'));
const q = (quests.quests ?? []).find((x) => x.id === first?.quest);
const cargoStep = (q?.checks ?? []).find((c) => c.type === 'cargoUpgraded');
return cargoStep?.id === q?.packageGood;
})(),
);
check("location: the bar's scenes (the first one)", JSON.stringify(vignettesForLocation(cfg, 'bar').map((v) => v.id)) === JSON.stringify(['special_delivery_bar_meeting']));
check('location: the shop has no scenes yet', vignettesForLocation(cfg, 'shop').length === 0);
@ -193,6 +207,15 @@ check('a scene: a step without a quest is dropped (a gate needs its quest)', (()
check('a scene: no id is dropped', normalizeVignette({ location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([])) === null);
check('a scene: no location is dropped', normalizeVignette({ id: 'x', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([])) === null);
check('a scene: no lines is dropped', normalizeVignette({ id: 'x', location: 'bar' }, mkCfg([])) === null);
// The story effects (the DONE beat — the model keeps the safe entries,
// GameScene.onVignetteComplete applies them).
check('effects: cargoHold keeps a positive bay count', normalizeEffects({ cargoHold: 4 })?.cargoHold === 4);
check('effects: a non-integer hold floors (4.7 → 4)', normalizeEffects({ cargoHold: 4.7 })?.cargoHold === 4);
check('effects: a zero/negative hold is dropped (no 0-bay build-out)', normalizeEffects({ cargoHold: 0 }) === null && normalizeEffects({ cargoHold: -2 }) === null);
check('effects: an unknown entry alone yields nothing', normalizeEffects({ anything: true }) === null);
check('effects: a missing/invalid block reads as none', normalizeEffects(null) === null && normalizeEffects('4') === null);
check('effects: a scene without the block ships none', normalizeVignette({ id: 'x', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }] }, mkCfg([]))?.effects === null);
check('a scene: an unknown speaker drops the whole scene', (() => {
const v = normalizeVignette(
{ id: 'ghost', location: 'bar', lines: [{ from: 'carol', text: 'Hi.' }, { from: 'no-one', text: '??' }] },

View File

@ -102,9 +102,15 @@ export function captureState(scene, now) {
// the absence as 0 (old saves load).
credits: Math.max(0, Math.round(Number(scene.ship.credits) || 0)),
// The trade-goods hold (data/economy.json → cargo) — the good ids in
// the filled bays, in fill order (one good per bay). Saves predating it
// lack the field; the restore treats the absence as an empty hold.
// the filled bays, in fill order (one good per bay).
cargo: scene.ship.cargo ? [...scene.ship.cargo.items] : [],
// The UNLOCKED cargo bays (ship.stats.cargoHold) — the run's ship
// starts with 0 (data/ship.json); the bar scene's DONE press builds
// it out (GameScene.onVignetteComplete). It is SHIP state (like the
// hold above), so the save carries it — a load restores it before
// the goods go back in (a save predating the field has none — the
// fresh ship's stat stands, old saves load).
cargoHold: Math.max(0, Math.floor(Number(scene.ship.stats?.cargoHold) || 0)),
},
discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} },
reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(),
@ -168,8 +174,8 @@ export function captureState(scene, now) {
// the surface SHOP gate: GameScene.isShopUnlocked). The witnesses live
// on the scene (this.story, the registry's 'story'); a save predating
// it has no field, and the restore stages the fresh (all-false) ledger
// (old saves load — the shop gate re-arms on the next capital landing,
// and the package handoff runs with it).
// (old saves load — the shop gate re-arms on the next capital landing;
// the handoff rides the bar scene's DONE press, onVignetteComplete).
story: {
capitalLanded: scene.story?.capitalLanded === true,
launchedFromCapital: scene.story?.launchedFromCapital === true,
@ -281,8 +287,9 @@ export function prepareLoad(registry, record) {
registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 0)));
// THE RUN'S STORY LEDGER (a save predating it has no field → the fresh
// all-false ledger — old saves load; the shop gate re-arms on the next
// capital landing, and the package handoff runs with it). Normalized
// here so a hand-edited record can't leak a bad shape into the scene.
// capital landing; the handoff rides the bar scene's DONE press).
// Normalized here so a hand-edited record can't leak a bad shape into
// the scene.
registry.set('story', {
capitalLanded: record.story?.capitalLanded === true,
launchedFromCapital: record.story?.launchedFromCapital === true,

View File

@ -1178,12 +1178,6 @@ export class GameScene extends Phaser.Scene {
delivered: storyRaw?.delivered === true, // step 3's proof (monotonic — the delivery stands)
};
this.registry.set('story', this.story);
// One-shot: a capital landing JUST placed the special package in the
// hold — startLanding passes the flag in the SurfaceScene's launch
// data (the surface calls the handoff out in its console slot) and it
// drops after the hand-off. Fresh per scene, so it never survives a
// load (the save has already carried the package in the hold).
this._packageHandoffPending = false;
// deck progress bar — drawn over the RESEARCH button while a project runs
this._researchDeckBar = {
@ -2631,6 +2625,11 @@ export class GameScene extends Phaser.Scene {
// these the restore below would re-seed 0 credits and an empty hold.
credits: rec.ship?.credits ?? 0,
cargo: rec.ship?.cargo ?? [],
// The unlocked cargo bays ride the jump too (the bar scene's DONE
// beat — effects.cargoHold — or a cargo-bay build). Without this the
// restore would re-seed the fresh ship's 0 and shed the goods
// aboard (the 4-bay hold would die on the first jump).
cargoHold: rec.ship?.cargoHold ?? 0,
};
rec.tethers = []; // the destination's native set re-forms in create()
prepareLoad(this.registry, rec);
@ -3020,11 +3019,7 @@ export class GameScene extends Phaser.Scene {
type: target.kindLabel ?? '',
tetherLevel: this.tetherLevelFor(name),
station: target.isStation === true,
// The handoff flag (the capital's package beat) — the surface reads
// it in init and calls it out in its console slot, then drops it.
packageHandoff: this._packageHandoffPending === true,
});
this._packageHandoffPending = false; // consumed by this launch
this.scene.sleep();
}
@ -3086,14 +3081,17 @@ export class GameScene extends Phaser.Scene {
// 'story' — SaveData carries it to record.story and back):
// · _recordLanding — startLanding (a real arrival): the last
// world landed on; the capital landing
// (the bar — the shop gate lifts + the
// package handoff runs);
// (the bar — the shop gate lifts);
// · onVignetteComplete — the bar scene's DONE press: the hold is
// built out (the scene's effects) and the
// special package lands in the hold;
// · onSurfaceDeparture — SurfaceScene.finishTakeoff (a real
// launch): the capital launch sets the
// destination back to the HOME WORLD (the
// delivery's route — the compass points it).
// The witnesses are persistent, so a save/reload keeps the shop gate
// lifted, the handoff done, and the delivery leg in flight.
// lifted, the package aboard (the cargo step's witness), and the
// delivery leg in flight.
// ---------------------------------------------------------------
/** Is this world the NEUTRAL CAPITAL (the neutralCapitalTarget's world —
@ -3129,12 +3127,14 @@ export class GameScene extends Phaser.Scene {
* · the delivery's proof (step 3) — the HOME landing that FOLLOWS the
* capital's launch (the leg's destination), recorded MONOTONIC: once
* delivered, a later landing elsewhere can't un-deliver the quest;
* · the NEUTRAL CAPITAL landing (the quest's step 1 — Trestle and
* Carol at the bar): the shop gate lifts (isShopUnlocked) and the
* package handoff runs (_handoffSpecialPackage).
* Pure bookkeeping + the handoff effect — the checklist itself is live,
* so _questRefresh announces a READY quest if the landing was the last
* piece (it usually is not: the upgrade + delivery still stand).
* · the NEUTRAL CAPITAL landing (the quest's bar beat): the shop gate
* lifts (isShopUnlocked). The package handoff does NOT run here —
* it rides the bar scene's DONE press (onVignetteComplete: the
* hold's built out first, the package second — the ship starts
* with cargoHold 0 and can't take the parcel until then).
* Pure bookkeeping — the checklist itself is live, so _questRefresh
* announces a READY quest if the landing was the last piece (the
* delivery, on a home return).
*/
_recordLanding(name) {
if (!this.story) return;
@ -3142,40 +3142,81 @@ export class GameScene extends Phaser.Scene {
if (this.story.launchedFromCapital === true && this.isHomeWorld(name)) {
this.story.delivered = true; // step 3 made — the parcel reached the home world
}
if (!this.isCapitalWorld(name)) return;
this.story.capitalLanded = true;
this._handoffSpecialPackage(); // the package joins the hold (idempotent)
if (this.isCapitalWorld(name)) {
this.story.capitalLanded = true; // the shop gate lifts (the bar's scene runs its own beat)
}
this._questRefresh(); // a step may have just landed (the delivery, on a home return)
}
/**
* The package handoff (the quest's packageGood — data/quests.json →
* special_delivery.packageGood, the 'special-package' in
* data/goods.json, sheet frame 114): on the capital landing the
* SPECIAL PACKAGE joins the hold. ONE per ship — a re-landing is a
* no-op while it is aboard; a full hold makes room by dropping the
* OLDEST ordinary good (the package is the quest, cargo is trade). The
* player-facing beat is the SurfaceScene's console note (startLanding's
* packageHandoff flag) — the flight HUD sits behind the surface's
* opaque plate for the whole stay.
* data/goods.json, sheet frame 114): the SPECIAL PACKAGE joins the
* hold. ONE per ship — a repeat is a no-op while it is aboard; a full
* hold makes room by dropping the OLDEST ordinary good (the package is
* the quest, cargo is trade — defensive: the scene's hold is fresh and
* empty when this runs). Called by onVignetteComplete AFTER the
* hold's upgrade (the parcel must fit — the ship starts with 0 bays).
* The player-facing beat is the SurfaceScene's console note (the
* completion carries it out — the flight HUD sits behind the surface's
* opaque plate for the whole stay).
* @returns {boolean} true when the package was JUST placed (the beat is due)
*/
_handoffSpecialPackage() {
_handoffSpecialPackage(goodId = null) {
const ship = this.ship;
if (!ship?.cargo) return;
const goodId = questDef('special_delivery')?.packageGood;
if (!goodId) return; // the quest data is gone — the handoff is off too
if (ship.cargo.has(goodId)) return; // already aboard — idempotent
if (!ship?.cargo) return false;
const id = String(goodId ?? questDef('special_delivery')?.packageGood ?? '').trim();
if (!id) return false; // the quest data is gone — the handoff is off too
if (ship.cargo.has(id)) return false; // already aboard — idempotent
if (ship.cargo.room <= 0) {
const i = ship.cargo.items.findIndex((x) => x !== goodId);
if (i < 0) return; // a hold of nothing but the package (it can't be)
const i = ship.cargo.items.findIndex((x) => x !== id);
if (i < 0) return false; // a hold of nothing but the package (it can't be)
ship.cargo.items.splice(i, 1); // make room — the package outranks trade
}
if (ship.cargo.add(goodId) <= 0) return;
// The player-facing beat lands on the SURFACE (the opaque plate hides
// the flight HUD's toasts for the whole stay): startLanding passes the
// one-shot flag in the launch data and the SurfaceScene calls it out
// in its own console slot (the bar — Trestle and Carol's beat).
this._packageHandoffPending = true;
return ship.cargo.add(id) > 0;
}
/**
* A VIGNETTE scene's DONE press (SurfaceScene._vignetteComplete → here,
* the ledger's owner): the scene's story beat, in order —
* 1. the scene's EFFECTS (data/vignettes.json → effects): the
* cargo-hold build-out (effects.cargoHold, MAX-wins — the run's
* ship starts with cargoHold 0, data/ship.json; Carol's crew is
* the labor, the scene's lines are the truth);
* 2. the scene's QUEST's package (data/quests.json → packageGood):
* the 'special-package' joins the hold — AFTER the upgrade (the
* hold must fit it). Its presence is the 'cargoUpgraded' step's
* witness (the check's id) — the parcel aboard proves the ship
* can hold cargo.
* _questRefresh then stands the just-made steps (steps 1 + 2) DONE.
* @returns {{upgradedTo: number|null, handoff: boolean}} the beats that
* fired (the SurfaceScene calls them out in its console slot)
*/
onVignetteComplete(vig) {
const out = { upgradedTo: null, handoff: false };
if (!vig) return out;
// 1 — the hold's built out (the scene's effects — pure data the
// model kept): MAX-wins, never a downgrade (a later hold — a
// cargo-bay build, 8/12/16 — is never stepped down by this).
const hold = Math.floor(Number(vig.effects?.cargoHold) || 0);
if (hold > 0 && typeof this.ship?.setCargoHold === 'function') {
const cur = Number(this.ship.stats?.cargoHold) || 0;
if (hold > cur) {
out.upgradedTo = this.ship.setCargoHold(hold);
this.refreshCreditsHud(); // the hold is now worth more — keep the readout honest
}
}
// 2 — the scene's quest's package (the handoff): the parcel joins the
// hold NOW — its presence is the 'cargoUpgraded' witness.
const questId = String(vig.quest ?? '').trim();
if (questId !== '') {
const goodId = questDef(questId)?.packageGood;
if (goodId && this._handoffSpecialPackage(goodId)) out.handoff = true;
}
if (out.upgradedTo != null || out.handoff) {
this._questRefresh(); // steps 1 + 2 may stand done now
}
return out;
}
/**
@ -3609,8 +3650,13 @@ export class GameScene extends Phaser.Scene {
* trestleMet — Trestle's arrival pair played (the comms ledger)
* capitalLanded — the run landed on the neutral capital (this.story —
* the 'Special Delivery' bar beat + the shop's gate)
* cargoUpgraded — a cargo-bay build is installed (BuildState — any
* build with effects.cargo.bays, data/builds.json)
* cargoUpgraded — the quest's package is ABOARD (check.id — the
* 'special-package', data/goods.json): it only fits
* once the hold is upgraded (the run's ship starts
* with cargoHold 0, data/ship.json), and the bar
* scene's DONE press is what upgrades + loads it
* (onVignetteComplete) — the parcel aboard is the
* upgrade's proof
* packageDelivered— the delivery's proof holds (this.story.delivered —
* the home landing that followed the capital's launch)
* vignettePlayed — the named VIGNETTE scene completed (registry
@ -3693,22 +3739,19 @@ export class GameScene extends Phaser.Scene {
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'cargoUpgraded') {
// Step 2 — the ship can HOLD CARGO: any installed build whose effect
// grows the hold (data/builds.json → the cargo-bay builds,
// effects.cargo.bays — the same spec _applyCargoUpgrade installs).
// The record is ship-scoped (BuildState is shared), so any world's
// build counts — the hold is the ship's.
const built = this.buildState?.built ?? new Map();
let done = false;
outer: for (const ids of built.values()) {
for (const id of ids) {
const spec = defById(id)?.effects?.cargo;
if (spec && typeof spec === 'object' && Number(spec.bays) > 0) {
done = true;
break outer;
}
}
}
// Step 2 — the ship can HOLD CARGO: the witness is the quest's
// package ABOARD (check.id — the 'special-package' in
// data/goods.json; the quest's packageGood is the fallback). It
// only fits once the hold is upgraded (the run's ship starts with
// cargoHold 0 — data/ship.json), and the bar scene's DONE press is
// what does the upgrade + the load (onVignetteComplete), so the
// parcel's presence IS the upgrade's proof — the step stands done
// the moment the package joins the hold (the scene's lines are the
// truth: Carol's crew built out the bays).
const goodId =
String(check.id ?? '').trim() ||
String(questDef('special_delivery')?.packageGood ?? '').trim();
const done = goodId !== '' && this.ship?.cargo?.has?.(goodId) === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'packageDelivered') {
@ -5391,9 +5434,22 @@ export class GameScene extends Phaser.Scene {
// The credit ledger (data/economy.json) — a save predating credits
// has no field; setCredits floors a missing/invalid value at 0.
this.ship.setCredits(r.ship.credits ?? 0);
// The unlocked cargo bays (ship.stats.cargoHold) — the run's ship
// starts with 0 (data/ship.json) and the bar scene's DONE press is
// what builds it out (onVignetteComplete); the hold is ship state,
// so the save carries it and the load restores it BEFORE the goods
// go back in. A save predating the field is the old-story era (the
// package landed on the capital landing and the ship's base hold
// was 4): fall back to "enough bays for the goods it carried" so a
// mid-delivery save's package survives the load.
if (typeof this.ship.setCargoHold === 'function') {
const carried = Array.isArray(r.ship.cargo) ? r.ship.cargo.length : 0;
const hold = typeof r.ship.cargoHold === 'number' ? r.ship.cargoHold : carried;
if (hold > (Number(this.ship.stats?.cargoHold) || 0)) this.ship.setCargoHold(hold);
}
// The trade-goods hold (data/economy.json → cargo) — a save predating
// cargo has no field; set() degrades to an empty hold (old saves load).
// Capacity stays the ship's stat (4, or an upgraded hold).
// Capacity is the restored stat above (0, 4, or an upgraded hold).
if (this.ship.cargo) this.ship.cargo.set(r.ship.cargo ?? []);
}
// The corner readouts (upper right) were built with the FRESH hold (0)

View File

@ -88,12 +88,6 @@ export class SurfaceScene extends Phaser.Scene {
// (data/music.json → stationFrames). Surface/shop clips are authored
// later — null slots degrade exactly like an unset planet slot.
this.isStation = data.station === true;
// The 'Special Delivery' handoff flag (GameScene.startLanding — the
// capital's landing JUST placed the special package in the hold):
// the surface calls the beat out in its console slot (the flight HUD's
// toasts sit behind this opaque plate for the whole stay). One-shot
// per stay — a re-landing with the package already aboard sets none.
this.packageHandoff = data.packageHandoff === true;
this.phase = 'landing'; // 'landing' → 'surface'
this.lastClickT = 0; // last pointerdown (performance.now(), ms) — the double-click skip
this.musicSpec = (this.isStation ? 'music.stationFrames.' : 'music.frames.') + this.planetFrame; // the surface loop (data/music.json)
@ -272,21 +266,6 @@ export class SurfaceScene extends Phaser.Scene {
}
this.buildDeck();
// The 'Special Delivery' handoff beat — the bar (Trestle + Carol) and
// the package now in the hold. Delayed a beat so the deck + HUD land
// first (the note shares the flight world's console slot).
if (this.packageHandoff) {
this.packageHandoff = false; // one note per handoff
this.time.delayedCall(700, () => {
if (this.phase !== 'surface') return;
this.consoleNote('SPECIAL PACKAGE SECURED IN THE CARGO HOLD', {
glyph: '\u2726',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3600,
});
});
}
}
// ------------------------------------------------------------------
@ -664,11 +643,34 @@ export class SurfaceScene extends Phaser.Scene {
/**
* The scene completed (the hub's onComplete — the played ledger is
* already marked, which is the step's 'vignettePlayed' witness): the
* location resumes. The bar is the loop again; the shop raises its
* location resumes. The scene's STORY BEAT runs first (GameScene — the
* ledger's owner): the hold is built out (the scene's effects — the
* run's ship starts with cargoHold 0, data/ship.json), THEN the
* scene's quest's package joins the hold (it must fit — and its
* presence is the 'cargoUpgraded' step's witness, so both steps stand
* done at the DONE press). The surface calls the beats out in its
* console slot (the flight HUD sits behind the opaque plate for the
* whole stay). The bar is the loop again; the shop raises its
* buy/sell view now — it deferred from entry while the scene held
* (the scene plays before the player can touch buy/sell).
*/
_vignetteComplete(vig) {
const g = this.gameScene ?? this.scene?.get?.('GameScene') ?? null;
const beat = typeof g?.onVignetteComplete === 'function' ? g.onVignetteComplete(vig) : null;
if (beat?.upgradedTo != null) {
this.consoleNote(`CARGO HOLD UPGRADED — ${beat.upgradedTo} BAYS`, {
glyph: '\u25b2',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3600,
});
}
if (beat?.handoff === true) {
this.consoleNote('SPECIAL PACKAGE SECURED IN THE CARGO HOLD', {
glyph: '\u2726',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3600,
});
}
if (this.shopMode) this.buildShopView();
this.vignetteLocation = null;
}

View File

@ -7,7 +7,9 @@
* speaker + text + an optional speech clip, played in a window centered
* on the screen over the location's looping clip (js/ui/VignetteWindow.js).
*
* { id, location, quest?, step?, once, lines: [{ from, text, audio? }] }
* { id, location, quest?, step?, once,
* effects?: { cargoHold? }, // the DONE press's story beat (pure data — GameScene applies it)
* lines: [{ from, text, audio? }] }
*
* This module normalizes the config (dropping — with a console warning —
* anything that can't be shown) and answers the questions the hub asks:
@ -131,6 +133,12 @@ export function normalizeLine(raw) {
* - quest: optional (no gate = the scene plays on every visit); when
* present it must name a non-empty quest id;
* - step: the gate's step index (default 0, clamped to >= 0);
* - effects: the story beat the DONE press runs (a plain object —
* normalizeEffects keeps the safe entries: cargoHold = a positive
* bay count; the run's ship starts with cargoHold 0 (data/ship.json)
* and the scene's lines are the truth — Carol's crew builds the bays).
* GameScene.onVignetteComplete applies it, BEFORE the scene's quest
* package handoff (the hold must fit the package);
* - once defaults true (a scene plays once per run; `once: false`
* re-arms on every visit — the author's repeat).
*
@ -173,10 +181,32 @@ export function normalizeVignette(raw, cfg) {
quest: quest || null,
step: quest ? step : null,
once: raw.once !== false,
effects: normalizeEffects(raw.effects),
lines,
};
}
/**
* The scene's story effects (data/vignettes.json → vignettes[].effects) —
* the beat the DONE press runs BEFORE the location resumes. Pure data:
* the model keeps the safe entries, GameScene.onVignetteComplete applies
* them.
*
* cargoHold — the ship's unlocked cargo bays (a positive integer; the
* run's ship starts with 0 — data/ship.json — and the
* application is MAX-wins, never a downgrade).
*
* @returns {object|null} the clean effects, or null when none survived
*/
export function normalizeEffects(raw) {
if (!raw || typeof raw !== 'object') return null;
const out = {};
const hold = Number(raw.cargoHold);
if (Number.isFinite(hold) && hold > 0) out.cargoHold = Math.floor(hold);
if (Object.keys(out).length === 0) return null;
return out;
}
/**
* The playable roster: the normalized vignettes in config order, minus
* the unplayable ones (warned per id). The characters table rides in