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:
parent
bd8b161584
commit
55db379a93
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 }] };
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '??' }] },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue