Move the surface shop gate from the deck slot to the Buy/Sell tab

The Shop button on the deck is now always live — the shop sub-bar opens on every world. The "Special Delivery" capital-landing witness now gates only the shop's Buy/Sell tab (ShopView reads GameScene.isTradeUnlocked, which replaces isShopUnlocked), so trading stays dark until the player lands on the neutral capital while the rest of the shop UI is reachable everywhere.

- Rename GameScene.isShopUnlocked → isTradeUnlocked and update all references (SaveData, SurfaceScene, quests.json comments)
- Remove the deck-slot enable guard in SurfaceScene.toggleShop / setBarLocked; the slot now re-arms with the rest of the deck
- Add ShopView.isTradeUnlocked + gate checks in setTab / _onTabTap; gray the Buy/Sell tab via _refreshTabs until the gate lifts
- Update dev probes (scout-*, shop-*) to lift gs.story.capitalLanded so the trade panel is reachable
- Extend shop-ui.test.mjs with a TRADE GATE case covering closed/lifted states
This commit is contained in:
Brian Fertig 2026-09-13 14:46:59 -06:00
parent 55db379a93
commit 942de11dde
18 changed files with 407 additions and 91 deletions

View File

@ -155,7 +155,7 @@
{ "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": "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)." }
{ "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, "sellFor": 0,
"_comment": "'Special Delivery' quest cargo (data/quests.json → special_delivery.packageGood): it lands in the ship's hold at the NEUTRAL CAPITAL's bar scene (GameScene.onVignetteComplete → _handoffSpecialPackage) and must be carried home. tradeable:false — no shop floor stocks it, the capital does not buy it back (Economy.shopFor filters it out), and the SELL grid refuses the bay EVERYWHERE EXCEPT THE HOME WORLD (TradeGrid._sellable — the grid's questSell flag is set only on the home world's shop; the tooltip says NOT FOR SALE elsewhere). On the home world it is saleable at its flat sellFor (ZERO credits — the delivery, not a commodity; Economy.questCargoSellPrice, tradePreview prices it flat where it is saleable). THAT sale is the quest's step 3: ShopView.confirm hands it to GameScene.onQuestPackageSold, which stands the 'packageDelivered' witness (story.delivered, monotonic). It is the catalog's one quest good: the sheet's spare cell 114 (assets/images/goods.png — 120 cells, 12 across)." }
]
}

View File

@ -148,7 +148,7 @@
"title": "Special Delivery",
"description": "The mail routes are dead, and a sealed package that must reach the home world is sitting at the neutral capital's bar. Carol is paying for the run, and Trestle will see it off — the only question is whether your ship can hold the cargo. Meet the two of them at the bar, upgrade the hold, and deliver the package to the home world.",
"issuer": "Carol",
"_comment": "The run's THIRD quest (the 'Leaving the Nest' follow-up — the bar at the neutral capital). It GRANTS the moment 'Leaving the Nest' is COMPLETED AND CLAIMED (unlocks.claimed — the same chain mechanic as leaving_the_nest). The three checks: step 1 'vignettePlayed' (check.id 'special_delivery_bar_meeting' — the bar's dialogue scene in data/vignettes.json: Trestle + Carol, the run's played ledger registry 'vignettesPlayed' is the witness — VignetteHub marks it when the scene's DONE press completes it; the gate — the quest granted, unclaimed, this step open — is what plays it when the bar is entered). The CAPITAL LANDING 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').",
"_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 trade gate lifts (the shop's Buy/Sell tab — ShopView reads GameScene.isTradeUnlocked; the deck's Shop button itself is always live). 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 floor 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): the package SOLD on the HOME WORLD for ZERO CREDITS — the home world is the ONE floor that takes it (TradeGrid's questSell gate — the SELL grid refuses the bay everywhere else, the tooltip reads NOT FOR SALE), priced at the good's flat sellFor (0 cr — Economy.questCargoSellPrice; the shop's Confirm is ShopView.confirm → GameScene.onQuestPackageSold, which records it — the parcel in Carol's hands IS the delivery, landing alone no longer completes the step; the sale is not buyback-eligible). The capital's launch still sets the destination back to the home world (SurfaceScene.finishTakeoff → GameScene.onSurfaceDeparture — the compass points the delivery's route). 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"]
@ -157,7 +157,7 @@
"checks": [
{ "type": "vignettePlayed", "id": "special_delivery_bar_meeting", "label": "Meet with Trestle and Carol at the neutral capital's bar" },
{ "type": "cargoUpgraded", "id": "special-package", "label": "Upgrade Ship to hold Cargo" },
{ "type": "packageDelivered", "label": "Deliver Package for Carol" }
{ "type": "packageDelivered", "label": "Deliver Package for Carol", "_comment": "Step 3 — the witness is the package's HOME-WORLD SALE at zero credits (story.delivered — GameScene.onQuestPackageSold, set by the shop's Confirm of that sale; the grid offers the sale on the home world only). Landing on the home world is no longer the proof." }
],
"reward": {
"credits": 200,

View File

@ -240,6 +240,24 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
}
}
check(`no shop in the galaxy carries the Special Package (${worlds} floors swept)`, worlds > 0 && leaked === 0);
// The delivery (the 'Special Delivery''s step 3 — the package SOLD on
// the home world): the catalog's one non-commodity prices at its flat
// sellFor (zero credits), not the fixed per-shop price — and that
// price is the delivery, not a reward (data/goods.json → sellFor).
check('isQuestCargo() flags the package (and only the package)',
E.isQuestCargo('special-package') === true && E.isQuestCargo(tradeable[0]?.id ?? 'fresh-produce') === false && E.isQuestCargo('no-such-good') === false);
check('questCargoSellPrice(): the parcel sells for zero (the delivery)',
E.questCargoSellPrice('special-package') === 0 && E.questCargoSellPrice(tradeable[0]?.id ?? 'fresh-produce') === 0);
{
const pv = E.tradePreview({
credits: 50, cls: 'industrial', systemId: 'sys-a',
buyMap: {}, sellMap: { 'special-package': 1 },
cargo: ['special-package'],
});
check('tradePreview: the parcel sells at its flat delivery price (0 cr, not the shop price)',
pv.valid === true && pv.sell.length === 1 && pv.sell[0].id === 'special-package' && pv.sell[0].unit === 0 && pv.sellTotal === 0 && pv.net === 0,
`preview ${JSON.stringify(pv)}`);
}
}
// ---------------------------------------------------------------------------

View File

@ -223,7 +223,9 @@ for (const c of starter?.checks ?? []) {
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 is NOT tradeable (the shops refuse it — home world excepted)', pkg?.tradeable === false);
check('delivery: the package sells for zero credits (the home-world sale IS the delivery)',
pkg?.sellFor === 0);
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

View File

@ -36,6 +36,10 @@ window.game = game;
}
if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; }
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// A known ledger + cargo (something to sell), and the run has ENTERED
// every system — the scout then has remote exits to offer.
const goods = config.get('goods.goods', []);

View File

@ -65,6 +65,10 @@ window.game = game;
if (!gs || !gs.ship) throw new Error('GameScene never came up');
step('boot', true);
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// 2 — a known ledger + cargo (something to sell).
const A = config.get('goods.goods', [])[0].id;
gs.ship.credits = 10000;

View File

@ -42,6 +42,10 @@ window.game = game;
}
if (!gs || !gs.ship) { window.__SCOUT_OVERLAP__ = { pass: false, error: 'no GameScene' }; return; }
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// A known ledger + cargo (something to sell), and the run has ENTERED
// every system — the scout then has remote exits to offer.
const goods = config.get('goods.goods', []);

View File

@ -34,6 +34,10 @@ window.game = game;
}
if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; }
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// A known ledger + cargo so the SELL grid has filled bays.
const goods = config.get('goods.goods', []);
gs.ship.credits = 10000;

View File

@ -59,6 +59,10 @@ window.game = game;
if (!gs || !gs.ship) throw new Error('GameScene never came up');
step('boot', true, { frame: gs.planet.sheetFrame });
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// 2 — a known ledger + cargo (something to sell).
const goods = config.get('goods.goods', []);
const A = goods[0].id;

View File

@ -37,6 +37,10 @@ window.game = game;
}
if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; }
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// A known ledger + cargo: TWO goods → bay 0 checked, bay 1 left unchecked
// so the shot shows the selection look next to an idle bay.
const goods = config.get('goods.goods', []);

View File

@ -59,6 +59,10 @@ const bounds = (o) => {
}
if (!gs || !gs.ship) throw new Error('GameScene never came up');
// The probe drives the trade panel on the home world — lift the TRADE gate
// (the 'Special Delivery' capital-landing witness) so Buy/Sell is live.
gs.story.capitalLanded = true;
// Land on the home world + force the surface stage (skip the clip).
const target = gs.commsTargetFor(gs.planet);
gs.startLanding(target);

View File

@ -27,6 +27,10 @@
* - BUYBACK — disabled until a sale; after a sale the goods you sold can be
* bought back at the SALE price (a neutral undo — sell→buyback nets zero,
* no infinite-credits loop); Clear resets the selection.
* - QUEST CARGO — the 'Special Delivery' package (tradeable:false) is NOT
* FOR SALE anywhere except the home world, where it sells for ZERO
* credits and the sale itself stands the delivery (the seam fires with
* the world's name; the parcel is not buyback-eligible).
*
* A fake Scene provides add.text/image/graphics/tweens/etc.; the phaser stub
* (dev/phaser-stub.mjs) provides the Container the components extend.
@ -165,14 +169,25 @@ const A = goods[0].id;
const B = goods[1].id;
const C = stock[0].id; // a good the shop actually sells
function makeView(ship, { reveal = true, topY = 120 } = {}) {
function makeView(ship, { reveal = true, topY = 120, tradeUnlocked = true, worldName = 'Somewhere', homeWorld = null, soldSpy = null } = {}) {
const scene = makeScene();
const gs = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; } };
// gameScene carries the HUD refresh seam + the TRADE gate
// (GameScene.isTradeUnlocked — the 'Special Delivery' capital landing).
const gs = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; }, isTradeUnlocked: () => tradeUnlocked };
if (homeWorld !== null) {
// The delivery gate + seam (GameScene.isHomeWorld / onQuestPackageSold
// — the 'Special Delivery' step 3: the parcel's home-world sale).
gs.isHomeWorld = (n) => String(n ?? '') === homeWorld;
gs.onQuestPackageSold = (n) => { soldSpy?.push(n); return true; };
}
const view = new ShopView(scene, {
ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {},
// topY = the surface HUD's planet-text bottom + padding (what
// SurfaceScene.shopTopY() computes). The panel top should sit here.
topY,
// worldName = this WORLD (SurfaceScene.planetName — the delivery gate
// runs the home check against it; the default is no home, ever).
worldName,
});
if (reveal) view.setTab('trade'); // reveal the panel (what clicking "Buy/Sell" does)
return { scene, view, gs, topY };
@ -262,6 +277,35 @@ console.log('== CONSTRUCT — entry state shows only the tabs; Buy/Sell reveals
}
console.log('CONSTRUCT: OK');
// ===========================================================================
console.log('\n== TRADE GATE — Buy/Sell stays grayed + dead until GameScene.isTradeUnlocked ==');
{
// The gate CLOSED (pre-'Special Delivery' capital landing): the shop screen
// itself opens (the deck's Shop button is always live), but the Buy/Sell
// tab is grayed and refuses to reveal the trade panel.
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship, { reveal: false, tradeUnlocked: false });
assert.equal(view._tabTrade.disabled, true, 'gate closed: Buy/Sell tab is grayed');
assert.equal(view._tabBuyback.disabled, true, 'buyback stays grayed (no sale)');
view.setTab('trade');
assert.equal(view.tab, null, 'setTab(trade) is refused while the gate is closed');
assert.equal(view.sellGrid, null, 'no SELL grid while the gate is closed');
assert.equal(view.buyList, null, 'no BUY list while the gate is closed');
view._onTabTap('trade');
assert.equal(view.tab, null, 'the tab tap is swallowed while the gate is closed');
// The gate LIFTED (the capital-landing witness lands): the same view's tab
// re-arms on the next refresh and the panel reveals.
view.gameScene = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; }, isTradeUnlocked: () => true };
view._refreshTabs();
assert.equal(view._tabTrade.disabled, false, 'gate lifted: Buy/Sell tab re-arms');
view.setTab('trade');
assert.equal(view.tab, 'trade', 'gate lifted: Buy/Sell reveals the panel');
assert.ok(view.sellGrid && view.buyList, 'both panels exist after the gate lifts');
}
console.log('TRADE GATE: OK');
// ===========================================================================
console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCallback ==');
{
@ -679,6 +723,75 @@ console.log('\n== BUYBACK — sell, then buy it back at the sale price (neutral)
}
console.log('BUYBACK: OK');
// ===========================================================================
console.log('\n== QUEST CARGO — the "Special Delivery" package: home-world-only, zero-credit sale ==');
{
const PKG = 'special-package';
assert.ok(goods.some((g) => g.id === PKG), 'the catalog carries the package');
const mkShip = (list) => { const s = makeShip(10000); s.cargo.add(list); return s; };
// 1) OFF the home world: the package's bay is NOT FOR SALE.
{
const ship = mkShip([A, PKG]);
const spy = [];
const { view } = makeView(ship, { worldName: 'Capital City', homeWorld: 'Home World', soldSpy: spy });
const pkgIdx = ship.cargo.items.indexOf(PKG);
assert.ok(pkgIdx >= 0, 'the package is aboard');
view.sellGrid.toggleBay(pkgIdx);
assert.equal(view.sellGrid.sellSel.size, 0, 'off-home: the package bay refuses the check (NOT FOR SALE)');
assert.deepEqual(view.sellGrid.getSellMap(), {}, '… nothing queued');
view.sellGrid.hoverCell(pkgIdx);
assert.equal(view.sellGrid.tipPrice.text, 'NOT FOR SALE', 'off-home: the tooltip says so');
// Ordinary bays still sell (only the parcel is refused).
view.sellGrid.toggleBay(0);
assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 1 }, 'off-home: ordinary goods are still saleable');
assert.equal(view.preview().sell.length, 1, '… the preview carries only the ordinary sale');
assert.equal(spy.length, 0, '… and the delivery seam never fires');
}
// 2) ON the home world: the package is saleable — at ZERO credits — and
// the sale stands the delivery (the seam fires with the world's name).
{
const ship = mkShip([A, PKG]);
const spy = [];
const { view } = makeView(ship, { worldName: 'Home World', homeWorld: 'Home World', soldSpy: spy });
const pkgIdx = ship.cargo.items.indexOf(PKG);
view.sellGrid.toggleBay(pkgIdx);
assert.ok(view.sellGrid.sellSel.has(pkgIdx), 'home: the package bay IS saleable (the delivery)');
view.sellGrid.hoverCell(pkgIdx);
assert.equal(view.sellGrid.tipPrice.text, 'sell for 0 cr', 'home: the tooltip prices the delivery at zero');
const pv = view.preview();
const pkgRow = pv.sell.find((s) => s.id === PKG);
assert.ok(pkgRow, 'the preview carries the parcel sale');
assert.equal(pkgRow.unit, 0, '… at zero credits (the delivery, not a reward)');
assert.equal(pkgRow.total, 0, '… zero total');
assert.equal(pv.valid, true, '… and the trade is legal');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before, '… the sale earns ZERO credits');
assert.ok(!ship.cargo.items.includes(PKG), '… the parcel leaves the hold (delivered)');
assert.equal(spy.length, 1, '… and the delivery seam fired exactly once');
assert.equal(spy[0], 'Home World', '… with the world the sale ran on');
// The delivery is not a trade: the parcel is NOT buyback-eligible.
assert.ok(!view.soldThisSession.some((r) => r.id === PKG), '… and the parcel is not on the Buyback list (the delivery, not a trade)');
}
// 3) The home check is name-based: the same package on a world the scene
// does NOT call home stays NOT FOR SALE.
{
const ship = mkShip([PKG]);
const spy = [];
const { view } = makeView(ship, { worldName: 'Astra-6', homeWorld: 'Home World', soldSpy: spy });
view.sellGrid.toggleBay(0);
assert.equal(view.sellGrid.sellSel.size, 0, 'non-home world: the parcel stays NOT FOR SALE');
assert.equal(view.preview().sell.length, 0, '… nothing queued, nothing priced');
assert.equal(spy.length, 0, '… no seam (the shop never offered the sale)');
}
}
console.log('QUEST CARGO: OK');
// ===========================================================================
console.log('\n== BUYBACK is locked until a sale + Clear resets the selection ==');
{

View File

@ -110,16 +110,48 @@ export function wants(goodId, 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).
* the hold, and no shop's FLOOR touches it — shopFor keeps it off every
* buy and buy-back list. The ONE sale it has is the delivery: the HOME
* WORLD's shop takes it for zero credits (the grid's home-only saleable
* gate — js/ui/shop/TradeGrid.js → _sellable, the flat price —
* questCargoSellPrice below, the good's `sellFor`). 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;
}
/**
* Is this good the run's QUEST CARGO — in the catalog but excluded from
* the shop economy (data/goods.json → tradeable: false — 'Special
* Delivery''s special-package)? It's the one good the economy doesn't
* price (no floor stocks it, no shop buys it back): its ONLY sale is the
* delivery — the home world takes it for its flat `sellFor` (zero credits
* — the parcel in Carol's hands IS the quest's step 3, and the shop's
* Confirm hands the sale to the ledger: ShopView.confirm →
* GameScene.onQuestPackageSold). Unknown ids are quest-cargo-false too
* (a good that isn't in the catalog is no one's cargo).
*/
export function isQuestCargo(goodId) {
const g = goodById(goodId);
return !!g && g.tradeable === false;
}
/**
* The QUEST CARGO's flat sale price (credits) — the delivery, not the
* per-shop price: sellPrice's demand/hops math is for commodities, and the
* parcel's sale is the run's story (the 'Special Delivery''s step 3 — the
* home world takes it for nothing). It's the good's `sellFor`
* (data/goods.json, zero for the package); 0 for anything that isn't
* quest cargo (those price via sellPrice, not here).
*/
export function questCargoSellPrice(goodId) {
const g = goodById(goodId);
if (!g || g.tradeable !== false) return 0;
return Math.max(0, Math.floor(Number(g.sellFor) || 0));
}
// ---------------------------------------------------------------------------
// Deterministic per-shop wobble (±economy.pricing.jitter), so no two shops are
// identical and no value looks "formulated." Same (good, class, system) ⇒ same
@ -353,7 +385,12 @@ export function tradePreview(o) {
const have = cargo.filter((c) => c === id).length;
const qty = Math.min(Math.max(0, Math.floor(Number(rawQty) || 0)), have);
if (qty <= 0) continue;
const unit = sellPrice(id, cls, systemId, galaxy);
// Quest cargo (tradeable:false — the 'Special Delivery' package) is the
// catalog's one non-commodity: where it is saleable (the home world
// only — the SELL grid's gate is the world check) it takes its flat
// delivery price (the good's `sellFor` — zero credits), not the fixed
// per-shop price (which would be a reward the quest doesn't pay).
const unit = isQuestCargo(id) ? questCargoSellPrice(id) : sellPrice(id, cls, systemId, galaxy);
const total = unit * qty;
sell.push({ id, name: goodById(id)?.name ?? id, qty, unit, total });
sellTotal += total;

View File

@ -33,8 +33,10 @@
* 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)
* // the 'Special Delivery' witnesses + the surface TRADE gate
* // (GameScene._recordLanding / onSurfaceDeparture /
* // onQuestPackageSold — delivered = the home-world SALE of
* // the package, the delivery's proof)
* playTimeMs }
*
* captureState(scene) — GameScene → record (the Save panel calls it)
@ -171,10 +173,11 @@ export function captureState(scene, now) {
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
// the surface TRADE gate: GameScene.isTradeUnlocked — the shop's
// Buy/Sell tab). 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;
// (old saves load — the trade gate re-arms on the next capital landing;
// the handoff rides the bar scene's DONE press, onVignetteComplete).
story: {
capitalLanded: scene.story?.capitalLanded === true,
@ -183,7 +186,7 @@ export function captureState(scene, now) {
typeof scene.story?.lastLandedWorld === 'string' && scene.story.lastLandedWorld
? scene.story.lastLandedWorld
: null,
delivered: scene.story?.delivered === true, // step 3's proof (monotonic)
delivered: scene.story?.delivered === true, // step 3's proof — the home-world sale (monotonic)
},
// DEPLETED asteroid fields — the clusters the run has mined to
// NOTHING (ids keyed "<systemId>:<clusterId>"), so a load keeps them
@ -286,7 +289,7 @@ export function prepareLoad(registry, record) {
// 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
// all-false ledger — old saves load; the trade gate re-arms on the next
// 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.

View File

@ -1163,19 +1163,19 @@ export class GameScene extends Phaser.Scene {
// 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:
// the surface TRADE gate: ShopView reads isTradeUnlocked before the
// shop's Buy/Sell tab 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)
capitalLanded: storyRaw?.capitalLanded === true, // the bar (the trade 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)
delivered: storyRaw?.delivered === true, // step 3's proof (the home-world SALE of the package — monotonic)
};
this.registry.set('story', this.story);
@ -3011,7 +3011,7 @@ export class GameScene extends Phaser.Scene {
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.
// bar: the trade gate lifts + the package lands in the hold.
this._recordLanding(name);
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? target.sheetFrame ?? 0),
@ -3081,15 +3081,22 @@ 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 bar — the trade gate lifts). Landing
// on the home world is NO LONGER the
// delivery's proof (the sale is — below);
// · 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
// delivery's route — the compass points it);
// · onQuestPackageSold — the shop's CONFIRM of the package's sale:
// saleable on the home world ONLY (TradeGrid's
// gate), at zero credits — that sale is
// step 3's proof (the parcel in Carol's
// hands), recorded monotonic.
// The witnesses are persistent, so a save/reload keeps the trade gate
// lifted, the package aboard (the cargo step's witness), and the
// delivery leg in flight.
// ---------------------------------------------------------------
@ -3103,12 +3110,13 @@ export class GameScene extends Phaser.Scene {
}
/**
* 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).
* The surface TRADE gate (the shop's Buy/Sell tab — the shop's sub-bar
* stays live everywhere, but trading is dark until the player reaches
* + LANDS on the neutral capital): true once the story ledger holds the
* capital landing. Read by the shop's sub-bar (js/ui/shop/ShopView.js —
* the Buy/Sell tab's disabled state; the shop button itself is always on).
*/
isShopUnlocked() {
isTradeUnlocked() {
return this.story?.capitalLanded === true;
}
@ -3124,28 +3132,25 @@ export class GameScene extends Phaser.Scene {
* '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 bar beat): the shop gate
* lifts (isShopUnlocked). The package handoff does NOT run here —
* · the NEUTRAL CAPITAL landing (the quest's bar beat): the trade gate
* lifts (isTradeUnlocked). 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).
* Landing on the HOME world no longer completes the quest on its own:
* the delivery's proof is the package's SALE there (onQuestPackageSold
* — the parcel in Carol's hands, not the ship at the gate).
* 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).
* announces a READY quest if the landing was the last piece (the bar,
* on the capital's arrival).
*/
_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)) {
this.story.capitalLanded = true; // the shop gate lifts (the bar's scene runs its own beat)
this.story.capitalLanded = true; // the trade gate lifts (the bar's scene runs its own beat)
}
this._questRefresh(); // a step may have just landed (the delivery, on a home return)
this._questRefresh(); // a step may have just landed (the bar, on the capital)
}
/**
@ -3238,6 +3243,33 @@ export class GameScene extends Phaser.Scene {
this.setDestination(homeId, 'home'); // the compass points home
}
/**
* The 'Special Delivery' step 3's seam (ShopView.confirm — the shop's
* CONFIRM of a sale that included the quest's package). The package is
* the run's story, not a commodity: the ONE floor that takes it is the
* HOME WORLD's (TradeGrid's saleable gate — every other world's grid
* refuses the bay, the tooltip says NOT FOR SALE), and it prices at
* zero (the good's `sellFor`, data/goods.json). THAT sale is the
* delivery — Carol gets her parcel — and it sets the witness the
* 'packageDelivered' check reads (this.story.delivered), recorded
* MONOTONIC: once it stands, a later landing elsewhere can't take it
* back (the quest can be CLAIMED and stay claimed; the cargo step's
* witness — the package aboard — is already on the ledger's completed
* set by then, so it stays done too).
* A sale off the home world is a no-op (the grid's gate makes it
* unreachable — belt and braces).
* @param {string} worldName the world the sale ran on (the surface's planet)
* @returns {boolean} true when the delivery stood NOW (not before)
*/
onQuestPackageSold(worldName) {
if (!this.story) return false;
if (!this.isHomeWorld(worldName)) return false; // not the home world — no delivery here
if (this.story.delivered === true) return false; // already stands (monotonic)
this.story.delivered = true; // step 3 made — the parcel is in Carol's hands
this._questRefresh(); // the checklist may now read READY (all three steps)
return true;
}
/**
* Mining phase changes (Mining → onPhase): the scene's share of the
* sequence — the ship's STATE, the ship lock, the console calls, the sfx.
@ -3649,16 +3681,20 @@ export class GameScene extends Phaser.Scene {
* 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)
* the 'Special Delivery' bar beat + the trade gate)
* 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
* upgrade's proof (recorded on the ledger's
* completed set the moment it reads DONE, so the
* step STAYS done even after the package is sold)
* packageDelivered— the delivery's proof holds (this.story.delivered —
* the home landing that followed the capital's launch)
* the package's HOME-WORLD SALE at zero credits;
* onQuestPackageSold sets it — landing alone no
* longer completes the step)
* vignettePlayed — the named VIGNETTE scene completed (registry
* 'vignettesPlayed' — VignetteHub marks it when the
* scene ENDS; data/vignettes.json's scene is the step
@ -3756,11 +3792,13 @@ export class GameScene extends Phaser.Scene {
}
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).
// is the package's HOME-WORLD SALE (zero credits — the delivery,
// not a commodity; onQuestPackageSold set it when the shop's
// Confirm ran it, and the grid only offers the sale there, so the
// sale IS the delivery — landing alone no longer completes it).
// Monotonic by design: once the delivery stands, it stands (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' };
}

View File

@ -287,15 +287,10 @@ 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;
}
// The SHOP button is always live — the shop's sub-bar opens on every
// world. The TRADE gate (GameScene.isTradeUnlocked — the story ledger's
// capitalLanded, 'Special Delivery' step 1) sits inside the shop on the
// Buy/Sell tab (ShopView reads it, so a save/load can't bypass it).
if (this.shopMode) {
this.leaveShop();
return;
@ -509,21 +504,16 @@ export class SurfaceScene extends Phaser.Scene {
* control on the surface — the same one-live-control language as the
* shop's lock (the one difference: over there the way-out IS the shop
* slot, over here it is the drawer's bar slot). The shop slot re-arms
* through its own gate (isShopUnlocked — true on the neutral capital,
* where the bar lives; the gate is the belt-and-braces pass). The bar
* slot itself is untouched in both directions (it's the toggle that
* owns the lock). Idempotent — safe on re-entry.
* with the rest (its TRADE gate — GameScene.isTradeUnlocked — lives on
* the shop's Buy/Sell tab, not on this slot). The bar slot itself is
* untouched in both directions (it's the toggle that owns the lock). Idempotent — safe on re-entry.
*/
setBarLocked(locked) {
const deck = this.actionBar;
if (deck && Array.isArray(deck.slots)) {
for (const s of deck.slots) {
if (!s.live) continue;
if (s.id === 'shop') {
deck.setSlotEnabled(s.id, this.gameScene?.isShopUnlocked?.() === true);
} else {
deck.setSlotEnabled(s.id, !locked);
}
deck.setSlotEnabled(s.id, !locked); // the shop slot re-arms with the rest — its trade gate lives on the shop's Buy/Sell tab, not here
}
}
const drawer = this.exploreSubBar;
@ -695,6 +685,10 @@ export class SurfaceScene extends Phaser.Scene {
galaxy: g.galaxy,
cls: this._shopClass(),
systemId,
// The delivery gate (the 'Special Delivery' package is saleable ONLY
// on the home world, at zero credits — the sale is the quest's step
// 3): the shop's home check runs against THIS world's name.
worldName: this.planetName,
gameScene: g,
onLeave: () => this.leaveShop(),
anchor: this.shopAnchor(),
@ -1083,13 +1077,11 @@ 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 SHOP slot is always live — the shop's sub-bar opens on every
// world. The TRADE gate (GameScene.isTradeUnlocked — the story
// ledger's capitalLanded, 'Special Delivery' step 1) sits on the
// shop's Buy/Sell tab (ShopView reads it from the GameScene's ledger,
// not this scene, so a save/load can't bypass it).
// The MENU button keeps the GameScene's save system. The run's state
// is in the paused GameScene beneath us — SavePanel captures from it

View File

@ -17,7 +17,11 @@
*
* The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): the
* player's cargo bays — tap a filled bay to check it off (a bay = 1 unit);
* locked bays are the hold's upgrade room.
* locked bays are the hold's upgrade room. ONE bay is not a commodity: the
* 'Special Delivery' package (tradeable:false) is NOT FOR SALE anywhere
* EXCEPT the home world — where it sells for zero credits, and THAT sale
* is the quest's step 3 (confirm() hands it to GameScene.onQuestPackageSold;
* it is not buyback-eligible).
* The BUY side (right) is a BuyList (js/ui/shop/BuyList.js): the shop's
* stock as WIDE ROWS — icon + name + one-line description (the good's
* flavor) + price + a −/qty/+ stepper — SCROLLABLE (drag / wheel) so a
@ -61,7 +65,16 @@ export class ShopView extends Phaser.GameObjects.Container {
* galaxy — the generated galaxy (Economy pricing).
* cls — the shop's economic class (Economy.economicClass).
* systemId — this system (the distance term for sell prices).
* gameScene — to refresh the credits/minerals HUD after a trade.
* worldName — this WORLD (the planet the player landed on): the
* QUEST CARGO's delivery gate — the 'Special Delivery'
* package is saleable ONLY on the home world (zero
* credits), so the home check runs against it (GameScene.
* isHomeWorld; a scene without the method = the package
* is not saleable — the old behavior).
* gameScene — to refresh the credits/minerals HUD after a trade, and
* to read the TRADE gate (isTradeUnlocked — the Buy/Sell
* tab stays grayed until the 'Special Delivery' capital
* landing; a headless fake without the method = open).
* onLeave — called by close() so the SurfaceScene can restore the
* surface loop (the deck's "Leave Shop" does this too).
* anchor — { x, y, w, left, right } the sub-bar's anchor: the
@ -79,6 +92,17 @@ export class ShopView extends Phaser.GameObjects.Container {
this.cls = o.cls;
this.systemId = o.systemId;
this.gameScene = o.gameScene;
this.worldName = typeof o.worldName === 'string' ? o.worldName.trim() : '';
// The QUEST CARGO's delivery (the 'Special Delivery' package —
// tradeable:false, data/goods.json): saleable ONLY on the home world,
// where it prices at its flat `sellFor` (zero credits — the delivery,
// not a commodity). That home-world sale is the quest's step 3:
// confirm() hands it to the ledger (GameScene.onQuestPackageSold).
// TradeGrid reads questSell per bay; the price comes off the good.
this.questSellable =
this.worldName !== '' &&
typeof this.gameScene?.isHomeWorld === 'function' &&
this.gameScene.isHomeWorld(this.worldName) === true;
this.onLeave = o.onLeave;
// The tab sub-bar anchor (the deck's span). Falls back to a
// bottom-center bar if the scene doesn't provide one (headless/dev).
@ -127,8 +151,10 @@ export class ShopView extends Phaser.GameObjects.Container {
enter() {
// Entry state: the shop video + tab bar only. The player must click
// "Buy/Sell" to reveal the trade panel (this keeps the screen open so the
// shop clip plays before the UI takes over). Buyback is grayed until a
// sale exists this visit.
// shop clip plays before the UI takes over). Buy/Sell is grayed while the
// trade gate is closed (GameScene.isTradeUnlocked — the 'Special
// Delivery' capital landing lifts it); Buyback is grayed until a sale
// exists this visit.
this.tab = null;
this._refreshTabs();
return this;
@ -138,7 +164,18 @@ export class ShopView extends Phaser.GameObjects.Container {
return this.soldThisSession.some((r) => r.qty > 0);
}
/** The TRADE gate (GameScene.isTradeUnlocked — the 'Special Delivery'
* capital landing lifts it; it's the shop's Buy/Sell tab that stays
* dark, the shop button itself is always live). A scene without the
* method (headless/dev) has no ledger, so the gate reads open there. */
isTradeUnlocked() {
const g = this.gameScene;
if (typeof g?.isTradeUnlocked !== 'function') return true;
return g.isTradeUnlocked() === true;
}
setTab(t) {
if (t === 'trade' && !this.isTradeUnlocked()) return this; // trade gate closed
if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked
// Leaving an OPEN trade panel with a pending trade? Save the selection
// (same as the toggle-close) so a tab switch never loses it; _showTrade
@ -381,6 +418,7 @@ export class ShopView extends Phaser.GameObjects.Container {
* _closePanel) — hiding the panel never loses the trade.
*/
_onTabTap(id) {
if (id === 'trade' && !this.isTradeUnlocked()) return; // the trade gate (the disabled tab swallows the click too)
if (id === 'trade') {
if (this.tab === 'trade') this._closePanel();
else this.setTab('trade');
@ -413,8 +451,11 @@ export class ShopView extends Phaser.GameObjects.Container {
}
_refreshTabs() {
// MenuButtons carry their own state (base/hover/disabled) — the only thing
// that changes here is Buyback's locked state (grayed until a sale exists).
// MenuButtons carry their own state (base/hover/disabled) — what changes
// here: Buy/Sell's TRADE gate (grayed until GameScene.isTradeUnlocked —
// the 'Special Delivery' capital landing) and Buyback's locked state
// (grayed until a sale exists this visit).
this._tabTrade?.setDisabled?.(!this.isTradeUnlocked());
this._tabBuyback?.setDisabled?.(!this.isBuybackEnabled());
}
@ -438,7 +479,15 @@ export class ShopView extends Phaser.GameObjects.Container {
const sellGrid = new TradeGrid(this.scene, {
side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
cls: this.cls, systemId: this.systemId,
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
// The per-unit price: ordinary goods at the shop's fixed sell price;
// quest cargo (the 'Special Delivery' package) at its flat delivery
// price — the good's `sellFor` (zero credits). The tooltip's "sell
// for N" matches what tradePreview pays for the same bay.
sellUnit: (id) =>
E.isQuestCargo(id) ? E.questCargoSellPrice(id) : E.sellPrice(id, this.cls, this.systemId, this.galaxy),
// The home world's floor: the package's bay is SALEABLE (the
// delivery). Every other world's grid refuses it (NOT FOR SALE).
questSell: this.questSellable,
});
// The BUY side: a scrollable LIST of stock rows (wide bars — icon +
// name + description + price + −/qty/+ stepper). Sized to the SELL
@ -615,6 +664,15 @@ export class ShopView extends Phaser.GameObjects.Container {
return false;
}
this._applyTrade(pv);
// The 'Special Delivery' step 3 — the parcel's HOME-WORLD SALE (zero
// credits) is the delivery itself. Only quest cargo (tradeable:false
// — the package) can ride a sell here (everywhere else the grid
// refuses it), and the ledger's owner double-checks the world before
// the delivery stands (GameScene.onQuestPackageSold — the witness
// the 'packageDelivered' check reads).
if (pv.sell.some((s) => E.isQuestCargo(s.id))) {
this.gameScene?.onQuestPackageSold?.(this.worldName);
}
this._afterTrade(pv);
return true;
}
@ -649,7 +707,15 @@ export class ShopView extends Phaser.GameObjects.Container {
} else {
// The trade — open tab OR the closed panel (tab === null, selection in
// the _pending* store). Record the sale for buyback, reset both.
for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name);
for (const s of pv.sell) {
// Quest cargo (the 'Special Delivery' package) is DELIVERED when
// it sells — the shop doesn't deal in it outside the delivery,
// so it never enters the buyback's undo list (the session is the
// shop's, and the shop's answer to the parcel is "zero, take
// your delivery", not "here, buy it back").
if (E.isQuestCargo(s.id)) continue;
this._addSold(s.id, s.qty, s.unit, s.name);
}
this.sellGrid?.clearSelection();
this.buyList?.clearSelection();
this._pendingSell = null;

View File

@ -37,15 +37,19 @@
*/
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
import { GOODS_TEXTURE_KEY, goodFrame, isTradeable } from '../../economy/Economy.js';
import { GOODS_TEXTURE_KEY, goodFrame, isQuestCargo, isTradeable } from '../../economy/Economy.js';
import { fontStack } from '../../utils/Theme.js';
export class TradeGrid extends Phaser.GameObjects.Container {
/**
* @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { maxSlots, capacity, cls, systemId, sellUnit(id) }
* @param {object} o — { maxSlots, capacity, cls, systemId, sellUnit(id), questSell }
* sellUnit is the per-unit price lookup (from the ShopView) so the grid
* can show "sell for N" without re-deriving.
* questSell — true on the HOME WORLD's shop: the quest cargo (the
* 'Special Delivery' package, tradeable:false) is SALEABLE here — its
* one sale, the delivery (zero credits — the good's `sellFor`).
* Everywhere else it stays NOT FOR SALE (toggleBay refuses it).
*/
constructor(scene, o) {
super(scene, 0, 0);
@ -56,6 +60,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
this.cls = o.cls;
this.systemId = o.systemId;
this.sellUnit = (id) => o.sellUnit?.(id) ?? 0;
this.questSell = o.questSell === true; // the home world's shop — the package's delivery
const g = config.get('shopui.grid', {});
this.cols = g.cols ?? 4;
@ -246,14 +251,28 @@ export class TradeGrid extends Phaser.GameObjects.Container {
return this.bays[i] ?? null;
}
/**
* Is a bay's good SALEABLE on THIS world's floor? Ordinary tradeable
* goods anywhere; the quest cargo (the 'Special Delivery' package —
* tradeable:false) ONLY when questSell is set (the home world's shop —
* the delivery is its one sale, zero credits). The SELL grid's world
* gate: the parcel reaches the home world and is handed over, never
* traded.
*/
_sellable(id) {
return isTradeable(id) || (this.questSell && isQuestCargo(id));
}
/** 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). */
* not saleable here (the special-package's bay is aboard, NOT FOR SALE
* off the home world — data/goods.json → tradeable:false; the
* tooltip says so on hover; the home world is the one exception,
* the delivery — see _sellable). */
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._sellable(id)) return this; // not saleable here (quest cargo off the home world)
if (this.sellSel.has(i)) this.sellSel.delete(i);
else this.sellSel.add(i);
this._renderCell(i);
@ -292,7 +311,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
if (!this._sellable(id)) continue; // not saleable here (quest cargo off the home world) — dropped
let need = Math.max(0, Math.floor(Number(want) || 0));
if (!need) continue;
for (let i = 0; i < this.capacity && need > 0; i++) {
@ -487,7 +506,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
if (!goodId) return this.unhover();
const unit = Math.round(this.sellUnit(goodId));
const saleable = isTradeable(goodId);
const saleable = this._sellable(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(saleable ? `sell for ${unit} cr` : 'NOT FOR SALE');