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:
parent
55db379a93
commit
942de11dde
|
|
@ -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": "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,
|
{ "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 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)." }
|
"_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)." }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
"title": "Special Delivery",
|
"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.",
|
"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",
|
"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": {
|
"unlocks": {
|
||||||
"_comment": "The grant condition: the listed quests' rewards CLAIMED — the run's chain (leaving_the_nest paid out → this one grants).",
|
"_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"]
|
"claimed": ["leaving_the_nest"]
|
||||||
|
|
@ -157,7 +157,7 @@
|
||||||
"checks": [
|
"checks": [
|
||||||
{ "type": "vignettePlayed", "id": "special_delivery_bar_meeting", "label": "Meet with Trestle and Carol at the neutral capital's bar" },
|
{ "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": "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": {
|
"reward": {
|
||||||
"credits": 200,
|
"credits": 200,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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: 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 is the "Special Package"', pkg?.name === 'Special Package');
|
||||||
check('delivery: the package rides on sheet frame 114', pkg?.icon === 114);
|
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)',
|
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);
|
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
|
// The cargo step's WITNESS — the package ABOARD (check.id = the package
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ window.game = game;
|
||||||
}
|
}
|
||||||
if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; }
|
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
|
// A known ledger + cargo (something to sell), and the run has ENTERED
|
||||||
// every system — the scout then has remote exits to offer.
|
// every system — the scout then has remote exits to offer.
|
||||||
const goods = config.get('goods.goods', []);
|
const goods = config.get('goods.goods', []);
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,10 @@ window.game = game;
|
||||||
if (!gs || !gs.ship) throw new Error('GameScene never came up');
|
if (!gs || !gs.ship) throw new Error('GameScene never came up');
|
||||||
step('boot', true);
|
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).
|
// 2 — a known ledger + cargo (something to sell).
|
||||||
const A = config.get('goods.goods', [])[0].id;
|
const A = config.get('goods.goods', [])[0].id;
|
||||||
gs.ship.credits = 10000;
|
gs.ship.credits = 10000;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ window.game = game;
|
||||||
}
|
}
|
||||||
if (!gs || !gs.ship) { window.__SCOUT_OVERLAP__ = { pass: false, error: 'no GameScene' }; return; }
|
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
|
// A known ledger + cargo (something to sell), and the run has ENTERED
|
||||||
// every system — the scout then has remote exits to offer.
|
// every system — the scout then has remote exits to offer.
|
||||||
const goods = config.get('goods.goods', []);
|
const goods = config.get('goods.goods', []);
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ window.game = game;
|
||||||
}
|
}
|
||||||
if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; }
|
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.
|
// A known ledger + cargo so the SELL grid has filled bays.
|
||||||
const goods = config.get('goods.goods', []);
|
const goods = config.get('goods.goods', []);
|
||||||
gs.ship.credits = 10000;
|
gs.ship.credits = 10000;
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ window.game = game;
|
||||||
if (!gs || !gs.ship) throw new Error('GameScene never came up');
|
if (!gs || !gs.ship) throw new Error('GameScene never came up');
|
||||||
step('boot', true, { frame: gs.planet.sheetFrame });
|
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).
|
// 2 — a known ledger + cargo (something to sell).
|
||||||
const goods = config.get('goods.goods', []);
|
const goods = config.get('goods.goods', []);
|
||||||
const A = goods[0].id;
|
const A = goods[0].id;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ window.game = game;
|
||||||
}
|
}
|
||||||
if (!gs || !gs.ship) { window.__SHOT_READY__ = { error: 'no GameScene' }; return; }
|
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
|
// 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.
|
// so the shot shows the selection look next to an idle bay.
|
||||||
const goods = config.get('goods.goods', []);
|
const goods = config.get('goods.goods', []);
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ const bounds = (o) => {
|
||||||
}
|
}
|
||||||
if (!gs || !gs.ship) throw new Error('GameScene never came up');
|
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).
|
// Land on the home world + force the surface stage (skip the clip).
|
||||||
const target = gs.commsTargetFor(gs.planet);
|
const target = gs.commsTargetFor(gs.planet);
|
||||||
gs.startLanding(target);
|
gs.startLanding(target);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@
|
||||||
* - BUYBACK — disabled until a sale; after a sale the goods you sold can be
|
* - 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,
|
* bought back at the SALE price (a neutral undo — sell→buyback nets zero,
|
||||||
* no infinite-credits loop); Clear resets the selection.
|
* 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
|
* A fake Scene provides add.text/image/graphics/tweens/etc.; the phaser stub
|
||||||
* (dev/phaser-stub.mjs) provides the Container the components extend.
|
* (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 B = goods[1].id;
|
||||||
const C = stock[0].id; // a good the shop actually sells
|
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 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, {
|
const view = new ShopView(scene, {
|
||||||
ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {},
|
ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {},
|
||||||
// topY = the surface HUD's planet-text bottom + padding (what
|
// topY = the surface HUD's planet-text bottom + padding (what
|
||||||
// SurfaceScene.shopTopY() computes). The panel top should sit here.
|
// SurfaceScene.shopTopY() computes). The panel top should sit here.
|
||||||
topY,
|
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)
|
if (reveal) view.setTab('trade'); // reveal the panel (what clicking "Buy/Sell" does)
|
||||||
return { scene, view, gs, topY };
|
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('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 ==');
|
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('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 ==');
|
console.log('\n== BUYBACK is locked until a sale + Clear resets the selection ==');
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -110,16 +110,48 @@ export function wants(goodId, cls) {
|
||||||
* Is this good TRADEABLE — part of the shop economy at all? The catalog's
|
* Is this good TRADEABLE — part of the shop economy at all? The catalog's
|
||||||
* quest cargo (data/goods.json → tradeable: false — 'Special Delivery''s
|
* quest cargo (data/goods.json → tradeable: false — 'Special Delivery''s
|
||||||
* special-package, the run's story parcel) is the exception: it rides in
|
* 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
|
* the hold, and no shop's FLOOR touches it — shopFor keeps it off every
|
||||||
* buy-back list, and the SELL grid refuses to queue a bay of it
|
* buy and buy-back list. The ONE sale it has is the delivery: the HOME
|
||||||
* (js/ui/shop/TradeGrid.js → toggleBay). Unknown ids are tradeable-false
|
* WORLD's shop takes it for zero credits (the grid's home-only saleable
|
||||||
* too (a good that isn't in the catalog can't be a good).
|
* 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) {
|
export function isTradeable(goodId) {
|
||||||
const g = goodById(goodId);
|
const g = goodById(goodId);
|
||||||
return !!g && g.tradeable !== false;
|
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
|
// Deterministic per-shop wobble (±economy.pricing.jitter), so no two shops are
|
||||||
// identical and no value looks "formulated." Same (good, class, system) ⇒ same
|
// 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 have = cargo.filter((c) => c === id).length;
|
||||||
const qty = Math.min(Math.max(0, Math.floor(Number(rawQty) || 0)), have);
|
const qty = Math.min(Math.max(0, Math.floor(Number(rawQty) || 0)), have);
|
||||||
if (qty <= 0) continue;
|
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;
|
const total = unit * qty;
|
||||||
sell.push({ id, name: goodById(id)?.name ?? id, qty, unit, total });
|
sell.push({ id, name: goodById(id)?.name ?? id, qty, unit, total });
|
||||||
sellTotal += total;
|
sellTotal += total;
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,10 @@
|
||||||
* totalMined: number, // lifetime asteroid mining
|
* totalMined: number, // lifetime asteroid mining
|
||||||
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
||||||
* story: { capitalLanded, launchedFromCapital, lastLandedWorld, delivered },
|
* story: { capitalLanded, launchedFromCapital, lastLandedWorld, delivered },
|
||||||
* // the 'Special Delivery' witnesses + the surface shop gate
|
* // the 'Special Delivery' witnesses + the surface TRADE gate
|
||||||
* // (GameScene._recordLanding / onSurfaceDeparture)
|
* // (GameScene._recordLanding / onSurfaceDeparture /
|
||||||
|
* // onQuestPackageSold — delivered = the home-world SALE of
|
||||||
|
* // the package, the delivery's proof)
|
||||||
* playTimeMs }
|
* playTimeMs }
|
||||||
*
|
*
|
||||||
* captureState(scene) — GameScene → record (the Save panel calls it)
|
* 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)),
|
totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 0)),
|
||||||
// THE RUN'S STORY LEDGER (data/quests.json → 'Special Delivery' — the
|
// THE RUN'S STORY LEDGER (data/quests.json → 'Special Delivery' — the
|
||||||
// neutral capital's bar, the package handoff, the delivery leg — and
|
// 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
|
// 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
|
// 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).
|
// the handoff rides the bar scene's DONE press, onVignetteComplete).
|
||||||
story: {
|
story: {
|
||||||
capitalLanded: scene.story?.capitalLanded === true,
|
capitalLanded: scene.story?.capitalLanded === true,
|
||||||
|
|
@ -183,7 +186,7 @@ export function captureState(scene, now) {
|
||||||
typeof scene.story?.lastLandedWorld === 'string' && scene.story.lastLandedWorld
|
typeof scene.story?.lastLandedWorld === 'string' && scene.story.lastLandedWorld
|
||||||
? scene.story.lastLandedWorld
|
? scene.story.lastLandedWorld
|
||||||
: null,
|
: 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
|
// DEPLETED asteroid fields — the clusters the run has mined to
|
||||||
// NOTHING (ids keyed "<systemId>:<clusterId>"), so a load keeps them
|
// 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).
|
// Lifetime asteroid mining (a save predating it has no field → 0).
|
||||||
registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 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
|
// 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).
|
// 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
|
// Normalized here so a hand-edited record can't leak a bad shape into
|
||||||
// the scene.
|
// the scene.
|
||||||
|
|
|
||||||
|
|
@ -1163,19 +1163,19 @@ export class GameScene extends Phaser.Scene {
|
||||||
|
|
||||||
// The run's STORY ledger (data/quests.json → 'Special Delivery' — the
|
// The run's STORY ledger (data/quests.json → 'Special Delivery' — the
|
||||||
// neutral capital's bar, the package handoff, the delivery leg — and
|
// neutral capital's bar, the package handoff, the delivery leg — and
|
||||||
// the surface SHOP gate: SurfaceScene reads isShopUnlocked before the
|
// the surface TRADE gate: ShopView reads isTradeUnlocked before the
|
||||||
// deck's BUY/SELL slot is allowed). Registry-backed like totalMined:
|
// shop's Buy/Sell tab is allowed). Registry-backed like totalMined:
|
||||||
// a load stages the saved record (SaveData.prepareLoad), a fresh run
|
// a load stages the saved record (SaveData.prepareLoad), a fresh run
|
||||||
// starts blank, and the New Game reset clears it (resetRunState).
|
// starts blank, and the New Game reset clears it (resetRunState).
|
||||||
const storyRaw = this.registry.get('story') ?? null;
|
const storyRaw = this.registry.get('story') ?? null;
|
||||||
this.story = {
|
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
|
launchedFromCapital: storyRaw?.launchedFromCapital === true, // the delivery leg's start
|
||||||
lastLandedWorld:
|
lastLandedWorld:
|
||||||
typeof storyRaw?.lastLandedWorld === 'string' && storyRaw.lastLandedWorld
|
typeof storyRaw?.lastLandedWorld === 'string' && storyRaw.lastLandedWorld
|
||||||
? storyRaw.lastLandedWorld
|
? storyRaw.lastLandedWorld
|
||||||
: null, // the last world landed on
|
: 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);
|
this.registry.set('story', this.story);
|
||||||
|
|
||||||
|
|
@ -3011,7 +3011,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.ensureLandingTether(name);
|
this.ensureLandingTether(name);
|
||||||
// 'Special Delivery' bookkeeping (a real landing, post-guards): the
|
// 'Special Delivery' bookkeeping (a real landing, post-guards): the
|
||||||
// story ledger's lastLandedWorld, and on the NEUTRAL CAPITAL — 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._recordLanding(name);
|
||||||
this.scene.launch('SurfaceScene', {
|
this.scene.launch('SurfaceScene', {
|
||||||
frame: Number(target.frame ?? target.sheetFrame ?? 0),
|
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):
|
// 'story' — SaveData carries it to record.story and back):
|
||||||
// · _recordLanding — startLanding (a real arrival): the last
|
// · _recordLanding — startLanding (a real arrival): the last
|
||||||
// world landed on; the capital landing
|
// 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
|
// · onVignetteComplete — the bar scene's DONE press: the hold is
|
||||||
// built out (the scene's effects) and the
|
// built out (the scene's effects) and the
|
||||||
// special package lands in the hold;
|
// special package lands in the hold;
|
||||||
// · onSurfaceDeparture — SurfaceScene.finishTakeoff (a real
|
// · onSurfaceDeparture — SurfaceScene.finishTakeoff (a real
|
||||||
// launch): the capital launch sets the
|
// launch): the capital launch sets the
|
||||||
// destination back to the HOME WORLD (the
|
// destination back to the HOME WORLD (the
|
||||||
// delivery's route — the compass points it).
|
// delivery's route — the compass points it);
|
||||||
// The witnesses are persistent, so a save/reload keeps the shop gate
|
// · 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
|
// lifted, the package aboard (the cargo step's witness), and the
|
||||||
// delivery leg in flight.
|
// 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
|
* The surface TRADE gate (the shop's Buy/Sell tab — the shop's sub-bar
|
||||||
* until the player reaches + LANDS on the neutral capital): true once
|
* stays live everywhere, but trading is dark until the player reaches
|
||||||
* the story ledger holds the capital landing. Read by the SurfaceScene
|
* + LANDS on the neutral capital): true once the story ledger holds the
|
||||||
* (buildDeck's slot enable + the deckAction guard).
|
* 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;
|
return this.story?.capitalLanded === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3124,28 +3132,25 @@ export class GameScene extends Phaser.Scene {
|
||||||
* 'Special Delivery' bookkeeping on a world landing (startLanding —
|
* 'Special Delivery' bookkeeping on a world landing (startLanding —
|
||||||
* after the guards, so this is a real arrival, not a refused one):
|
* after the guards, so this is a real arrival, not a refused one):
|
||||||
* · the ledger's lastLandedWorld (the run's arrival trail);
|
* · the ledger's lastLandedWorld (the run's arrival trail);
|
||||||
* · the delivery's proof (step 3) — the HOME landing that FOLLOWS the
|
* · the NEUTRAL CAPITAL landing (the quest's bar beat): the trade gate
|
||||||
* capital's launch (the leg's destination), recorded MONOTONIC: once
|
* lifts (isTradeUnlocked). The package handoff does NOT run here —
|
||||||
* 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 —
|
|
||||||
* it rides the bar scene's DONE press (onVignetteComplete: the
|
* it rides the bar scene's DONE press (onVignetteComplete: the
|
||||||
* hold's built out first, the package second — the ship starts
|
* hold's built out first, the package second — the ship starts
|
||||||
* with cargoHold 0 and can't take the parcel until then).
|
* 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
|
* Pure bookkeeping — the checklist itself is live, so _questRefresh
|
||||||
* announces a READY quest if the landing was the last piece (the
|
* announces a READY quest if the landing was the last piece (the bar,
|
||||||
* delivery, on a home return).
|
* on the capital's arrival).
|
||||||
*/
|
*/
|
||||||
_recordLanding(name) {
|
_recordLanding(name) {
|
||||||
if (!this.story) return;
|
if (!this.story) return;
|
||||||
this.story.lastLandedWorld = String(name ?? '');
|
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)) {
|
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
|
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
|
* Mining phase changes (Mining → onPhase): the scene's share of the
|
||||||
* sequence — the ship's STATE, the ship lock, the console calls, the sfx.
|
* 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)
|
* trestleTalked — Trestle's three brief comms played (the comms ledger)
|
||||||
* trestleMet — Trestle's arrival pair played (the comms ledger)
|
* trestleMet — Trestle's arrival pair played (the comms ledger)
|
||||||
* capitalLanded — the run landed on the neutral capital (this.story —
|
* 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
|
* cargoUpgraded — the quest's package is ABOARD (check.id — the
|
||||||
* 'special-package', data/goods.json): it only fits
|
* 'special-package', data/goods.json): it only fits
|
||||||
* once the hold is upgraded (the run's ship starts
|
* once the hold is upgraded (the run's ship starts
|
||||||
* with cargoHold 0, data/ship.json), and the bar
|
* with cargoHold 0, data/ship.json), and the bar
|
||||||
* scene's DONE press is what upgrades + loads it
|
* scene's DONE press is what upgrades + loads it
|
||||||
* (onVignetteComplete) — the parcel aboard is the
|
* (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 —
|
* 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
|
* vignettePlayed — the named VIGNETTE scene completed (registry
|
||||||
* 'vignettesPlayed' — VignetteHub marks it when the
|
* 'vignettesPlayed' — VignetteHub marks it when the
|
||||||
* scene ENDS; data/vignettes.json's scene is the step
|
* scene ENDS; data/vignettes.json's scene is the step
|
||||||
|
|
@ -3756,11 +3792,13 @@ export class GameScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
if (type === 'packageDelivered') {
|
if (type === 'packageDelivered') {
|
||||||
// Step 3 — the parcel in Carol's hands on the home world: the proof
|
// Step 3 — the parcel in Carol's hands on the home world: the proof
|
||||||
// (_recordLanding set it) — the HOME landing that followed the
|
// is the package's HOME-WORLD SALE (zero credits — the delivery,
|
||||||
// capital's launch (the leg its departure set). Monotonic by design:
|
// not a commodity; onQuestPackageSold set it when the shop's
|
||||||
// once the delivery stands, a later landing elsewhere can't take it
|
// Confirm ran it, and the grid only offers the sale there, so the
|
||||||
// back (the quest can be CLAIMED and stay claimed, the run's record
|
// sale IS the delivery — landing alone no longer completes it).
|
||||||
// is the record).
|
// 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;
|
const done = this.story?.delivered === true;
|
||||||
return { label, done, detail: done ? '1/1' : '0/1' };
|
return { label, done, detail: done ? '1/1' : '0/1' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -287,15 +287,10 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
*/
|
*/
|
||||||
toggleShop() {
|
toggleShop() {
|
||||||
if (this.phase !== 'surface') return; // no deck, no shop
|
if (this.phase !== 'surface') return; // no deck, no shop
|
||||||
// The SHOP gate (GameScene.isShopUnlocked — the shop lifts when the
|
// The SHOP button is always live — the shop's sub-bar opens on every
|
||||||
// run LANDS on the neutral capital, 'Special Delivery' step 1): a
|
// world. The TRADE gate (GameScene.isTradeUnlocked — the story ledger's
|
||||||
// press before then is refused (buildDeck disables the slot — this is
|
// capitalLanded, 'Special Delivery' step 1) sits inside the shop on the
|
||||||
// the belt-and-braces pass, so a stale keypress or a direct call can't
|
// Buy/Sell tab (ShopView reads it, so a save/load can't bypass it).
|
||||||
// open it either).
|
|
||||||
if (this.gameScene?.isShopUnlocked?.() !== true) {
|
|
||||||
this.consoleNote('SHOP OFFLINE — REACH THE NEUTRAL CAPITAL FIRST');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.shopMode) {
|
if (this.shopMode) {
|
||||||
this.leaveShop();
|
this.leaveShop();
|
||||||
return;
|
return;
|
||||||
|
|
@ -509,21 +504,16 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
* control on the surface — the same one-live-control language as the
|
* 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
|
* 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
|
* 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,
|
* with the rest (its TRADE gate — GameScene.isTradeUnlocked — lives on
|
||||||
* where the bar lives; the gate is the belt-and-braces pass). The bar
|
* the shop's Buy/Sell tab, not on this slot). The bar slot itself is
|
||||||
* slot itself is untouched in both directions (it's the toggle that
|
* untouched in both directions (it's the toggle that owns the lock). Idempotent — safe on re-entry.
|
||||||
* owns the lock). Idempotent — safe on re-entry.
|
|
||||||
*/
|
*/
|
||||||
setBarLocked(locked) {
|
setBarLocked(locked) {
|
||||||
const deck = this.actionBar;
|
const deck = this.actionBar;
|
||||||
if (deck && Array.isArray(deck.slots)) {
|
if (deck && Array.isArray(deck.slots)) {
|
||||||
for (const s of deck.slots) {
|
for (const s of deck.slots) {
|
||||||
if (!s.live) continue;
|
if (!s.live) continue;
|
||||||
if (s.id === 'shop') {
|
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
|
||||||
deck.setSlotEnabled(s.id, this.gameScene?.isShopUnlocked?.() === true);
|
|
||||||
} else {
|
|
||||||
deck.setSlotEnabled(s.id, !locked);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const drawer = this.exploreSubBar;
|
const drawer = this.exploreSubBar;
|
||||||
|
|
@ -695,6 +685,10 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
galaxy: g.galaxy,
|
galaxy: g.galaxy,
|
||||||
cls: this._shopClass(),
|
cls: this._shopClass(),
|
||||||
systemId,
|
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,
|
gameScene: g,
|
||||||
onLeave: () => this.leaveShop(),
|
onLeave: () => this.leaveShop(),
|
||||||
anchor: this.shopAnchor(),
|
anchor: this.shopAnchor(),
|
||||||
|
|
@ -1083,13 +1077,11 @@ export class SurfaceScene extends Phaser.Scene {
|
||||||
onAction: (id) => this.deckAction(id),
|
onAction: (id) => this.deckAction(id),
|
||||||
buttons: Array.isArray(deckButtons) ? deckButtons : undefined,
|
buttons: Array.isArray(deckButtons) ? deckButtons : undefined,
|
||||||
});
|
});
|
||||||
// The SHOP gate (the deck's BUY/SELL slot): dark until the player
|
// The SHOP slot is always live — the shop's sub-bar opens on every
|
||||||
// reaches the NEUTRAL CAPITAL and lands (GameScene.isShopUnlocked —
|
// world. The TRADE gate (GameScene.isTradeUnlocked — the story
|
||||||
// the story ledger's capitalLanded, 'Special Delivery' step 1). Until
|
// ledger's capitalLanded, 'Special Delivery' step 1) sits on the
|
||||||
// then the slot reads disabled — the press itself is refused in
|
// shop's Buy/Sell tab (ShopView reads it from the GameScene's ledger,
|
||||||
// toggleShop as the belt-and-braces pass (the gate lives in the
|
// not this scene, so a save/load can't bypass it).
|
||||||
// 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
|
// The MENU button keeps the GameScene's save system. The run's state
|
||||||
// is in the paused GameScene beneath us — SavePanel captures from it
|
// is in the paused GameScene beneath us — SavePanel captures from it
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,11 @@
|
||||||
*
|
*
|
||||||
* The SELL side (left) is a TradeGrid (js/ui/shop/TradeGrid.js): the
|
* 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);
|
* 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
|
* 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
|
* stock as WIDE ROWS — icon + name + one-line description (the good's
|
||||||
* flavor) + price + a −/qty/+ stepper — SCROLLABLE (drag / wheel) so a
|
* 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).
|
* galaxy — the generated galaxy (Economy pricing).
|
||||||
* cls — the shop's economic class (Economy.economicClass).
|
* cls — the shop's economic class (Economy.economicClass).
|
||||||
* systemId — this system (the distance term for sell prices).
|
* 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
|
* onLeave — called by close() so the SurfaceScene can restore the
|
||||||
* surface loop (the deck's "Leave Shop" does this too).
|
* surface loop (the deck's "Leave Shop" does this too).
|
||||||
* anchor — { x, y, w, left, right } the sub-bar's anchor: the
|
* 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.cls = o.cls;
|
||||||
this.systemId = o.systemId;
|
this.systemId = o.systemId;
|
||||||
this.gameScene = o.gameScene;
|
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;
|
this.onLeave = o.onLeave;
|
||||||
// The tab sub-bar anchor (the deck's span). Falls back to a
|
// 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).
|
// bottom-center bar if the scene doesn't provide one (headless/dev).
|
||||||
|
|
@ -127,8 +151,10 @@ export class ShopView extends Phaser.GameObjects.Container {
|
||||||
enter() {
|
enter() {
|
||||||
// Entry state: the shop video + tab bar only. The player must click
|
// 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
|
// "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
|
// shop clip plays before the UI takes over). Buy/Sell is grayed while the
|
||||||
// sale exists this visit.
|
// 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.tab = null;
|
||||||
this._refreshTabs();
|
this._refreshTabs();
|
||||||
return this;
|
return this;
|
||||||
|
|
@ -138,7 +164,18 @@ export class ShopView extends Phaser.GameObjects.Container {
|
||||||
return this.soldThisSession.some((r) => r.qty > 0);
|
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) {
|
setTab(t) {
|
||||||
|
if (t === 'trade' && !this.isTradeUnlocked()) return this; // trade gate closed
|
||||||
if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked
|
if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked
|
||||||
// Leaving an OPEN trade panel with a pending trade? Save the selection
|
// 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
|
// (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.
|
* _closePanel) — hiding the panel never loses the trade.
|
||||||
*/
|
*/
|
||||||
_onTabTap(id) {
|
_onTabTap(id) {
|
||||||
|
if (id === 'trade' && !this.isTradeUnlocked()) return; // the trade gate (the disabled tab swallows the click too)
|
||||||
if (id === 'trade') {
|
if (id === 'trade') {
|
||||||
if (this.tab === 'trade') this._closePanel();
|
if (this.tab === 'trade') this._closePanel();
|
||||||
else this.setTab('trade');
|
else this.setTab('trade');
|
||||||
|
|
@ -413,8 +451,11 @@ export class ShopView extends Phaser.GameObjects.Container {
|
||||||
}
|
}
|
||||||
|
|
||||||
_refreshTabs() {
|
_refreshTabs() {
|
||||||
// MenuButtons carry their own state (base/hover/disabled) — the only thing
|
// MenuButtons carry their own state (base/hover/disabled) — what changes
|
||||||
// that changes here is Buyback's locked state (grayed until a sale exists).
|
// 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());
|
this._tabBuyback?.setDisabled?.(!this.isBuybackEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -438,7 +479,15 @@ export class ShopView extends Phaser.GameObjects.Container {
|
||||||
const sellGrid = new TradeGrid(this.scene, {
|
const sellGrid = new TradeGrid(this.scene, {
|
||||||
side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
|
side: 'sell', maxSlots: this.maxSlots, capacity: this.capacity,
|
||||||
cls: this.cls, systemId: this.systemId,
|
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 +
|
// The BUY side: a scrollable LIST of stock rows (wide bars — icon +
|
||||||
// name + description + price + −/qty/+ stepper). Sized to the SELL
|
// name + description + price + −/qty/+ stepper). Sized to the SELL
|
||||||
|
|
@ -615,6 +664,15 @@ export class ShopView extends Phaser.GameObjects.Container {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this._applyTrade(pv);
|
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);
|
this._afterTrade(pv);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -649,7 +707,15 @@ export class ShopView extends Phaser.GameObjects.Container {
|
||||||
} else {
|
} else {
|
||||||
// The trade — open tab OR the closed panel (tab === null, selection in
|
// The trade — open tab OR the closed panel (tab === null, selection in
|
||||||
// the _pending* store). Record the sale for buyback, reset both.
|
// 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.sellGrid?.clearSelection();
|
||||||
this.buyList?.clearSelection();
|
this.buyList?.clearSelection();
|
||||||
this._pendingSell = null;
|
this._pendingSell = null;
|
||||||
|
|
|
||||||
|
|
@ -37,15 +37,19 @@
|
||||||
*/
|
*/
|
||||||
import Phaser from '../../vendor/phaser.js';
|
import Phaser from '../../vendor/phaser.js';
|
||||||
import { config } from '../../config/Config.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';
|
import { fontStack } from '../../utils/Theme.js';
|
||||||
|
|
||||||
export class TradeGrid extends Phaser.GameObjects.Container {
|
export class TradeGrid extends Phaser.GameObjects.Container {
|
||||||
/**
|
/**
|
||||||
* @param {object} scene — the SurfaceScene (or a headless fake).
|
* @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
|
* sellUnit is the per-unit price lookup (from the ShopView) so the grid
|
||||||
* can show "sell for N" without re-deriving.
|
* 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) {
|
constructor(scene, o) {
|
||||||
super(scene, 0, 0);
|
super(scene, 0, 0);
|
||||||
|
|
@ -56,6 +60,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
||||||
this.cls = o.cls;
|
this.cls = o.cls;
|
||||||
this.systemId = o.systemId;
|
this.systemId = o.systemId;
|
||||||
this.sellUnit = (id) => o.sellUnit?.(id) ?? 0;
|
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', {});
|
const g = config.get('shopui.grid', {});
|
||||||
this.cols = g.cols ?? 4;
|
this.cols = g.cols ?? 4;
|
||||||
|
|
@ -246,14 +251,28 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
||||||
return this.bays[i] ?? null;
|
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/
|
/** 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 —
|
* not saleable here (the special-package's bay is aboard, NOT FOR SALE
|
||||||
* data/goods.json → tradeable:false; the tooltip says so on hover). */
|
* 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) {
|
toggleBay(i) {
|
||||||
if (i < 0 || i >= this.capacity) return this; // locked bay
|
if (i < 0 || i >= this.capacity) return this; // locked bay
|
||||||
const id = this._bayIdAt(i);
|
const id = this._bayIdAt(i);
|
||||||
if (!id) return this; // empty bay
|
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);
|
if (this.sellSel.has(i)) this.sellSel.delete(i);
|
||||||
else this.sellSel.add(i);
|
else this.sellSel.add(i);
|
||||||
this._renderCell(i);
|
this._renderCell(i);
|
||||||
|
|
@ -292,7 +311,7 @@ export class TradeGrid extends Phaser.GameObjects.Container {
|
||||||
setSellMap(map = {}) {
|
setSellMap(map = {}) {
|
||||||
this.sellSel = new Set();
|
this.sellSel = new Set();
|
||||||
for (const [id, want] of Object.entries(map ?? {})) {
|
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));
|
let need = Math.max(0, Math.floor(Number(want) || 0));
|
||||||
if (!need) continue;
|
if (!need) continue;
|
||||||
for (let i = 0; i < this.capacity && need > 0; i++) {
|
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();
|
if (!goodId) return this.unhover();
|
||||||
|
|
||||||
const unit = Math.round(this.sellUnit(goodId));
|
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.tipName && typeof this.tipName.setText === 'function') this.tipName.setText(this._goodName(goodId) || goodId);
|
||||||
if (this.tipPrice && typeof this.tipPrice.setText === 'function')
|
if (this.tipPrice && typeof this.tipPrice.setText === 'function')
|
||||||
this.tipPrice.setText(saleable ? `sell for ${unit} cr` : 'NOT FOR SALE');
|
this.tipPrice.setText(saleable ? `sell for ${unit} cr` : 'NOT FOR SALE');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue