Add "Special Delivery" quest with package handoff and shop gating

- New main quest granted after claiming "Leaving the Nest": land at the neutral capital (the bar), upgrade cargo bays, then deliver the sealed package to the home world.
- Introduce a non-tradeable `special-package` good (sheet frame 114) that no shop stocks or buys back; `Economy.isTradeable` filters it out of every floor and the sell grid refuses to queue it.
- Record story witnesses (`capitalLanded`, `launchedFromCapital`, `lastLandedWorld`, `delivered`) in a registry-backed ledger, persisted via SaveData so progress survives load and reset clears it.
- Gate the surface shop behind the capital landing: deck slot disabled and `toggleShop` refuses until `isShopUnlocked`.
- On capital departure, set the destination back to the home world so the compass marks the delivery leg; delivery proof is monotonic once recorded.
- Surface console note announces the package handoff when it first lands in the hold.
- Extend economy and quest test suites to cover the new check types, non-tradeable exclusion, unlock chain, and save/load of the story ledger.
This commit is contained in:
Brian Fertig 2026-09-13 08:49:50 -06:00
parent efaa559efa
commit 1e3beccb22
10 changed files with 422 additions and 13 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -19,10 +19,12 @@
"tech": { "label": "Technology", "order": 6 },
"pharma": { "label": "Pharma & Biotech", "order": 7 },
"luxury": { "label": "Luxury & Exotic", "order": 8 },
"gemstone": { "label": "Gemstones", "order": 9 }
"gemstone": { "label": "Gemstones", "order": 9 },
"quest": { "label": "Quest Cargo", "order": 10,
"_comment": "The run's story cargo (the 'Special Delivery' package, data/quests.json) — tradeable:false, so no shop stocks or buys it. Listed last: it is the one good the economy does not price." }
},
"sheet": {
"_comment": "The goods spritesheet (assets/images/goods.png) — 128×128 icon frames, 12 across (1536×1280 = 120 cells), read left→right then top→bottom. Frame index N = the Nth entry in goods below (each also carries its index in icon — set that field to reflow the sheet without moving entries). Cell for frame N: col = N % 12, row = floor(N / 12), top-left at (col·128, row·128). Categories are contiguous blocks: food 0–13 · water 14–21 · energy 22–35 · metals 36–53 · industrial 54–67 · tech 68–85 · pharma 86–95 · luxury 96–105 · gemstone 106–113 (the 8 signature gems sit in the bottom-right corner).",
"_comment": "The goods spritesheet (assets/images/goods.png) — 128×128 icon frames, 12 across (1536×1280 = 120 cells), read left→right then top→bottom. Frame index N = the Nth entry in goods below (each also carries its index in icon — set that field to reflow the sheet without moving entries). Cell for frame N: col = N % 12, row = floor(N / 12), top-left at (col·128, row·128). Categories are contiguous blocks: food 0–13 · water 14–21 · energy 22–35 · metals 36–53 · industrial 54–67 · tech 68–85 · pharma 86–95 · luxury 96–105 · gemstone 106–113 (the 8 signature gems sit in the bottom-right corner). Cell 114 is the 'Special Delivery' package (special-package, the quest's cargo — the sheet's first spare cell).",
"texture": "assets/images/goods.png",
"frameWidth": 128,
"frameHeight": 128,
@ -151,6 +153,9 @@
{ "id": "frost-diamond", "name": "Frost Diamond", "category": "gemstone", "tier": 3, "baseValue": 135, "producer": ["ice"], "demand": ["lava","arid","rocky","gas","capital"], "weight": 3, "flavor": "Cold, cut to a point.", "icon": 110 },
{ "id": "magma-ruby", "name": "Magma Ruby", "category": "gemstone", "tier": 3, "baseValue": 128, "producer": ["lava"], "demand": ["ice","rocky","gas","capital","terran"], "weight": 3, "flavor": "Fire, held still.", "icon": 111 },
{ "id": "aether-crystal", "name": "Aether Crystal", "category": "gemstone", "tier": 3, "baseValue": 140, "producer": ["station"], "demand": ["capital","rocky","gas"], "weight": 2, "flavor": "Grown in the quiet between stars.", "icon": 112 },
{ "id": "imperial-diamond", "name": "Imperial Diamond", "category": "gemstone", "tier": 4, "baseValue": 300, "producer": ["capital"], "demand": ["capital","station","terran","rocky","gas","ice","lava"],"weight": 2, "flavor": "The measure by which all others are weighed.", "icon": 113 }
{ "id": "imperial-diamond", "name": "Imperial Diamond", "category": "gemstone", "tier": 4, "baseValue": 300, "producer": ["capital"], "demand": ["capital","station","terran","rocky","gas","ice","lava"],"weight": 2, "flavor": "The measure by which all others are weighed.", "icon": 113 },
{ "id": "special-package", "name": "Special Package", "category": "quest", "tier": 1, "baseValue": 0, "producer": [], "demand": [], "weight": 0, "flavor": "Sealed. Do not open. It will wait for you.", "icon": 114, "tradeable": false,
"_comment": "'Special Delivery' quest cargo (data/quests.json → special_delivery.packageGood): it lands in the ship's hold on the NEUTRAL CAPITAL's landing (GameScene._recordLanding → _handoffSpecialPackage) and must be carried home (the delivery — land on the home world). tradeable:false — no shop floor stocks it, the capital does not buy it back (Economy.shopFor filters it out), and the SELL grid refuses to queue it (Economy.isTradeable, TradeGrid.toggleBay). It is the catalog's one quest good: the sheet's spare cell 114 (assets/images/goods.png — 120 cells, 12 across)." }
]
}

View File

@ -140,6 +140,29 @@
"credits": 200,
"_comment": "The completion reward — 200 Credits, claimed via the CLAIM REWARD action."
}
},
{
"id": "special_delivery",
"category": "main",
"icon": "diamond",
"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 are witnessed by the run's STORY ledger (registry 'story', saved as record.story — GameScene._recordLanding / onSurfaceDeparture): step 1 'capitalLanded' — a real landing on the NEUTRAL CAPITAL WORLD (startLanding — the bar; the shop gate lifts with it, 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').",
"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"]
},
"packageGood": "special-package",
"checks": [
{ "type": "capitalLanded", "label": "Meet with Trestle and Carol at the neutral capital's bar" },
{ "type": "cargoUpgraded", "label": "Upgrade Ship to hold Cargo" },
{ "type": "packageDelivered", "label": "Deliver Package for Carol" }
],
"reward": {
"credits": 200,
"_comment": "The completion reward — 200 Credits, claimed via the CLAIM REWARD action."
}
}
]
}

View File

@ -57,6 +57,12 @@ const check = (label, cond) => {
const SEED = 'orbit-economy-test';
const g = Galaxy.create(SEED);
const goods = config.get('goods.goods', []);
// The catalog's quest cargo (tradeable: false — the 'Special Delivery'
// package, data/goods.json) is the one good the economy does not price:
// the invariants below that assume a good is a commodity (a producer map,
// a buy-back, a floor slot) run over the TRADEABLE subset, and a dedicated
// section (§5b) pins the package's exclusion from every shop in the galaxy.
const tradeable = goods.filter((x) => x.tradeable !== false);
const clsOf = (sysId) => [...g.economy.classBySystem.get(sysId)];
const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.classBySystem.get(sysId).has(c));
@ -66,7 +72,7 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
{
check('galaxy.economy exists', !!g.economy);
check('classBySystem covers every system', g.economy.classBySystem.size === g.byId.size);
check('producerHops has an entry for every good', goods.every((x) => g.economy.producerHops[x.id] && Object.keys(g.economy.producerHops[x.id]).length > 0));
check('producerHops has an entry for every tradeable good', tradeable.every((x) => g.economy.producerHops[x.id] && Object.keys(g.economy.producerHops[x.id]).length > 0));
const home = clsOf(g.homeSystemId);
check('home system carries terran (the home world)', home.includes('terran'));
@ -79,9 +85,11 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
const missing = wantClasses.filter((c) => !present.has(c));
check(`every catalog class exists in the galaxy (missing: ${missing.join(', ') || 'none'})`, missing.length === 0);
// Every good has at least one producing system (the loop is possible).
const unproducible = goods.filter((x) => !goods.length || ![...g.economy.classBySystem.values()].some((s) => x.producer.some((c) => s.has(c))));
check('every good is produced somewhere', unproducible.length === 0);
// Every TRADEABLE good has at least one producing system (the loop is
// possible) — the quest cargo is exempt: nothing produces the package,
// it arrives by the quest's handoff, not by the trade map.
const unproducible = tradeable.filter((x) => !tradeable.length || ![...g.economy.classBySystem.values()].some((s) => x.producer.some((c) => s.has(c))));
check('every tradeable good is produced somewhere', unproducible.length === 0);
}
// ---------------------------------------------------------------------------
@ -187,7 +195,7 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
const capSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('capital'))[0];
const cshop = E.shopFor('capital', capSys, g);
check('a capital buys back EVERYTHING (universal sink)', cshop.sell.length === goods.length);
check('a capital buys back EVERY tradeable good (universal sink)', cshop.sell.length === tradeable.length);
check('a capital floor is 8–16 goods', cshop.buy.length >= 8 && cshop.buy.length <= 16);
// THE WORLD'S JEWEL (§4/§6): every shop carries ITS class's signature gem —
@ -210,6 +218,30 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
check(`every shop carries its class's signature gem (${gemWorlds - gemMissing}/${gemWorlds} worlds)`, gemWorlds > 0 && gemMissing === 0);
}
// ---------------------------------------------------------------------------
// 5b. The quest cargo — the 'Special Delivery' package (non-tradeable)
// ---------------------------------------------------------------------------
{
const pkg = goods.find((x) => x.id === 'special-package');
check('the catalog carries the Special Package (sheet frame 114)', !!pkg && pkg.name === 'Special Package' && pkg.icon === 114);
check('isTradeable() says NO for the package', E.isTradeable('special-package') === false);
check('isTradeable() says YES for an ordinary good', E.isTradeable(tradeable[0]?.id ?? 'fresh-produce') === true);
check('isTradeable() is false for an unknown id', E.isTradeable('no-such-good') === false);
// The package never stocks and never buy-backs — on ANY class of ANY
// system (sweep the whole galaxy, not just one floor: the capital's
// "EVERYTHING" floor is the one that would leak it).
let worlds = 0;
let leaked = 0;
for (const [id, set] of g.economy.classBySystem) {
for (const cls of set) {
worlds++;
const shop = E.shopFor(cls, id, g);
if (shop.buy.some((b) => b.id === 'special-package') || shop.sell.some((s) => s.id === 'special-package')) leaked++;
}
}
check(`no shop in the galaxy carries the Special Package (${worlds} floors swept)`, worlds > 0 && leaked === 0);
}
// ---------------------------------------------------------------------------
// 6. Credits start at 0 (the config the Ship reads)
// ---------------------------------------------------------------------------

View File

@ -123,7 +123,7 @@ check('starter: reward fits the ship\u2019s hold',
// The requirement types the scene evaluates (GameScene._questCheckEval)
// — a typo here would be a checklist row that never completes.
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet']);
const CHECK_TYPES = new Set(['research', 'mineralsMined', 'tetherBuilt', 'discoveredWorld', 'systemCharted', 'gatesActivated', 'trestleTalked', 'trestleMet', 'capitalLanded', 'cargoUpgraded', 'packageDelivered']);
check('starter: every check type is a known live check',
(starter?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
@ -176,6 +176,43 @@ for (const c of starter?.checks ?? []) {
check('nest: the reward is a positive integer (credits)', Number.isInteger(q?.reward?.credits) && q.reward.credits > 0);
}
// ----------------------------------------------------------------------
// 5c. The 'Special Delivery' quest — the nest quest's follow-up chain
// ----------------------------------------------------------------------
{
const goods = read('goods.json');
const q = (quests.quests ?? []).find((x) => x.id === 'special_delivery');
check('delivery: the quest is defined', !!q);
check('delivery: sits in the MAIN story', q?.category === 'main');
check('delivery: has id/title/description/issuer', typeof q?.id === 'string' && typeof q?.title === 'string' && typeof q?.description === 'string' && typeof q?.issuer === 'string');
check('delivery: icon names a research icon', typeof q?.icon === 'string' && q.icon.length > 0);
check('delivery: NOT a starter (it grants by its unlock requirement)', q?.starter !== true);
// The grant condition — 'Leaving the Nest' CLAIMED (the run's chain).
check('delivery: unlocks on leaving_the_nest being claimed', JSON.stringify(q?.unlocks?.claimed) === JSON.stringify(['leaving_the_nest']));
check('delivery: the id in unlocks.claimed is a defined quest', (q?.unlocks?.claimed ?? []).every((id) => questDef(id) !== null));
// The three checks, in order — the bar, the hold, the delivery.
check('delivery: the three checklist lines (the brief, verbatim)', JSON.stringify((q?.checks ?? []).map((c) => c.label)) === JSON.stringify([
"Meet with Trestle and Carol at the neutral capital's bar",
'Upgrade Ship to hold Cargo',
'Deliver Package for Carol',
]));
check('delivery: every check type is a known live check', (q?.checks ?? []).every((c) => CHECK_TYPES.has(c.type)));
const byType = Object.fromEntries((q?.checks ?? []).map((c) => [c.type, c]));
check('delivery: the three check types (capitalLanded, cargoUpgraded, packageDelivered)',
['capitalLanded', 'cargoUpgraded', 'packageDelivered'].every((t) => !!byType?.[t]));
check('delivery: the reward is 200 credits', Number.isInteger(q?.reward?.credits) && q.reward.credits === 200);
// The package good — real, on the sheet's spare cell, and NON-TRADEABLE
// (no shop stocks or buys it; the SELL grid refuses to queue it).
const pkgId = q?.packageGood;
const pkg = (goods.goods ?? []).find((x) => x.id === pkgId);
check('delivery: packageGood names a catalog good', typeof pkgId === 'string' && pkgId.length > 0 && !!pkg);
check('delivery: the package is the "Special Package"', pkg?.name === 'Special Package');
check('delivery: the package rides on sheet frame 114', pkg?.icon === 114);
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);
}
// ----------------------------------------------------------------------
// 6. The quest ledger — QuestState (give/claim/save-restore)
// ----------------------------------------------------------------------
@ -222,6 +259,14 @@ check('helpers: starterQuestIds() is exactly [the starter]',
})());
check('unlock: the follow-up is now held', st.isGranted('leaving_the_nest'));
check('unlock: grantUnlocked() is idempotent', st.grantUnlocked().length === 0);
// The SECOND link of the run's chain — 'Leaving the Nest' claimed arms
// 'Special Delivery' (exactly one new grant, idempotent on re-call).
check('unlock: leaving_the_nest\u2019s claim arms special_delivery', (() => {
st.claim('leaving_the_nest');
return JSON.stringify(st.grantUnlocked()) === JSON.stringify(['special_delivery']);
})());
check('unlock: the delivery quest is now held', st.isGranted('special_delivery'));
check('unlock: a re-call with nothing new is a no-op', st.grantUnlocked().length === 0);
check('unlock: a pre-follow-up save (no leaving_the_nest) still loads', (() => {
const r = QuestState.fromJSON({ granted: [starter.id], claimed: [starter.id] });
return r.isGranted(starter.id) && r.isClaimed(starter.id) && !r.isGranted('leaving_the_nest');

View File

@ -106,6 +106,20 @@ export function wants(goodId, cls) {
return !!(g && cls && Array.isArray(g.demand) && g.demand.includes(cls));
}
/**
* Is this good TRADEABLE — part of the shop economy at all? The catalog's
* quest cargo (data/goods.json → tradeable: false — 'Special Delivery''s
* special-package, the run's story parcel) is the exception: it rides in
* the hold, but no shop touches it — shopFor keeps it off every floor and
* buy-back list, and the SELL grid refuses to queue a bay of it
* (js/ui/shop/TradeGrid.js → toggleBay). Unknown ids are tradeable-false
* too (a good that isn't in the catalog can't be a good).
*/
export function isTradeable(goodId) {
const g = goodById(goodId);
return !!g && g.tradeable !== false;
}
// ---------------------------------------------------------------------------
// Deterministic per-shop wobble (±economy.pricing.jitter), so no two shops are
// identical and no value looks "formulated." Same (good, class, system) ⇒ same
@ -198,7 +212,13 @@ export function shopFor(cls, systemId, galaxy) {
const sk = shop.stockByKind?.[kind] ?? {};
const maxStock = sk.max ?? shop.maxStock ?? 16;
const minStock = sk.min ?? shop.minStock ?? 8;
const goods = goodsList();
// THE SHOP FLOOR's catalog — tradeable goods only. The quest cargo
// (tradeable: false, data/goods.json → special-package) is the one good
// the economy does not price: it never stocks, never resells, never
// buy-backs — not on the capital's "EVERYTHING" floor either (the
// package is the run's story, not a commodity; it reaches the hold via
// the quest's handoff, GameScene._handoffSpecialPackage).
const goods = goodsList().filter((g) => isTradeable(g.id));
const seed = galaxy?.seed;
const core = goods.filter((g) => produces(g.id, cls));

View File

@ -30,6 +30,9 @@
* quests: QuestState.toJSON() | null, // held + claimed quest ids
* totalMined: number, // lifetime asteroid mining
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
* story: { capitalLanded, launchedFromCapital, lastLandedWorld, delivered },
* // the 'Special Delivery' witnesses + the surface shop gate
* // (GameScene._recordLanding / onSurfaceDeparture)
* playTimeMs }
*
* captureState(scene) — GameScene → record (the Save panel calls it)
@ -142,6 +145,22 @@ export function captureState(scene, now) {
// requirement's input — the hold is cargo, this is the record).
// A save predating it has no field; the restore stages 0.
totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 0)),
// THE RUN'S STORY LEDGER (data/quests.json → 'Special Delivery' — the
// neutral capital's bar, the package handoff, the delivery leg — and
// 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).
story: {
capitalLanded: scene.story?.capitalLanded === true,
launchedFromCapital: scene.story?.launchedFromCapital === true,
lastLandedWorld:
typeof scene.story?.lastLandedWorld === 'string' && scene.story.lastLandedWorld
? scene.story.lastLandedWorld
: null,
delivered: scene.story?.delivered === true, // step 3's proof (monotonic)
},
// DEPLETED asteroid fields — the clusters the run has mined to
// NOTHING (ids keyed "<systemId>:<clusterId>"), so a load keeps them
// gone: the content regenerates from the seed and GameScene splices
@ -242,6 +261,19 @@ export function prepareLoad(registry, record) {
registry.set('quests', record.quests ? QuestState.fromJSON(record.quests) : null);
// Lifetime asteroid mining (a save predating it has no field → 0).
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.
registry.set('story', {
capitalLanded: record.story?.capitalLanded === true,
launchedFromCapital: record.story?.launchedFromCapital === true,
lastLandedWorld:
typeof record.story?.lastLandedWorld === 'string' && record.story.lastLandedWorld
? record.story.lastLandedWorld
: null,
delivered: record.story?.delivered === true,
});
// DEPLETED asteroid fields (a save predating them has no field → empty
// set — old saves load; GameScene.create() splices them out of the
// regenerated content of the system the run is in).
@ -296,6 +328,7 @@ export function resetRunState(registry) {
registry.set('quests', null);
registry.set('totalMined', null);
registry.set('depletedClusters', null);
registry.set('story', null); // the bar, the handoff, the leg — all fresh
registry.set('commsPlayed', null); // the welcome arms again on the new run
registry.set(PENDING_RESTORE_KEY, null);
}

View File

@ -1152,6 +1152,30 @@ export class GameScene extends Phaser.Scene {
// a fresh run starts at zero, and the New Game reset clears it.
this.totalMined = Math.max(0, Math.round(Number(this.registry.get('totalMined')) || 0));
// The run's STORY ledger (data/quests.json → 'Special Delivery' — the
// neutral capital's bar, the package handoff, the delivery leg — and
// the surface SHOP gate: SurfaceScene reads isShopUnlocked before the
// deck's BUY/SELL slot is allowed). Registry-backed like totalMined:
// a load stages the saved record (SaveData.prepareLoad), a fresh run
// starts blank, and the New Game reset clears it (resetRunState).
const storyRaw = this.registry.get('story') ?? null;
this.story = {
capitalLanded: storyRaw?.capitalLanded === true, // the bar (the shop's gate lifts with it)
launchedFromCapital: storyRaw?.launchedFromCapital === true, // the delivery leg's start
lastLandedWorld:
typeof storyRaw?.lastLandedWorld === 'string' && storyRaw.lastLandedWorld
? storyRaw.lastLandedWorld
: null, // the last world landed on
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 = {
g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false),
@ -2977,13 +3001,21 @@ export class GameScene extends Phaser.Scene {
// the launch, so the surface HUD's tetherLevel snapshot already
// reads 1.
this.ensureLandingTether(name);
// 'Special Delivery' bookkeeping (a real landing, post-guards): the
// story ledger's lastLandedWorld, and on the NEUTRAL CAPITAL — the
// bar: the shop gate lifts + the package lands in the hold.
this._recordLanding(name);
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? target.sheetFrame ?? 0),
name,
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();
}
@ -3037,6 +3069,125 @@ export class GameScene extends Phaser.Scene {
this._questRefresh(); // a tether requirement may have just landed
}
// --------------------------------------------------------------- 'Special Delivery'
// The run's story beats around the neutral capital (data/quests.json →
// special_delivery — granted when 'Leaving the Nest' is claimed). The
// quest's checklist is LIVE (_questCheckEval); these seams own the
// WITNESSES the checks read, recorded in this.story (the registry's
// '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);
// · 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.
// ---------------------------------------------------------------
/** Is this world the NEUTRAL CAPITAL (the neutralCapitalTarget's world —
* matched case-insensitively, the way _capitalLandingBlocked does)? */
isCapitalWorld(worldName) {
const cap = String(this.galaxy?.neutralCapitalTarget?.()?.objectId ?? '').trim();
const name = String(worldName ?? '').trim();
return cap !== '' && cap.toLowerCase() === name.toLowerCase();
}
/**
* The surface SHOP gate (the deck's BUY/SELL slot — the shop is dark
* until the player reaches + LANDS on the neutral capital): true once
* the story ledger holds the capital landing. Read by the SurfaceScene
* (buildDeck's slot enable + the deckAction guard).
*/
isShopUnlocked() {
return this.story?.capitalLanded === true;
}
/** Is this world the HOME WORLD (the run's origin — name-matched against
* this.homeWorldName, case-insensitively)? */
isHomeWorld(worldName) {
const home = String(this.homeWorldName ?? '').trim();
const name = String(worldName ?? '').trim();
return home !== '' && home.toLowerCase() === name.toLowerCase();
}
/**
* 'Special Delivery' bookkeeping on a world landing (startLanding —
* after the guards, so this is a real arrival, not a refused one):
* · the ledger's lastLandedWorld (the run's arrival trail);
* · 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).
*/
_recordLanding(name) {
if (!this.story) return;
this.story.lastLandedWorld = String(name ?? '');
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)
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.
*/
_handoffSpecialPackage() {
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.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)
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;
}
/**
* The surface TAKE OFF seam (SurfaceScene.finishTakeoff → here).
* 'Special Delivery' step 3's leg: launching off the NEUTRAL CAPITAL
* (the bar is done, the package is aboard) records the launch in the
* story ledger and sets the run's destination back to the HOME WORLD
* (the delivery's route — the compass marks it, and the REACHED toast
* fires on the home world's arrival via _checkDestinationReached).
* A launch off any other world is a no-op — the quest's auto-course
* earns itself only from the capital's departure.
*/
onSurfaceDeparture(worldName) {
if (!this.story || !this.isCapitalWorld(worldName)) return;
this.story.launchedFromCapital = true;
this._questRefresh(); // the delivery leg may have just completed
const homeId = this.galaxy?.homeSystemId;
if (!homeId || this.systemRecord?.id === homeId) return; // already home (defensive)
this.setDestination(homeId, 'home'); // the compass points home
}
/**
* Mining phase changes (Mining → onPhase): the scene's share of the
* sequence — the ship's STATE, the ship lock, the console calls, the sfx.
@ -3407,6 +3558,17 @@ export class GameScene extends Phaser.Scene {
* mineralsMined — lifetime asteroid mining >= amount (this.totalMined)
* tetherBuilt — the world's tether standing >= level ('home' = the home world)
* discoveredWorld — discovered planets/stations (not the home world) >= count
* systemCharted — the named system's NAV chart is complete (all its
* planets/stations/jumpgates discovered)
* gatesActivated — the named system's jumpgates are live
* trestleTalked — Trestle's three brief comms played (the comms ledger)
* 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)
* packageDelivered— the delivery's proof holds (this.story.delivered —
* the home landing that followed the capital's launch)
*/
_questCheckEval(check) {
const label = String(check?.label ?? '');
@ -3471,6 +3633,46 @@ export class GameScene extends Phaser.Scene {
// ledger = met.
return this._trestleCheck('trestleArrival', label, 'MET');
}
// 'Special Delivery' (data/quests.json → special_delivery) — the
// witnesses are this.story's ledger (_recordLanding +
// onSurfaceDeparture own the writes; SaveData carries them to
// record.story and back, so the progress survives a load).
if (type === 'capitalLanded') {
// Step 1 — the bar: a real landing on the neutral capital (Trestle
// and Carol). The same world _capitalLandingBlocked gates — the
// arrival that is allowed is the arrival that counts.
const done = this.story?.capitalLanded === true;
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;
}
}
}
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'packageDelivered') {
// Step 3 — the parcel in Carol's hands on the home world: the proof
// (_recordLanding set it) — the HOME landing that followed the
// capital's launch (the leg its departure set). Monotonic by design:
// once the delivery stands, a later landing elsewhere can't take it
// back (the quest can be CLAIMED and stay claimed, the run's record
// is the record).
const done = this.story?.delivered === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
return { label, done: false, detail: '??' };
}

View File

@ -83,6 +83,12 @@ 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)
@ -252,6 +258,21 @@ 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,
});
});
}
}
// ------------------------------------------------------------------
@ -273,6 +294,15 @@ export class SurfaceScene extends Phaser.Scene {
*/
toggleShop() {
if (this.phase !== 'surface') return; // no deck, no shop
// The SHOP gate (GameScene.isShopUnlocked — the shop lifts when the
// run LANDS on the neutral capital, 'Special Delivery' step 1): a
// press before then is refused (buildDeck disables the slot — this is
// the belt-and-braces pass, so a stale keypress or a direct call can't
// open it either).
if (this.gameScene?.isShopUnlocked?.() !== true) {
this.consoleNote('SHOP OFFLINE — REACH THE NEUTRAL CAPITAL FIRST');
return;
}
if (this.shopMode) {
this.leaveShop();
return;
@ -767,6 +797,13 @@ export class SurfaceScene extends Phaser.Scene {
onAction: (id) => this.deckAction(id),
buttons: Array.isArray(deckButtons) ? deckButtons : undefined,
});
// The SHOP gate (the deck's BUY/SELL slot): dark until the player
// reaches the NEUTRAL CAPITAL and lands (GameScene.isShopUnlocked —
// the story ledger's capitalLanded, 'Special Delivery' step 1). Until
// then the slot reads disabled — the press itself is refused in
// toggleShop as the belt-and-braces pass (the gate lives in the
// GameScene's ledger, not this scene, so a save/load can't bypass it).
this.actionBar.setSlotEnabled('shop', this.gameScene?.isShopUnlocked?.() === true);
// The MENU button keeps the GameScene's save system. The run's state
// is in the paused GameScene beneath us — SavePanel captures from it
@ -1097,6 +1134,12 @@ export class SurfaceScene extends Phaser.Scene {
this.setSurfaceMusic(false); // the takeoff clip ends — the hum ends with it
this.destroyVideo(this.takeoffVideo);
this.takeoffVideo = null;
// The 'Special Delivery' departure seam (GameScene.onSurfaceDeparture):
// a launch OFF the neutral capital is the delivery's leg — the run's
// destination is set back to the home world (the compass points it).
// Runs now, while the GameScene still owns the run: the wake back to
// the flight world is the beat the quest's step 3 waits on.
this.gameScene?.onSurfaceDeparture?.(this.planetName);
// The comms beat (data/comms.json: tetherL2Launch — oac-relaunch →
// oac-explore): the player is launching back into space with the
// level-2 tether installed on the HOME world (the run's build record

View File

@ -37,7 +37,7 @@
*/
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js';
import { GOODS_TEXTURE_KEY, goodFrame, isTradeable } from '../../economy/Economy.js';
import { fontStack } from '../../utils/Theme.js';
export class TradeGrid extends Phaser.GameObjects.Container {
@ -246,11 +246,14 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return this.bays[i] ?? null;
}
/** Check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
/** Check / uncheck a bay (1 unit of its good). No-op if empty/locked/
* quest cargo (the special-package's bay is aboard, not for sale —
* data/goods.json → tradeable:false; the tooltip says so on hover). */
toggleBay(i) {
if (i < 0 || i >= this.capacity) return this; // locked bay
const id = this._bayIdAt(i);
if (!id) return this; // empty bay
if (!isTradeable(id)) return this; // quest cargo — the shop refuses it
if (this.sellSel.has(i)) this.sellSel.delete(i);
else this.sellSel.add(i);
this._renderCell(i);
@ -289,6 +292,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
setSellMap(map = {}) {
this.sellSel = new Set();
for (const [id, want] of Object.entries(map ?? {})) {
if (!isTradeable(id)) continue; // quest cargo is never queued for sale
let need = Math.max(0, Math.floor(Number(want) || 0));
if (!need) continue;
for (let i = 0; i < this.capacity && need > 0; i++) {
@ -483,8 +487,10 @@ export class TradeGrid extends Phaser.GameObjects.Container {
if (!goodId) return this.unhover();
const unit = Math.round(this.sellUnit(goodId));
const saleable = isTradeable(goodId);
if (this.tipName && typeof this.tipName.setText === 'function') this.tipName.setText(this._goodName(goodId) || goodId);
if (this.tipPrice && typeof this.tipPrice.setText === 'function') this.tipPrice.setText(`sell for ${unit} cr`);
if (this.tipPrice && typeof this.tipPrice.setText === 'function')
this.tipPrice.setText(saleable ? `sell for ${unit} cr` : 'NOT FOR SALE');
// size from the text metrics (real Phaser or the test stubs)
const nameW = this.tipName?.width ?? 60, nameH = this.tipName?.height ?? 18;