Add shop buy/sell UI, cargo hold, and cargo bay upgrades

- Introduce CargoHold (pure class) as the ship's trade-goods carrier: fixed bays, one good each, capacity = unlocked bays, save/restore round-trip. Ship.cargo + setCargoHold/cargoRoom wired into stats.
- Add three credit-costed cargo builds (cargo-bay-8/12/16) that expand the hold 4→8→12→16 (MAX wins). BuildWindow affordability now covers both minerals and credits; GameScene.beginBuild debits credit lines and _applyCargoUpgrade expands the hold on completion.
- Add tradePreview/resultingCargo to Economy.js as the shop's pure calculator: sell/buy pricing, net credits, the two caps (can't go negative, end hold fits capacity), and atomic sell-to-free-room-for-buy.
- New ShopView + TradeGrid compose the buy/sell screen over the shop clip: SELL grid of cargo bays (tap to check), BUY grid with quantity steppers, live NET preview, Confirm/Clear, and a session-local Buyback tab (neutral undo at the sale price, locked until a sale). Wired into SurfaceScene's SHOP button.
- data/shopui.json drives the shop layout/animation knobs; economy.json gains cargo.maxSlots (16-slot grid); ship.json base cargoHold drops to 4 unlocked bays.
- Headless tests: dev/cargo.test.mjs (hold rules + trade preview caps), dev/shop-ui.test.mjs (construct, sell, buy, caps, buyback, clear), plus updated builds/ship-behavior suites; phaser-stub gains Geom.Rectangle for the hitArea path.
This commit is contained in:
Brian Fertig 2026-09-11 20:11:34 -06:00
parent 5f54cc327e
commit 1b4cba9f4f
22 changed files with 2273 additions and 24 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

View File

@ -177,6 +177,69 @@
"theme": {
"color": "#ff8a3d"
}
},
"cargo-bay-8": {
"label": "Cargo Bay \u2014 8",
"description": "Expands the ship's cargo hold from 4 to 8 bays \u2014 roughly 2\u00d7 the goods per run, and the trade income to match. A ship upgrade: built from any planet's console, paid in credits, counted wherever the ship lands (never re-bought).",
"icon": "storage",
"category": "cargo",
"targets": ["ship"],
"cost": {
"credits": 300
},
"duration": 20,
"requires": [],
"repeatable": false,
"effects": {
"cargo": {
"bays": 8
}
},
"theme": {
"color": "#7ce8a4"
}
},
"cargo-bay-12": {
"label": "Cargo Bay \u2014 12",
"description": "Expands the ship's cargo hold to 12 bays \u2014 three quarters of a full hold in a single run. Supersedes the 8-bay bay (the biggest hold always wins). A ship upgrade, paid in credits.",
"icon": "storage",
"category": "cargo",
"targets": ["ship"],
"cost": {
"credits": 1200
},
"duration": 30,
"requires": [],
"repeatable": false,
"effects": {
"cargo": {
"bays": 12
}
},
"theme": {
"color": "#7ce8a4"
}
},
"cargo-bay-16": {
"label": "Cargo Bay \u2014 16",
"description": "The full 16-bay cargo hold \u2014 a freighter's worth of goods in one run, the biggest the ship can carry. Supersedes every smaller bay. A ship upgrade, paid in credits.",
"icon": "storage",
"category": "cargo",
"targets": ["ship"],
"cost": {
"credits": 3000
},
"duration": 40,
"requires": [],
"repeatable": false,
"effects": {
"cargo": {
"bays": 16
}
},
"theme": {
"color": "#7ce8a4"
}
}
},

View File

@ -21,8 +21,9 @@
"jitter": 0.10
},
"cargo": {
"_comment": "Cargo capacity is the ship's hold (ship.stats.cargoHold = 100). Decision #2: each good occupies 1 hold unit (unitPerGood=1) — the simplest-to-manage option. You CARRY goods between shops; that's the whole game loop.",
"_comment": "The hold is a set of fixed BAYS — one good per bay (unitPerGood=1). `capacitySource` is the UNLOCKED-bay count (ship.stats.cargoHold, starts at 4; cargo upgrades raise it). `maxSlots` is the fixed grid the shop UI always shows (16 slots) — locked bays are just capacity the ship hasn't bought yet. You CARRY goods between shops; that's the whole game loop.",
"capacitySource": "ship.stats.cargoHold",
"maxSlots": 16,
"unitPerGood": 1
},
"credits": {

View File

@ -32,6 +32,7 @@
"quests.json",
"comms.json",
"goods.json",
"economy.json"
"economy.json",
"shopui.json"
]
}

View File

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

80
data/shopui.json Normal file
View File

@ -0,0 +1,80 @@
{
"_comment": "SHOP UI — the buy/sell (and buyback) screen over the shop clip (data/shopui.json). LAYOUT: the two 4×4 grids sit side by side (SELL = the player's cargo bays on the LEFT, BUY = the shop's stock on the RIGHT), a center column carries the live NET preview + Confirm/Clear, and a tab bar up top toggles Buy/Sell ↔ Buyback (Buyback is disabled until the player has SOLD something this session). The panels are semi-transparent so the shop clip shows through; items + text are full alpha. GEOMETRY (slot/gap/gridCols/gridRows/centerW) is primitives the code (js/ui/shop/ShopView.js) lays out against the live scene size — not pixel positions. The ECONOMY itself (prices, stock) is Economy.js + data/goods.json + data/economy.json; this file is only presentation.",
"enabled": true,
"tabs": {
"_comment": "The sub-menu (two buttons). Buy/Sell opens the trade panel; Buyback opens the buyback panel (goods sold this session, session-local only). `disabledAlpha` is the Buyback dim until the player has sold something.",
"buySell": "Buy/Sell",
"buyback": "Buyback",
"w": 184, "h": 46, "gap": 14,
"y": 44,
"activeAccent": "#00e5ff",
"disabledAlpha": 0.34
},
"panel": {
"_comment": "The semi-transparent panel behind each side's grid — the shop clip shows through it (bgAlpha < 1) but the goods + labels sit at full alpha on top.",
"bgColor": "#0a1120",
"bgAlpha": 0.70,
"border": "#22405f",
"pad": 16,
"title": { "fontSize": 21, "color": "#eaf6ff", "h": 34 }
},
"grid": {
"_comment": "The 4×4 grid shared by both sides (data/economy.json → cargo.maxSlots = 16 slots). SELL: the first `capacity` cells are live cargo bays (one good each), the rest are locked (the hold's upgrade room). BUY: the shop's stock (12–16) fills the first N, the rest are empty.",
"cols": 4, "rows": 4,
"slot": 96, "gap": 10,
"centerW": 208
},
"slot": {
"_comment": "A single bay/stock cell. `bg` is the cell plate, `selected` the SELL-check highlight (a bay marked to sell), `stepper` the BUY quantity control. `lockAlpha` dims the locked/empty cells. Colors follow the theme (cyan selection, gold buy, green sell, magenta = bad).",
"bgColor": "#0e1728",
"border": "#1d2e4c",
"selected": "#00e5ff",
"lockAlpha": 0.30,
"iconScale": 0.58,
"name": { "fontSize": 11, "color": "#eaf6ff" },
"price": { "fontSize": 12, "sell": "#7ce8a4", "buy": "#ffc94d" },
"emptyLabel": "—",
"lockLabel": "locked"
},
"stepper": {
"_comment": "The BUY-side quantity stepper (− n +). Clicking −/+ adjusts that good's buy quantity; the live preview recalcs. The sell side is bay-based (each bay = 1 unit, tap to check), so it has no stepper.",
"fontSize": 19,
"color": "#eaf6ff",
"btn": "#7d92c4",
"activeColor": "#00e5ff"
},
"preview": {
"_comment": "The live NET-credits readout in the center column. Positive (you're up) = green, negative (you'd spend past your credits / it's a net spend) = the value shown; the Confirm button is disabled while the trade is invalid (can't go negative, hold overflows).",
"label": "NET",
"labelColor": "#7d92c4",
"labelFontSize": 13,
"valueFontSize": 34,
"posColor": "#7ce8a4",
"negColor": "#ff2d6f"
},
"buttons": {
"_comment": "Confirm (execute the trade) + Clear (reset this side's selection) — they appear once the player starts checking/stepping items. `disabled` dims Confirm while the trade is invalid. `back` is a reserved return-to-menu label (the deck's Leave Shop is the real exit). Separate *Label (text) and *Accent (color) keys so they don't collide.",
"w": 158, "h": 54,
"confirmLabel": "Confirm",
"clearLabel": "Clear",
"backLabel": "‹ Menu",
"accent": "#00e5ff",
"confirmAccent": "#7ce8a4",
"clearAccent": "#7d92c4",
"disabled": "#7d92c4"
},
"animation": {
"_comment": "Reveal: the SELL grid slides in from the left, the BUY grid from the right, the center column fades up (data/shopui.json → animation). A short stagger per row keeps it feeling assembled rather than popped.",
"slideMs": 340,
"staggerMs": 26,
"ease": "Cubic.Out"
}
}

View File

@ -78,7 +78,14 @@ const cargo = loadBuilds('cargo');
const miningCat = loadBuilds('mining');
check('model: loadBuilds("planet") holds the three tether builds', !!planet.builds['tether-l1'] && !!planet.builds['tether-l2'] && !!planet.builds['tether-l3']);
check('model: loadBuilds("planet") keeps registry entry (label/accent)', planet.label === 'Planet' && typeof planet.accent === 'string');
check('model: loadBuilds("cargo") is empty today (no cargo modules yet)', Object.keys(cargo.builds).length === 0);
check('model: loadBuilds("cargo") holds the three ship-hold bays (8/12/16)', !!cargo.builds['cargo-bay-8'] && !!cargo.builds['cargo-bay-12'] && !!cargo.builds['cargo-bay-16']);
// The cargo bays are a CREDIT sink (the trade income buys the hold) — so the
// affordability wallet must cover credits, not just minerals.
check('model: cargo bays are credit-costed (the first credit sink)', cargo.builds['cargo-bay-8'].cost.credits === 300 && cargo.builds['cargo-bay-12'].cost.credits === 1200 && cargo.builds['cargo-bay-16'].cost.credits === 3000);
check('model: cargo bays cost CREDITS (not minerals)', costLines(cargo.builds['cargo-bay-8']).every((l) => l.res === 'credits'));
check('model: a credits wallet affords the 8-bay (300 cr) build', canAfford(cargo.builds['cargo-bay-8'], { minerals: 0, credits: 300 }));
check('model: a credits wallet BELOW the cost does not afford it', !canAfford(cargo.builds['cargo-bay-8'], { minerals: 100000, credits: 299 }));
check('model: a minerals-only wallet (old economy) does NOT afford a credit build', !canAfford(cargo.builds['cargo-bay-8'], 100000));
check('model: loadBuilds("mining") holds the three ship-upgrade builds', !!miningCat.builds['mining-arm-improved'] && !!miningCat.builds['mining-arm-advanced'] && !!miningCat.builds['mining-storage-improved']);
check('model: loadBuilds("mining") keeps registry entry (label/accent)', miningCat.label === 'Mining' && typeof miningCat.accent === 'string');
check('model: an unknown category falls back to the default', loadBuilds('nope').id === builds.defaultCategory);

237
dev/cargo.test.mjs Normal file
View File

@ -0,0 +1,237 @@
/**
* Cargo + trade-preview test (dev tool, run with Node — no browser needed):
*
* node dev/cargo.test.mjs
*
* The shop's ECONOMIC core, headless:
*
* - CARGOHOLD (js/economy/CargoHold.js) — the ship's trade-goods hold as a
* pure object: fixed bays (one good each), capacity = unlocked bays,
* add/remove/count/clear, a capacity drop sheds the oldest goods, save/
* load round-trips, and the invariant items.length ≤ capacity.
* - TRADE PREVIEW (js/economy/Economy.js → tradePreview / resultingCargo)
* — the shop UI's calculator: sell side priced at sellPrice, buy side at
* buyPrice, net = sell − buy, the two caps (credits can't go negative,
* the END hold must fit the unlocked bays), "sell 2 to make room to buy
* 3" landing in one confirm, and sell qty clamped to what's aboard.
*
* No Phaser, no scene — plain Node against the real config + a real galaxy.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { CargoHold } = await import(pathToFileURL(join(__dirname, '../js/economy/CargoHold.js')).href);
const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const goods = config.get('goods.goods', []);
assert.ok(goods.length > 0, 'the goods catalog is loaded');
console.log('== CARGOHOLD — the ship\u2019s trade-goods hold ==');
// --- construction + capacity --------------------------------------------
{
const h = new CargoHold(4);
assert.equal(h.capacity, 4, 'capacity = unlocked bays');
assert.equal(h.used, 0, 'starts empty');
assert.equal(h.room, 4, '… with room for all four');
assert.equal(h.isFull(), false, '… and not full');
assert.equal(h.add(['a', 'b']), 2, 'add fills bays, returns how many landed');
assert.deepEqual(h.items, ['a', 'b'], '… in fill order');
assert.equal(h.add(['c', 'd', 'e']), 2, 'add stops when the hold is full (only 2 fit)');
assert.equal(h.used, 4, '… the hold is now full');
assert.equal(h.isFull(), true, '… and reports full');
}
// --- duplicates, count, has, remove -------------------------------------
{
const h = new CargoHold(6);
assert.equal(h.add(['gem', 'gem', 'gem']), 3, 'three of the same good = three bays');
assert.equal(h.count('gem'), 3, 'count() sees the duplicates');
assert.equal(h.has('gem'), true, 'has() is true');
assert.equal(h.remove('gem', 2), 2, 'remove() takes up to N copies');
assert.equal(h.count('gem'), 1, '… one left');
assert.equal(h.remove('gem', 5), 1, '… asking for more than you have removes what\u2019s there');
assert.equal(h.count('gem'), 0, '… now empty of it');
assert.equal(h.has('gem'), false, 'has() is false again');
assert.equal(h.remove('nope', 1), 0, 'removing a good that\u2019s not aboard is a no-op');
}
// --- a capacity DROP sheds the oldest goods -----------------------------
{
const h = new CargoHold(4);
h.add(['a', 'b', 'c', 'd']);
assert.equal(h.used, 4, 'full before the drop');
h.capacity = 2; // the ship shrinks its hold
assert.equal(h.capacity, 2, 'capacity is now 2');
assert.equal(h.used, 2, '… and it can only keep two bays');
assert.deepEqual(h.items, ['a', 'b'], '… shedding the OLDEST (tail) goods first');
h.capacity = 6; // grow it back — frees bays
assert.equal(h.room, 4, 'growing frees the vacated bays');
}
// --- set() truncates to capacity; save/load + clone ---------------------
{
const h = new CargoHold(3);
h.set(['a', 'b', 'c', 'd', 'e']);
assert.deepEqual(h.items, ['a', 'b', 'c'], 'set() keeps only what fits the capacity');
h.set(['x', 42, null, 'y']);
assert.deepEqual(h.items, ['x', 'y'], 'set() filters out non-ids');
const round = CargoHold.fromJSON(h.toJSON());
assert.equal(round.capacity, 3, 'round-trip keeps the capacity');
assert.deepEqual(round.items, ['x', 'y'], '… and the contents');
const base = new CargoHold(2);
base.add(['p', 'q']);
const copy = base.clone();
copy.remove('p', 1);
assert.deepEqual(base.items, ['p', 'q'], 'clone is independent — mutating it leaves the original');
assert.deepEqual(copy.items, ['q']);
}
console.log('CARGOHOLD: capacity, dups, drop-sheds, save/load, clone: OK');
console.log('\n== TRADE PREVIEW — the shop UI\u2019s calculator ==');
// A real galaxy (for the fixed per-shop prices).
const galaxy = Galaxy.create('orbit-cargo-math');
const sysId = [...galaxy.byId.keys()][0];
const cls = [...galaxy.economy.classBySystem.get(sysId)].find((c) => c !== 'capital') ?? 'rocky';
const good = goods.find((g) => g.id) ; // any catalog good
const goodId = good.id;
const maxSlots = config.get('economy.cargo.maxSlots', 16);
const CAP = 4; // the player\u2019s starting unlocked bays
// --- sell side: priced at sellPrice, net positive ------------------------
{
const cargo = [goodId, goodId]; // two aboard
const pv = E.tradePreview({ cls, systemId: sysId, galaxy, cargo, sellMap: { [goodId]: 2 }, buyMap: {}, credits: 0, capacity: CAP });
const unit = E.sellPrice(goodId, cls, sysId, galaxy);
assert.ok(unit > 0, 'the sell price is positive');
assert.equal(pv.sell.length, 1, 'one sell line');
assert.equal(pv.sell[0].qty, 2, '… for two units');
assert.equal(pv.sell[0].unit, unit, '… at the shop\u2019s fixed sell price');
assert.equal(pv.sellTotal, unit * 2, 'sellTotal = qty × sellPrice');
assert.equal(pv.buyTotal, 0, '… nothing bought');
assert.equal(pv.net, unit * 2, 'net = the sale proceeds');
assert.equal(pv.creditsAfter, unit * 2, '… credits go up by that');
assert.equal(pv.valid, true, 'selling needs no credits and frees bays → valid');
assert.equal(pv.cargoAfter, 0, '… and the hold is empty after');
}
// --- buy side: priced at buyPrice, net negative --------------------------
{
const pv = E.tradePreview({ cls, systemId: sysId, galaxy, cargo: [], sellMap: {}, buyMap: { [goodId]: 3 }, credits: 100000, capacity: CAP });
const unit = E.buyPrice(goodId, cls, sysId, galaxy);
assert.ok(unit > 0, 'the buy price is positive');
assert.equal(pv.buy[0].unit, unit, '… at the shop\u2019s fixed buy price');
assert.equal(pv.buyTotal, unit * 3, 'buyTotal = qty × buyPrice');
assert.equal(pv.net, -unit * 3, 'net = the spend');
assert.equal(pv.creditsAfter, 100000 - unit * 3, '… credits go down by that');
assert.equal(pv.cargoAfter, 3, '… three bays fill');
assert.deepEqual(pv.cargo, [goodId, goodId, goodId], 'resulting hold = the bought goods');
}
// --- the two caps: credits and cargo ------------------------------------
{
const unit = E.buyPrice(goodId, cls, sysId, galaxy);
const cost = unit * 2;
// Can't spend into the red.
const broke = E.tradePreview({ cls, systemId: sysId, galaxy, cargo: [], sellMap: {}, buyMap: { [goodId]: 2 }, credits: cost - 1, capacity: CAP });
assert.equal(broke.valid, false, 'buying a credit over what you have is INVALID');
assert.ok(broke.errors.includes('insufficient-credits'), '… flagged as insufficient-credits');
assert.equal(broke.creditsAfter, -1, '… creditsAfter would be −1');
// Exactly affording it is valid (the cap is < 0, not ≤).
const exact = E.tradePreview({ cls, systemId: sysId, galaxy, cargo: [], sellMap: {}, buyMap: { [goodId]: 2 }, credits: cost, capacity: CAP });
assert.equal(exact.valid, true, 'spending down to exactly 0 is allowed');
assert.equal(exact.creditsAfter, 0, '… and lands on zero');
// Cargo: buying beyond the unlocked bays is invalid.
const full = E.tradePreview({ cls, systemId: sysId, galaxy, cargo: [], sellMap: {}, buyMap: { [goodId]: CAP + 1 }, credits: 100000, capacity: CAP });
assert.equal(full.valid, false, 'buying more than the hold can carry is INVALID');
assert.ok(full.errors.includes('cargo-full'), '… flagged as cargo-full');
}
// --- the LOOP in one confirm: sell 2 to make room to buy 3 --------------
{
// A full 4-bay hold of goodId. Sell 2 (frees 2 bays + credits), buy 3
// (fills 3). End: 4 − 2 + 3 = 5 … that\u2019s over capacity — so use a mix that
// stays in the hold: sell 3, buy 3 → end 4 (fits), and the sale funds it.
const unit = E.buyPrice(goodId, cls, sysId, galaxy);
const sellUnit = E.sellPrice(goodId, cls, sysId, galaxy);
const cargo = [goodId, goodId, goodId, goodId]; // full (4)
const pv = E.tradePreview({
cls, systemId: sysId, galaxy, cargo,
sellMap: { [goodId]: 3 }, buyMap: { [goodId]: 3 },
credits: 0, capacity: CAP,
});
assert.equal(pv.cargoAfter, 4, 'end hold = 4 − 3 + 3 = 4 (fits the capacity)');
assert.equal(pv.creditsAfter, sellUnit * 3 - unit * 3, 'net = sale proceeds − the spend');
assert.equal(pv.valid, pv.creditsAfter >= 0 && pv.cargoAfter <= CAP, '… valid iff both caps hold');
// A genuinely room-making trade: sell 2 (frees room) then buy 3 from FULL.
const pv2 = E.tradePreview({
cls, systemId: sysId, galaxy, cargo,
sellMap: { [goodId]: 2 }, buyMap: { [goodId]: 3 },
credits: 100000, capacity: CAP,
});
assert.equal(pv2.cargoAfter, 5, '4 − 2 + 3 = 5 bays wanted…');
assert.equal(pv2.valid, false, '… but that\u2019s OVER the 4-bay hold → cargo-full');
// …and the SAME trade with one fewer buy now fits (room was the blocker).
const pv3 = E.tradePreview({
cls, systemId: sysId, galaxy, cargo,
sellMap: { [goodId]: 2 }, buyMap: { [goodId]: 2 },
credits: 100000, capacity: CAP,
});
assert.equal(pv3.cargoAfter, 4, '4 − 2 + 2 = 4 → fits');
assert.equal(pv3.valid, true, '… so it\u2019s valid');
}
// --- sell qty clamps to what\u2019s actually aboard ------------------------
{
const cargo = [goodId]; // only ONE aboard
const pv = E.tradePreview({ cls, systemId: sysId, galaxy, cargo, sellMap: { [goodId]: 9 }, buyMap: {}, credits: 0, capacity: CAP });
assert.equal(pv.sell[0].qty, 1, 'asking to sell 9 of a good you have one of sells ONE');
assert.equal(pv.sellTotal, E.sellPrice(goodId, cls, sysId, galaxy), '… priced for the one unit');
assert.equal(pv.cargoAfter, 0, '… hold ends empty');
}
// --- a mixed trade (different goods, sell + buy at once) ----------------
{
const a = goods[0].id;
const b = goods[1].id;
const cargo = [a, a, b]; // two a, one b
const pv = E.tradePreview({
cls, systemId: sysId, galaxy, cargo,
sellMap: { [a]: 2 }, buyMap: { [b]: 2 },
credits: 100000, capacity: CAP,
});
assert.equal(pv.sellTotal, 2 * E.sellPrice(a, cls, sysId, galaxy), 'a\u2019s sale at its sell price');
assert.equal(pv.buyTotal, 2 * E.buyPrice(b, cls, sysId, galaxy), 'b\u2019s purchase at its buy price');
assert.equal(pv.net, pv.sellTotal - pv.buyTotal, 'net = sale − purchase');
assert.equal(pv.cargoAfter, 3 - 2 + 2, 'end hold = 3 − 2 + 2 = 3');
assert.deepEqual([...pv.cargo].sort(), [b, b, b].sort(), 'resulting hold = the one leftover b + two bought b\u2019s');
}
console.log('TRADE PREVIEW: sell/buy pricing, both caps, loop, clamps, mix: OK');
console.log('\nAll cargo checks passed \u2714');

View File

@ -72,10 +72,22 @@ class BaseFilterShader {
setupUniforms(_controller, _ctx) {}
}
export { Controller, BaseFilterShader };
// Phaser.Geom.Rectangle (headless): the interactive hit-area shape the shop
// UI builds its hitAreaCallback around (js/ui/shop/TradeGrid.js + ShopView.js).
class Rectangle {
constructor(x = 0, y = 0, width = 0, height = 0) {
this.x = x; this.y = y; this.width = width; this.height = height;
}
static Contains(r, px, py) {
return px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height;
}
}
export { Controller, BaseFilterShader, Rectangle };
export default {
GameObjects: { Container, Sprite },
Geom: { Rectangle },
Physics: { Arcade: { Sprite } },
Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } },
Filters: { Controller },

View File

@ -119,13 +119,51 @@ const integrate = (ship) => {
ship.stats.shields === s.shields &&
ship.stats.cargoHold === s.cargoHold &&
ship.stats.mineralStorage === s.mineralStorage);
check('base stats are the shipped values (100 / 0 / 100 / 250)',
check('base stats are the shipped values (100 / 0 / 4 / 250)',
ship.stats.hullIntegrity === 100 &&
ship.stats.shields === 0 &&
ship.stats.cargoHold === 100 &&
ship.stats.cargoHold === 4 &&
ship.stats.mineralStorage === 250);
check('cargo hold is wired (CargoHold, capacity = unlocked bays, 4→16 grid)',
Array.isArray(ship.cargo.items) &&
ship.cargo.capacity === ship.stats.cargoHold &&
ship.cargo.capacity === 4 &&
ship.maxCargoSlots === 16 &&
ship.cargoRoom() === 4);
}
// --- Test 0b: cargo build upgrades (data/builds.json → ship hold) -----------
{
const defs = config.get('builds.builds', {});
const cargoDefs = Object.entries(defs)
.filter(([, d]) => d?.category === 'cargo')
.map(([id, d]) => ({ id, bays: d.effects?.cargo?.bays, targets: d.targets, cost: d.cost }))
.sort((a, b) => (a.bays ?? 0) - (b.bays ?? 0));
check('cargo category has 3 builds (no more "category cargo has no builds")',
cargoDefs.length === 3 && cargoDefs.every((d) => d.bays > 0));
check('cargo builds target the ship and are credit-costed',
cargoDefs.every((d) => Array.isArray(d.targets) && d.targets.includes('ship') &&
d.cost && typeof d.cost.credits === 'number' && d.cost.credits > 0));
check('cargo bays escalate 8 < 12 < 16 (all ≤ the 16-slot cap)',
cargoDefs.map((d) => d.bays).join(',') === '8,12,16' && cargoDefs.every((d) => d.bays <= 16));
// Apply them in build order (the way GameScene._applyBuildEffects would),
// MAX-wins: the hold only ever grows.
const ship = new Ship(scene, 0, 0);
const before = ship.stats.cargoHold;
for (const d of cargoDefs) {
const bays = d.bays;
if (bays > ship.stats.cargoHold) ship.setCargoHold(bays);
}
check('building all three cargo bays expands the hold to 16',
before === 4 && ship.stats.cargoHold === 16 && ship.cargo.capacity === 16);
// MAX-wins: a smaller bay never downgrades the already-expanded hold
// (GameScene._applyCargoUpgrade guards with `bays > current` before applying).
const smaller = 8;
if (smaller > ship.stats.cargoHold) ship.setCargoHold(smaller);
check('MAX-wins — re-applying the 8-bay bay does not shrink the 16-bay hold',
ship.stats.cargoHold === 16 && ship.cargo.capacity === 16);
}
// --- Test 1: fly to a point, arrive and stop --------------------------------
{
const ship = new Ship(scene, 640, 360);

361
dev/shop-ui.test.mjs Normal file
View File

@ -0,0 +1,361 @@
/**
* Shop UI test (dev tool, run with Node — no browser needed):
*
* node --import ./dev/phaser-loader.mjs dev/shop-ui.test.mjs
*
* The SHOP SCREEN's economic core (js/ui/shop/ShopView.js + TradeGrid.js),
* driven headlessly against the real config + a real galaxy + a real
* CargoHold (js/economy/CargoHold.js). It does NOT test pixel layout (that's
* verified in the browser) — it tests the LOGIC the player feels:
*
* - CONSTRUCT — the view builds both grids over a real shop (16 sell bays,
* 12–16 buy slots) and starts on the Buy/Sell tab.
* - SELL — checking a cargo bay sells one unit at the shop's sell price;
* Confirm earns the credits and drops the good out of the hold.
* - BUY — stepping a stock good buys it at the shop's buy price; Confirm
* spends the credits and fills the bays.
* - CAPS — Confirm is BLOCKED when the trade would go negative (not enough
* credits) or overflow the hold (cargo-full); selling to free room for a
* buy lands in one confirm.
* - 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.
*
* A fake Scene provides add.text/image/graphics/tweens/etc.; the phaser stub
* (dev/phaser-stub.mjs) provides the Container the components extend.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const Phaser = (await import(pathToFileURL(join(__dirname, '../js/vendor/phaser.js')).href)).default;
const { CargoHold } = await import(pathToFileURL(join(__dirname, '../js/economy/CargoHold.js')).href);
const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { ShopView } = await import(pathToFileURL(join(__dirname, '../js/ui/shop/ShopView.js')).href);
const goods = config.get('goods.goods', []);
assert.ok(goods.length >= 2, 'catalog has goods to trade');
// ---------------------------------------------------------------------------
// A headless Scene: just enough for the components to CONSTRUCT + render to
// no-op stubs. The LOGIC under test lives in the components + Economy, not
// here — this only keeps `scene.add.text/image/graphics/...` from throwing.
// ---------------------------------------------------------------------------
function stubObject(extra = {}) {
return {
setOrigin() { return this; },
setAlpha(a) { this.alpha = a; return this; },
setScale() { return this; },
setTint() { return this; },
setPosition(x, y) { this.x = x; this.y = y; return this; },
setStrokeStyle() { return this; },
setInteractive(opts) { this._interactive = opts; return this; },
setTexture(key, frame) { this._textureKey = key; this.frame = frame; return this; },
on() { return this; },
once() { return this; },
off() { return this; },
emit() { return this; },
setText(s) { this.text = String(s); return this; },
setColor(c) { this.color = c; return this; },
setFontSize() { return this; },
setWordWrap() { return this; },
setLineSpacing() { return this; },
setPadding() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
set() { return this; },
clear() { return this; },
fillStyle() { return this; },
lineStyle() { return this; },
rect() { return this; },
fillRect() { return this; },
lineRect() { return this; },
fill() { return this; },
stroke() { return this; },
destroy() { this.destroyed = true; },
...extra,
};
}
function makeScene() {
const scene = {
scale: { width: 1280, height: 720, on() { return this; } },
add: {
text: (x, y, str, style) => stubObject({ x, y, text: String(str), _style: style }),
image: (x, y, key, frame) => stubObject({ x, y, key, frame }),
graphics: () => stubObject({}),
existing: (o) => o,
},
tweens: {
// Apply the target values immediately (so the grid ends at its final
// layout position — the real tween animates to the same place).
add: (t) => {
const apply = (o) => { for (const k of ['x', 'y', 'scale', 'alpha']) if (typeof t?.[k] === 'number' && o) o[k] = t[k]; };
if (Array.isArray(t?.targets)) t.targets.forEach(apply);
else if (t?.targets) apply(t.targets);
return { destroy() {}, stop() {} };
},
killTweensOf() {},
},
time: { now: 0, delayCall: () => ({ stop() {} }) },
input: { pointer: {}, on() { return this; }, off() { return this; } },
events: { on() { return this; }, off() { return this; }, once() { return this; }, emit() { return this; } },
textures: { exists: (k) => k === 'goods_icons' },
cameras: { main: { setScroll() { return this; }, flash() { return this; } } },
game: { loop: { now: 0 } },
};
scene.add.container = (x, y) => new Phaser.GameObjects.Container(scene, x, y);
return scene;
}
// A minimal ship: the real CargoHold + the credit ledger the ShopView drives.
function makeShip(credits = 0) {
const cargo = new CargoHold(4);
return {
credits,
cargo,
addCredits(n) { this.credits = Math.max(0, this.credits + Math.round(Number(n) || 0)); return this; },
spendCredits(n) { n = Math.round(Number(n) || 0); if (n > this.credits) return false; this.credits -= n; return true; },
};
}
const galaxy = Galaxy.create('orbit-shop-ui');
const classesOf = (id) => {
const s = galaxy.economy.classBySystem.get(id);
return s ? [...s] : [];
};
const sysId = [...galaxy.byId.keys()].find((id) => classesOf(id).some((c) => c !== 'capital'));
const cls = classesOf(sysId).find((c) => c !== 'capital') ?? 'rocky';
const stock = E.shopFor(cls, sysId, galaxy).buy;
assert.ok(stock.length >= 1, 'the shop has goods to buy');
const A = goods[0].id;
const B = goods[1].id;
const C = stock[0].id; // a good the shop actually sells
function makeView(ship) {
const scene = makeScene();
const gs = { refreshed: 0, refreshCreditsHud() { this.refreshed += 1; } };
const view = new ShopView(scene, {
ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {},
});
return { scene, view, gs };
}
// ===========================================================================
console.log('== CONSTRUCT — both grids, Buy/Sell default ==');
{
const ship = makeShip(10000);
ship.cargo.add([A, B]);
const { view } = makeView(ship);
assert.equal(view.tab, 'trade', 'opens on the Buy/Sell tab');
assert.ok(view.sellGrid && view.buyGrid, 'both the SELL and BUY grids exist');
assert.equal(view.sellGrid.maxSlots, 16, 'SELL grid is 16 bays');
assert.equal(view.buyGrid.maxSlots, 16, 'BUY grid is 16 slots');
assert.ok(view.buyGrid.stock.length >= 1, 'BUY grid carries the shop stock');
assert.deepEqual(view.sellGrid.bays.slice(0, 2), [A, B], 'SELL bays mirror the hold');
assert.deepEqual(view.sellGrid.getSellMap(), {}, 'nothing checked yet');
assert.equal(view.isBuybackEnabled(), false, 'Buyback is off before a sale');
}
console.log('CONSTRUCT: OK');
// ===========================================================================
console.log('\n== INTERACTION — icons bound per good + taps carry a hitAreaCallback ==');
{
const ship = makeShip(10000);
ship.cargo.add([A, B]);
const { view } = makeView(ship);
const goodById = (id) => goods.find((g) => g.id === id);
// Each BUY cell's icon must carry the correct spritesheet frame (set via
// setTexture(key, frame) — NOT the read-only `.frame = n` assignment that
// corrupted the texture source and crashed the real WebGL render).
let checkedIcons = 0;
for (let i = 0; i < view.buyGrid.cells.length; i++) {
const cell = view.buyGrid.cells[i];
const id = view.buyGrid.stock[i]?.id;
if (!id) continue;
const idx = goodById(id)?.icon;
assert.ok(typeof idx === 'number', `good ${id} has an icon index`);
assert.equal(cell.icon?._textureKey, E.GOODS_TEXTURE_KEY, 'icon bound to the goods sheet');
assert.equal(cell.icon?.frame, idx, `buy cell icon frame = good icon index (${idx})`);
checkedIcons += 1;
}
assert.ok(checkedIcons >= 1, 'at least one buy cell got its icon bound');
// Every tappable element must be interactive WITH a hitAreaCallback — a bare
// setInteractive() leaves no callback and crashes Phaser v4's input (the
// exact browser error). The stub now mirrors Phaser.Geom.Rectangle so the
// real hitArea/hitAreaCallback path is exercised.
assert.equal(typeof view.sellGrid.cells[0].plate?._interactive?.hitAreaCallback, 'function', 'sell bay tap has a hitAreaCallback');
assert.ok(view.sellGrid.cells[0].plate?._interactive?.hitArea, 'sell bay tap has a hitArea rect');
assert.equal(typeof view.buyGrid.cells[0].stepMinus?._interactive?.hitAreaCallback, 'function', 'buy stepper tap has a hitAreaCallback');
// The two panels must settle at DIFFERENT layout positions — the slide-in
// tween targets each grid's set position (the ShopView centers
// [SELL] [center] [BUY]). The old bug tweened to x:0, stacking both panels
// at the left edge so the cargo/sell panel sat hidden behind the buy panel.
assert.ok(view.sellGrid.x > 0, `sell panel settles at a positive layout x (got ${view.sellGrid.x})`);
assert.ok(view.buyGrid.x > view.sellGrid.x, `buy panel is to the right of the sell panel (sell ${view.sellGrid.x} < buy ${view.buyGrid.x})`);
assert.notEqual(view.sellGrid.x, view.buyGrid.x, 'the panels do not overlap at the origin');
}
console.log('INTERACTION: OK');
// ===========================================================================
console.log('\n== SELL — check a bay, Confirm earns credits + empties the hold ==');
{
const ship = makeShip(10000);
ship.cargo.add([A, B]);
const { view, gs } = makeView(ship);
const sellA = E.sellPrice(A, cls, sysId, galaxy);
assert.ok(sellA > 0, 'the sell price is positive');
view.sellGrid.toggleBay(0); // bay 0 holds A
assert.deepEqual(view.sellGrid.getSellMap(), { [A]: 1 }, 'checking bay 0 → sell one A');
let pv = view.preview();
assert.equal(pv.sellTotal, sellA, 'sellTotal = the A sale');
assert.equal(pv.net, sellA, '… net = the sale (nothing bought)');
assert.equal(pv.valid, true, '… and it\u2019s legal');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before + sellA, 'credits rise by the sale');
assert.deepEqual(ship.cargo.snapshot(), [B], 'A leaves the hold, B stays');
assert.equal(view.isBuybackEnabled(), true, '… and A is now buyback-eligible');
assert.equal(gs.refreshed, 1, 'the credits HUD was refreshed');
}
console.log('SELL: OK');
// ===========================================================================
console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays ==');
{
const ship = makeShip(10000); // empty hold (room for 4)
const { view, gs } = makeView(ship);
const buyC = E.buyPrice(C, cls, sysId, galaxy);
assert.ok(buyC > 0, 'the buy price is positive');
view.buyGrid.bumpBuy(C, 2);
assert.deepEqual(view.buyGrid.getBuyMap(), { [C]: 2 }, 'stepping twice → buy two C');
let pv = view.preview();
assert.equal(pv.buyTotal, buyC * 2, 'buyTotal = 2 × the C price');
assert.equal(pv.net, -buyC * 2, '… net = the spend');
assert.equal(pv.valid, true, '… affordable and room for two');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before - buyC * 2, 'credits fall by the spend');
assert.deepEqual(ship.cargo.snapshot(), [C, C], '… and the two C fill the hold');
assert.equal(gs.refreshed, 1, '… and the HUD refreshes');
}
console.log('BUY: OK');
// ===========================================================================
console.log('\n== CAPS — blocked when negative or over the hold ==');
{
// Not enough credits.
const broke = makeShip(0);
const buyC = E.buyPrice(C, cls, sysId, galaxy);
const { view: vb } = makeView(broke);
vb.buyGrid.bumpBuy(C, 1);
let pv = vb.preview();
assert.equal(pv.valid, false, 'buying with 0 credits is INVALID');
assert.ok(pv.errors.includes('insufficient-credits'), '… flagged insufficient-credits');
assert.equal(vb.confirm(), false, '… and Confirm is refused');
assert.equal(broke.credits, 0, '… nothing spent');
assert.equal(broke.cargo.used, 0, '… nothing bought');
// Over the hold (full 4, buy 1 → 5).
const full = makeShip(100000);
full.cargo.add([A, B, A, B]); // 4 bays, no room
const { view: vf } = makeView(full);
vf.buyGrid.bumpBuy(C, 1);
pv = vf.preview();
assert.equal(pv.valid, false, 'buying with a full hold is INVALID');
assert.ok(pv.errors.includes('cargo-full'), '… flagged cargo-full');
assert.equal(vf.confirm(), false, '… and Confirm is refused');
// …but SELL one to free a bay, then buy one → valid in the same confirm.
vf.sellGrid.toggleBay(0); // sell bay 0 (an A)
pv = vf.preview();
assert.equal(pv.cargoAfter, 4, '4 − 1 (sold) + 1 (bought) = 4 → fits');
assert.equal(pv.valid, true, '… now it\u2019s legal (room freed)');
const before = full.credits;
assert.equal(vf.confirm(), true, '… and it goes through');
assert.deepEqual(full.cargo.snapshot().sort(), [A, B, B, C].sort(), 'one A left, one C arrived (net hold 4)');
assert.equal(full.credits, before + E.sellPrice(A, cls, sysId, galaxy) - E.buyPrice(C, cls, sysId, galaxy), '… credits = +sale − buy');
}
console.log('CAPS: OK');
// ===========================================================================
console.log('\n== BUYBACK — sell, then buy it back at the sale price (neutral) ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship);
const sellA = E.sellPrice(A, cls, sysId, galaxy);
// Sell one A.
view.sellGrid.toggleBay(0);
assert.equal(view.confirm(), true, 'sell the A');
assert.equal(ship.credits, 10000 + sellA, '… earned the sale price');
assert.deepEqual(ship.cargo.snapshot(), [], '… hold is empty');
assert.equal(view.isBuybackEnabled(), true, 'Buyback is now enabled');
// Switch to the Buyback tab and buy the A back.
view.setTab('buyback');
assert.equal(view.tab, 'buyback', '… on the Buyback tab');
assert.deepEqual(view._bbRows(), [{ id: A, name: goods[0].name, qty: 1, price: sellA, sel: 0 }], '… the sold A is listed at the sale price');
view.bumpBuyback(A, 1);
assert.deepEqual(view.getBuybackMap(), { [A]: 1 }, 'stepping once → buy back one A');
let pv = view.preview();
assert.equal(pv.buyTotal, sellA, '… it costs the SALE price (the neutral undo)');
assert.equal(pv.net, -sellA, '… net = −sale');
assert.equal(pv.valid, true, '… affordable and room for one');
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before - sellA, '… spends the sale price → back to 10000');
assert.equal(ship.credits, 10000, '… so sell→buyback nets ZERO (no free-credit loop)');
assert.deepEqual(ship.cargo.snapshot(), [A], '… and the A is back in the hold');
assert.equal(view.isBuybackEnabled(), false, '… fully bought back → Buyback disables again');
}
console.log('BUYBACK: OK');
// ===========================================================================
console.log('\n== BUYBACK is locked until a sale + Clear resets the selection ==');
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship);
assert.equal(view.isBuybackEnabled(), false, 'no sale yet → Buyback disabled');
view.setTab('buyback');
assert.equal(view.tab, 'trade', '… and it refuses to open (stays on Buy/Sell)');
view.sellGrid.toggleBay(0);
assert.equal(view.sellGrid.hasSelection(), true, 'a bay is checked');
view.clear();
assert.equal(view.sellGrid.hasSelection(), false, 'Clear drops the selection');
assert.equal(view.preview().net, 0, '… and the net returns to zero');
}
console.log('LOCK + CLEAR: OK');
console.log('\nAll shop-UI checks passed \u2714');

View File

@ -207,6 +207,13 @@ never drift or corrupt.
- **You start at 0** (`economy.json → credits.startingCredits`).
- **Earning credits** = selling goods + mission/quest credit rewards. **Spending** = builds,
upgrades, and buying goods.
- **The cargo bays are the first credit sink** — the hold expands 4 → 8 → 12 → 16 through three
credit-costed ship builds (`data/builds.json → cargo-bay-8/12/16`, `targets: ["ship"]`,
`cost: { credits: 300/1200/3000 }`, `effects.cargo.bays`). Built from any planet's console and
applied to the ship (MAX wins, never a downgrade). The build console was extended to pay out
credits (`BuildWindow` affordability covers both minerals + credits; `GameScene.beginBuild`
debits `ship.spendCredits` for credit lines) — so trading income now has somewhere to go
before the next sink exists.
- **Trading is LOCKED until the third story quest** (not written yet). Until then the economy
exists in the world (shops, prices, gems) but the player's buy/sell UI is gated. Initial
credits come from **mission/quest rewards** (the quest reward object already has a `credits`
@ -238,13 +245,24 @@ That's flavor, not management.
| `js/economy/Economy.js` | The pure price/stock engine (`buyPrice`/`sellPrice`/`shopFor`) + the `buildEconomy` generation pass (classBySystem + producerHops). |
| `js/ui/CreditsHud.js` | The CREDITS line, upper-right, above the minerals HUD (count-up on change). |
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
| `js/entities/Ship.js` | `ship.credits` + `canAfford`/`addCredits`/`spendCredits`/`setCredits` (mirrors the minerals seam). |
| `js/save/SaveData.js` | Credits captured/restored with the ship record. |
| `js/scenes/GameScene.js` | Builds the credits HUD (minerals HUD shifted below it), refreshes it on credit changes, pays quest `reward.credits` on claim. |
| `js/entities/Ship.js` | `ship.credits` + `canAfford`/`addCredits`/`spendCredits`/`setCredits` (the ledger) and `ship.cargo` (the `CargoHold`) + `maxCargoSlots`/`setCargoHold`/`cargoRoom`. |
| `js/economy/CargoHold.js` | The trade-goods hold — a **pure, framework-free** class (capacity, items, add/remove/count/has/clear/set/snapshot/save-clone). One good per bay. |
| `js/ui/shop/TradeGrid.js` | One side of the shop: a 4×4 grid of goods. SELL mode = the player's cargo bays (tap to check); BUY mode = the shop's stock (−/qty/+ stepper). |
| `js/ui/shop/ShopView.js` | The buy/sell (and buyback) screen: the two grids + a live NET preview + Confirm/Clear, a Buy/Sell ↔ Buyback tab bar, the session-local `soldThisSession` buyback, and the transaction apply. |
| `data/shopui.json` | The shop screen's layout/animation knobs (tabs, panels, grid geometry, slot colors, stepper, preview, buttons) — presentation only; the economy is `Economy.js`. |
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
| `js/save/SaveData.js` | Credits + cargo captured/restored with the ship record. |
| `js/scenes/GameScene.js` | Builds the credits HUD (minerals HUD shifted below it), refreshes it on credit changes, pays quest `reward.credits` on claim, restores cargo. |
| `js/scenes/SurfaceScene.js` | Opens the `ShopView` over the shop clip on the deck's SHOP button (resolving this world's economic class) and tears it down on LEAVE SHOP. |
| `data/builds.json` | The **cargo bays** (`cargo-bay-8/12/16`) — the first credit sink. Credit-costed ship builds that expand the hold (4 → 8 → 12 → 16), built from any planet's console, MAX wins. |
| `js/ui/BuildWindow.js` | The build console. Its affordability wallet now covers **both** minerals + credits (`_wallet()`), so the credit-costed cargo bays are buyable. |
| `js/scenes/GameScene.js` (build) | `beginBuild` debits credit lines via `ship.spendCredits` (mineral lines via `ship.setMinerals`); `_applyCargoUpgrade` expands the hold on completion (MAX wins). |
| `dev/economy.test.mjs` | The engine's invariants: determinism, fixed prices, the loop, distance, shop floor, hubs. |
| `dev/cargo.test.mjs` | The hold's rules (capacity, dups, drop-sheds, save/clone) + the `tradePreview` caps (credits/cargo, sell→buy in one confirm).
| `dev/shop-ui.test.mjs` | The shop screen's LOGIC headlessly: construct, sell, buy, the two caps, the session-local buyback (a neutral undo), the lock, and Clear.
| `docs/ECONOMY.md` | This document. |
Implemented this pass: the **price/stock engine**, the **generation pass**, **credits state + save + HUD**, and **quest-credit payout** (the reward seam is live; no first-pass quest pays credits yet). The **shop buy/sell UI panel** and the **goods spritesheet** remain deferred — trading is locked until the third story quest.
Implemented this pass: the **price/stock engine**, the **generation pass**, **credits state + save + HUD**, **quest-credit payout**, the **cargo hold** (`CargoHold` + `ship.cargo` + save/restore), and the full **shop buy/sell/buyback UI** (`TradeGrid` + `ShopView`, wired into the SurfaceScene over the existing shop clip). Trading is locked until the third story quest.
---
@ -253,7 +271,10 @@ Implemented this pass: the **price/stock engine**, the **generation pass**, **cr
1. **Initial credits** — trading is gated until the third story quest; first credits come from
**mission/quest rewards** (the `reward.credits` field already exists in `data/quests.json`)
and, later, **asteroid gemstone mining** (mining-laser upgrades yield gems you sell).
2. **Cargo weight** — **1 hold unit per good** (simplest to manage; `cargoHold = 100`).
2. **Cargo weight** — the hold is a set of fixed **bays, one good each** (`unitPerGood = 1`).
The player starts with **4 unlocked bays** (`cargoHold = 4`); cargo upgrades raise it toward
the **16-slot grid** the shop UI always shows (`cargo.maxSlots = 16`). Locked bays are just
capacity the ship hasn't bought yet.
3. **Reputation & faction prices** — **deferred** (the hooks stay in `economy.json`; wired later).
4. **Zones / regional bands** — **hop-distance only** (no extra per-zone price swing).
5. **Station vs. capital** — stations = wide *variety* (resellers), capitals = wide *premium*
@ -267,3 +288,53 @@ Implemented this pass: the **price/stock engine**, the **generation pass**, **cr
region), not a live per-trade hop-multiplier.
- **Credits are the go-between** — buy and sell are two separate credit transactions; there is
no good-for-good barter.
---
## 12. The shop screen (player-facing UI)
Where the loop is actually played. You open it from the surface deck's **SHOP** button (the
existing `data/actionbar.json` slot); the shop clip is already live, and the screen sits **over**
it. It's one component — `js/ui/shop/ShopView.js` — composing two `TradeGrid`s.
```
┌─────────────────────────────────────────────────────────────────┐
│ [ Buy/Sell | Buyback ] ← tabs │
│ │
│ ┌SELL 4×4┐ NET +420 cr ┌BUY 4×4┐ │
│ │ bays │ (green ≥ 0 / │ stock │ │
│ │(cargo) │ red < 0) │(12–16) │ │
│ └────────┘ [ Confirm ] └────────┘ │
│ [ Clear ] │
└─────────────────────────────────────────────────────────────────┘
```
- **SELL (left)** — your **cargo bays**. 16 cells (the fixed grid); the first `cargoHold`
are live bays (filled with a good, or empty), the rest are **locked** (the hold's upgrade
room). Tap a filled bay to **check it off to sell** (a bay = 1 unit). No stepper — the
quantity is simply the number of its bays that are checked.
- **BUY (right)** — the shop's **stock** (12–16 distinct goods from `Economy.shopFor`). Each
cell has a **−/qty/+ stepper**; buy as many as you like (capped by credits + hold room).
- **NET preview (center)** — the live credit delta. Green when you're up (a net sale), red when
you'd spend. **Confirm** is disabled while the trade is invalid (can't go negative, hold
would overflow). **Clear** drops the current selection. Both appear once you start picking.
- **SELL → BUY is atomic** — check bays to sell *and* step goods to buy, then one Confirm
executes it: credits `+ sale − buy`, cargo `− sold + bought`, in that order (free room
first). Selling to free a bay for a buy lands in the same Confirm.
- **BUYBACK (second tab, session-local only)** — after you SELL something, the goods you sold
are listed here and can be bought **back** — at the **sale price** (a *neutral* undo, so
sell → buyback nets **zero** and can't loop into free credits). It's **locked** (dim, no-op)
until you've sold something, and it **disappears when you leave the shop** (no persistence —
`data/shopui.json`). Buyback is clamped to what you actually sold.
**Determinism + statelessness preserved.** The screen reads **fixed** per-(good, location)
prices (`Economy.buyPrice`/`sellPrice` — the seed + place, never where you got a good or what's
in your hold). There are **no per-player sold/bought counters**; reopening a shop re-derives
the same stock from `(seed, placeId, economyVersion)`. The only session state is the buyback
list (explicitly reset on leave). So the economy stays "interact, don't control": you trade
against fixed regional prices, never against your own history.
**Tested headlessly** (`dev/shop-ui.test.mjs`): construct both grids, sell a bay and earn the
sale price, step a stock good and pay the buy price, the two caps (credits / cargo) blocking
Confirm, the sell→buy-in-one-confirm, the buyback lock + neutral undo, and Clear. Pixel layout
and the slide-in animation are verified in the browser (the stub doesn't render).

148
js/economy/CargoHold.js Normal file
View File

@ -0,0 +1,148 @@
/**
* CargoHold — the ship's trade-goods hold, as a PURE, framework-free object.
*
* The player carries trade goods between shops (buy cheap in one region,
* sell high in another — the whole game loop). The hold is a set of fixed
* BAYS: one good occupies one bay (data/economy.json → cargo.unitPerGood = 1).
* The number of UNLOCKED bays is the capacity (ship.stats.cargoHold — the
* player starts with 4, upgrades raise it toward the 16-slot grid); the
* locked bays are just capacity the ship doesn't have yet.
*
* It is deliberately NOT a Phaser object: the rules live here so they can be
* unit-tested in plain Node (dev/cargo.test.mjs) and so Ship.js stays a thin
* wrapper. A good is identified by its catalog id (data/goods.json); the hold
* stores ids, one per filled bay, in fill order. Duplicates are fine (two
* frost-diamonds = two bays).
*
* The hold is the carrier, not a price ledger — pricing lives in
* js/economy/Economy.js (buyPrice/sellPrice, per good + shop).
*/
export class CargoHold {
/**
* @param {number} capacity — the number of unlocked bays (0 when the ship
* has no cargo capacity yet). Clamped to a non-negative integer.
*/
constructor(capacity = 0) {
this._capacity = Math.max(0, Math.floor(Number(capacity) || 0));
// Filled bays, in fill order. Each entry is a good id (string).
// Invariant: items.length ≤ _capacity (enforced by add/set).
this.items = [];
}
/** The number of unlocked bays (the hold's capacity). */
get capacity() {
return this._capacity;
}
/**
* Raise or lower the capacity. Raising frees more bays (the cargo upgrades
* do this). Lowering DROPS the oldest (tail) goods first so the invariant
* holds — a ship that shrinks its hold sheds what no longer fits.
*/
set capacity(n) {
this._capacity = Math.max(0, Math.floor(Number(n) || 0));
while (this.items.length > this._capacity) this.items.pop();
}
/** How many bays are filled (0 … capacity). */
get used() {
return this.items.length;
}
/** Empty bays left (capacity − used). */
get room() {
return Math.max(0, this._capacity - this.items.length);
}
isFull() {
return this.room <= 0;
}
/** How many copies of a good are aboard. */
count(goodId) {
let n = 0;
for (const id of this.items) if (id === goodId) n++;
return n;
}
has(goodId) {
return this.count(goodId) > 0;
}
/**
* Load goods into the hold (buying, or a hand-off). `goodIds` is a good id
* or an array of them (duplicates allowed — one per bay). Fills until the
* hold is full or the list is exhausted. Returns the number actually added.
*/
add(goodIds) {
const ids = Array.isArray(goodIds) ? goodIds : [goodIds];
let added = 0;
for (const id of ids) {
if (this.room <= 0) break;
if (typeof id !== 'string' || !id) continue;
this.items.push(id);
added++;
}
return added;
}
/**
* Remove up to `n` copies of a good from the hold (selling). Returns the
* number actually removed (0 when the good isn't aboard).
*/
remove(goodId, n = 1) {
const k = Math.max(1, Math.floor(Number(n) || 1));
let removed = 0;
for (let i = 0; i < this.items.length && removed < k; i++) {
if (this.items[i] === goodId) {
this.items.splice(i, 1);
i--;
removed++;
}
}
return removed;
}
/** Empty the hold entirely. */
clear() {
this.items.length = 0;
}
/**
* Replace the hold's contents wholesale (the SAVE/LOAD path). `items` is an
* array of good ids; it is filtered to real ids and truncated to capacity
* (a record can never overfill the hold). Returns the number kept.
*/
set(items) {
const clean = (Array.isArray(items) ? items : []).filter(
(id) => typeof id === 'string' && id,
);
this.items = clean.slice(0, this._capacity);
return this.items.length;
}
/** A copy of the filled-bay id list (safe to iterate / spread). */
snapshot() {
return [...this.items];
}
/** Serialize (SaveData.js → captureState → record.ship.cargo). */
toJSON() {
return { capacity: this._capacity, items: [...this.items] };
}
/**
* Deserialize (GameScene.applyRestore). Missing fields degrade to an empty
* hold (a save predating cargo loads as an empty one).
*/
static fromJSON(o) {
const h = new CargoHold(o?.capacity ?? 0);
h.set(o?.items ?? []);
return h;
}
/** A deep copy (tests, previews). */
clone() {
return CargoHold.fromJSON(this.toJSON());
}
}

View File

@ -240,6 +240,121 @@ export function shopFor(cls, systemId, galaxy) {
return { buy, sell };
}
// ---------------------------------------------------------------------------
// THE TRADE PREVIEW — pure, framework-free (the shop UI's calculator)
// ---------------------------------------------------------------------------
/**
* A good id → N repeated ids (the buy/sell side is quantity-based, the hold
* is bay-based: `boughtUnits` of a good fill that many bays).
*/
function repeat(id, qty) {
const n = Math.max(0, Math.floor(Number(qty) || 0));
return Array.from({ length: n }, () => id);
}
/**
* Apply a settled trade to a bay list: remove the sold goods (frees bays),
* then add the bought goods (fills bays). Order matters — selling first is
* what lets "sell 2 to make room to buy 3" land in one confirm even from a
* full hold. Pure (returns a new array).
*/
export function resultingCargo(cargo, sell = [], buy = []) {
const out = (Array.isArray(cargo) ? cargo : []).filter((id) => typeof id === 'string' && id);
for (const s of sell) {
for (const _ of repeat(s.id, s.qty)) {
const i = out.indexOf(s.id);
if (i < 0) break; // nothing left of this good aboard
out.splice(i, 1);
}
}
for (const b of buy) out.push(...repeat(b.id, b.qty));
return out;
}
/**
* The SHOP UI's preview: given the goods the player wants to SELL (out of
* their hold) and BUY (from the shop's stock), compute the two sides, the net
* credit change, the resulting hold, and whether the trade is legal.
*
* sellMap = { goodId: qty } — goods to sell (qty clamped to what's aboard)
* buyMap = { goodId: qty } — goods to buy (into the hold)
*
* Pricing is the FIXED per-(good, shop) price from buyPrice/sellPrice above —
* independent of where the good came from. The two caps:
* · credits — creditsAfter must be ≥ 0 (you can't spend into the red);
* · cargo — the END hold (aboard − sold + bought) must fit the unlocked
* bays (capacity). Selling to free room for a buy is allowed
* (the net end-state is what's checked, not the peak).
* Pure in its inputs — the UI calls it on every selection change; a test can
* call it with no scene at all.
*
* @returns {{
* sell: Array<{id,name,qty,unit,total}>, buy: Array<{id,name,qty,unit,total}>,
* sellTotal:number, buyTotal:number, net:number, creditsAfter:number,
* sellUnits:number, buyUnits:number, cargoAfter:number, cargo: string[],
* valid:boolean, errors: string[]
* }}
*/
export function tradePreview(o) {
const cls = o.cls;
const systemId = o.systemId;
const galaxy = o.galaxy;
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
const sellMap = o.sellMap ?? {};
const buyMap = o.buyMap ?? {};
// Per-good BUY-price overrides (the BUYBACK tab passes the sale price back
// into the buy side — "buy it back for what you got for it", a neutral undo
// so sell→buyback can't net-profit into an infinite-credits loop).
const buyOverrides = o.buyOverrides ?? {};
const credits = Math.max(0, Number(o.credits) || 0);
const capacity = Math.max(0, Math.floor(Number(o.capacity) || 0));
const sell = [];
let sellTotal = 0;
let sellUnits = 0;
for (const [id, rawQty] of Object.entries(sellMap)) {
if (typeof id !== 'string' || !id) continue;
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);
const total = unit * qty;
sell.push({ id, name: goodById(id)?.name ?? id, qty, unit, total });
sellTotal += total;
sellUnits += qty;
}
const buy = [];
let buyTotal = 0;
let buyUnits = 0;
for (const [id, rawQty] of Object.entries(buyMap)) {
if (typeof id !== 'string' || !id) continue;
const qty = Math.max(0, Math.floor(Number(rawQty) || 0));
if (qty <= 0) continue;
const unit = (id in buyOverrides) ? Math.max(0, Number(buyOverrides[id]) || 0) : buyPrice(id, cls, systemId, galaxy);
const total = unit * qty;
buy.push({ id, name: goodById(id)?.name ?? id, qty, unit, total });
buyTotal += total;
buyUnits += qty;
}
const net = sellTotal - buyTotal;
const creditsAfter = credits + net;
const cargoList = resultingCargo(cargo, sell, buy);
const cargoAfter = cargoList.length;
const errors = [];
if (creditsAfter < 0) errors.push('insufficient-credits');
if (cargoAfter > capacity) errors.push('cargo-full');
const valid = errors.length === 0;
return {
sell, buy, sellTotal, buyTotal, net, creditsAfter,
sellUnits, buyUnits, cargoAfter, cargo: cargoList,
valid, errors,
};
}
// ---------------------------------------------------------------------------
// THE GENERATION PASS — galaxy.economy (classBySystem + producerHops)
// ---------------------------------------------------------------------------

View File

@ -1,6 +1,7 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { CargoHold } from '../economy/CargoHold.js';
/**
* The player's ship: an arcade-physics sprite that flies to wherever you click
@ -27,7 +28,8 @@ import { toColor } from '../utils/Color.js';
* ship's starting condition before any upgrades:
* - hullIntegrity → the hull's max health (damage it can take)
* - shields → damage absorbed in front of the hull
* - cargoHold → goods capacity
* - cargoHold → goods capacity (UNLOCKED bays; the player starts with
* 4, upgrades raise it toward the 16-slot grid)
* - mineralStorage → minerals capacity
* - miningSpeed → multiplier on mining rate (1 = baseline)
*
@ -105,7 +107,7 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
this.stats = {
hullIntegrity: config.get('ship.stats.hullIntegrity', 100),
shields: config.get('ship.stats.shields', 0),
cargoHold: config.get('ship.stats.cargoHold', 100),
cargoHold: config.get('ship.stats.cargoHold', 4),
mineralStorage: config.get('ship.stats.mineralStorage', 250),
miningSpeed: config.get('ship.stats.miningSpeed', 1),
};
@ -122,6 +124,16 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
// cap (they're a ledger, not a hold). See js/economy/Economy.js.
this.credits = Math.max(0, Math.round(Number(config.get('economy.credits.startingCredits', 0)) || 0));
// CARGO — the trade-goods hold (data/economy.json → cargo). A set of fixed
// BAYS, one good each (unitPerGood = 1). `cargo.capacity` = the UNLOCKED
// bays (stats.cargoHold — the player starts with 4); `maxCargoSlots` is
// the 16-slot grid the hold grows into as it's upgraded. This is the
// carrier for the buy-cheap / sell-high loop (js/ui/shop/*); the PRICING
// lives in js/economy/Economy.js. It's a plain object (CargoHold.js) so
// its rules are unit-testable with no scene.
this.maxCargoSlots = Math.max(1, Math.floor(Number(config.get('economy.cargo.maxSlots', 16)) || 16));
this.cargo = new CargoHold(this.stats.cargoHold);
// World size = size × scale. Sheet frames are frameWidth px wide; the
// dart is drawn at `size` px — the sprite scale maps either one onto
// the same world size, so both look identical.
@ -242,6 +254,27 @@ export class Ship extends Phaser.Physics.Arcade.Sprite {
this.credits = Math.max(0, Math.round(Number(n) || 0));
}
// ---------------------------------------------------------------------
// CARGO (the trade-goods hold) — thin wrappers over this.cargo (CargoHold)
// ---------------------------------------------------------------------
/**
* Raise (or lower) the hold's UNLOCKED bays — a cargo upgrade (builds) does
* this: 4 → 8 → 12 → 16. Clamped to [0, maxCargoSlots]. A drop that
* overflows sheds the oldest goods (CargoHold.capacity handles it).
*/
setCargoHold(n) {
const v = Math.max(0, Math.min(this.maxCargoSlots, Math.floor(Number(n) || 0)));
this.stats.cargoHold = v;
this.cargo.capacity = v;
return v;
}
/** Empty bays left (0 when full). */
cargoRoom() {
return this.cargo.room;
}
/** Set the destination to fly to (world coordinates). */
setTarget(x, y) {
this.target = { x, y };

View File

@ -96,6 +96,10 @@ export function captureState(scene, now) {
// currency. Saves predating it lack the field; the restore treats
// the absence as 0 (old saves load).
credits: Math.max(0, Math.round(Number(scene.ship.credits) || 0)),
// The trade-goods hold (data/economy.json → cargo) — the good ids in
// the filled bays, in fill order (one good per bay). Saves predating it
// lack the field; the restore treats the absence as an empty hold.
cargo: scene.ship.cargo ? [...scene.ship.cargo.items] : [],
},
discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} },
reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(),

View File

@ -4459,13 +4459,24 @@ export class GameScene extends Phaser.Scene {
if (typeof needTether === 'number' && this.tetherLevelFor(planetName) < needTether) {
return { ok: false, reason: `REQUIRES A LEVEL-${needTether} TETHER ON THIS WORLD` };
}
// cost — the full amount, paid up front (minerals are the build
// currency today — data/builds.json → cost)
const cost = Number(def.cost?.minerals ?? 0);
if (cost > 0) {
if (this.ship.minerals < cost) return { ok: false, reason: `NEED ${cost} MINERALS` };
this.ship.setMinerals(this.ship.minerals - cost);
this.refreshMineralHud();
// cost — the full amount, paid up front. The cost is a { res: amount }
// map (data/builds.json → cost): the planet builds pay MINERALS, the
// cargo bays pay CREDITS (the trade income buys the hold expansion).
// Each line is checked + debited from its own wallet (ship.minerals /
// ship.credits).
const cost = def.cost ?? null;
if (cost && typeof cost === 'object') {
const mineralsCost = Number(cost.minerals ?? 0);
const creditsCost = Number(cost.credits ?? 0);
if (mineralsCost > 0) {
if (this.ship.minerals < mineralsCost) return { ok: false, reason: `NEED ${mineralsCost} MINERALS` };
this.ship.setMinerals(this.ship.minerals - mineralsCost);
this.refreshMineralHud();
}
if (creditsCost > 0) {
if (!this.ship.spendCredits(creditsCost)) return { ok: false, reason: `NEED ${creditsCost} CREDITS` };
this.refreshCreditsHud();
}
}
const durMs = Math.max(1, Number(def.duration ?? 0) * 1000);
state.start(planetName, buildId, durMs, now ?? this.time.now);
@ -4539,6 +4550,11 @@ export class GameScene extends Phaser.Scene {
// the ship's mining speed (minerals/s), effects.mining.capacity =
// the mineral hold's cap. MAX wins — never a downgrade.
this._applyMiningUpgrade(spec);
} else if (type === 'cargo' && spec && typeof spec === 'object') {
// SHIP upgrade (the cargo bay): effects.cargo.bays = the hold's bay
// count (the trade capacity). MAX wins — 4 → 8 → 12 → 16, never a
// downgrade.
this._applyCargoUpgrade(spec);
} else {
console.warn(`[orbit] build: unknown effect ${type}`, spec);
}
@ -4579,6 +4595,28 @@ export class GameScene extends Phaser.Scene {
}
}
/**
* Apply a cargo SHIP upgrade (a build's effects.cargo,
* data/builds.json): `bays` = the hold's bay count (the trade capacity).
* MAX wins — a smaller hold is never installed over a bigger one (build
* the 16-bay, then a 12 — the ship keeps 16). Toasts the expansion.
*/
_applyCargoUpgrade(spec) {
const ship = this.ship;
if (!ship || typeof ship.setCargoHold !== 'function') return;
if (Number.isFinite(Number(spec.bays))) {
const bays = Math.max(0, Math.round(Number(spec.bays)));
if (bays > (Number(ship.stats?.cargoHold) || 0)) {
ship.setCargoHold(bays);
this.refreshCreditsHud(); // the hold is now worth more — keep the readout honest
this.consoleToast(`CARGO HOLD EXPANDED — ${bays} BAYS`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
}
/**
* Re-derive the ship's mining upgrades from the build RECORDS (the
* save carries the records, not the ship's derived stats): the MAX
@ -4959,6 +4997,10 @@ export class GameScene extends Phaser.Scene {
// The credit ledger (data/economy.json) — a save predating credits
// has no field; setCredits floors a missing/invalid value at 0.
this.ship.setCredits(r.ship.credits ?? 0);
// The trade-goods hold (data/economy.json → cargo) — a save predating
// cargo has no field; set() degrades to an empty hold (old saves load).
// Capacity stays the ship's stat (4, or an upgraded hold).
if (this.ship.cargo) this.ship.cargo.set(r.ship.cargo ?? []);
}
// The corner readout (upper right) was built with the FRESH hold (0)
// earlier in create() — push the restored value through it. No-op

View File

@ -11,6 +11,8 @@ import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js';
import { ShopView } from '../ui/shop/ShopView.js';
import * as Economy from '../economy/Economy.js';
import { buildDefs } from '../research/ResearchModel.js';
/**
@ -101,6 +103,7 @@ export class SurfaceScene extends Phaser.Scene {
this.shopKey = null;
this.shopVideo = null; // the SHOP clip (created on first press, then paused/resumed)
this.shopMode = false; // true while the shop clip is on screen (SHOP toggles it)
this.shopView = null; // the buy/sell UI over the clip (js/ui/shop/ShopView.js); null when out of the shop
this.noteG = null; // console-note glyphs (consoleNote)
this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState
}
@ -295,6 +298,7 @@ export class SurfaceScene extends Phaser.Scene {
this.shopVideo.resume();
this.shopMode = true;
this.actionBar?.setLabel('shop', this.shopLabel(true)); // the slot now reads LEAVE SHOP
this.buildShopView(); // the buy/sell UI over the clip (js/ui/shop/ShopView.js)
}
/**
@ -307,6 +311,7 @@ export class SurfaceScene extends Phaser.Scene {
*/
leaveShop() {
this.shopMode = false;
this.destroyShopView(); // tear down the buy/sell UI before restoring the surface
this.shopVideo?.pause();
this.shopVideo?.setVisible(false); // its frozen last frame would cover the surface
this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate)
@ -326,6 +331,58 @@ export class SurfaceScene extends Phaser.Scene {
return inShop ? String(active) : String(base);
}
/**
* The BUY/SELL UI over the shop clip (js/ui/shop/ShopView.js). Built once
* per shop visit (a fresh session each time you land — the session-local
* Buyback state resets with it), and destroyed by leaveShop(). It reads the
* paused GameScene for the ledger (ship.credits / ship.cargo) + the economy
* (galaxy, this system) and the shop's economic class (this world's nature).
*/
buildShopView() {
if (this.shopView) return this.shopView; // already up this visit
const g = this.gameScene;
if (!g?.ship || !g?.galaxy) return null; // no state beneath (shouldn't happen)
const systemId = g.systemRecord?.id ?? g.galaxy.currentSystemId ?? null;
this.shopView = new ShopView(this, {
ship: g.ship,
galaxy: g.galaxy,
cls: this._shopClass(),
systemId,
gameScene: g,
onLeave: () => this.leaveShop(),
});
return this.shopView;
}
destroyShopView() {
if (!this.shopView) return;
this.shopView.destroy?.();
this.shopView = null;
}
/**
* This world's economic class (data/economy.json classes) for the shop:
* match the surface world to a planet (js/entities/Planet.js, carries
* `.class`) or a station settlement, and ask Economy.economicClass. A
* capital world resolves to 'capital' (the universal buyer); a deep-space
* station to 'station'. Falls back to the system's first recorded class.
*/
_shopClass() {
const g = this.gameScene;
const name = this.planetName;
const match = (list) => (Array.isArray(list) ? list : []).find(
(o) => o && (o.discoveryName === name || o.name === name),
);
const p = match(g?.systemPlanets) ?? (g?.planet?.discoveryName === name ? g.planet : null);
if (p && p.class) return Economy.economicClass(p);
const st = match(g?.systemStations);
if (st) return 'station'; // both deepSpaceStation + waypoint shops are the 'station' class
const sysId = g?.systemRecord?.id;
const raw = g?.galaxy?.economy?.classBySystem?.get?.(sysId);
const classes = raw ? [...raw] : [];
return (classes.length ? classes[0] : 'rocky');
}
/**
* The console note — this scene's small cousin of GameScene.consoleToast:
* an accent glyph + console-caps line, top-center, fading in, holding,
@ -538,6 +595,7 @@ export class SurfaceScene extends Phaser.Scene {
state: this.gameScene.buildState,
planetName: this.planetName,
minerals: () => this.gameScene?.ship?.minerals ?? 0,
credits: () => this.gameScene?.ship?.credits ?? 0,
tetherLevel: () => this.gameScene?.tetherLevelFor(this.planetName) ?? 0,
researchUnlocked: (cat, node) =>
this.gameScene?.researchState?.isUnlocked(cat, node) ?? false,

View File

@ -164,6 +164,12 @@ export class BuildWindow extends Phaser.GameObjects.Container {
this.state = o.state ?? null;
this.planetName = o.planetName ?? '';
this.minerals = typeof o.minerals === 'function' ? o.minerals : () => 0;
this.credits = typeof o.credits === 'function' ? o.credits : () => 0;
// Affordability wallet: the build's cost is a { res: amount } map
// (data/builds.json → cost) — the planet builds pay MINERALS, the cargo
// bays pay CREDITS (the trade income). canAfford() checks every line
// against this wallet, so both currencies are covered.
this._wallet = () => ({ minerals: this.minerals(), credits: this.credits() });
this.tetherLevel = typeof o.tetherLevel === 'function' ? o.tetherLevel : () => 0;
this.researchUnlocked = typeof o.researchUnlocked === 'function' ? o.researchUnlocked : () => false;
this.onBuild = typeof o.onBuild === 'function' ? o.onBuild : null;
@ -831,7 +837,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
const g = row.g;
g.clear();
const st = rowState(row.def, this.ctxFor(row.id), this._activeOnPlanet(), row.id);
const affordable = canAfford(row.def, this.minerals());
const affordable = canAfford(row.def, this._wallet());
const w = row.w;
const h = row.h;
@ -1223,7 +1229,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
return 'busy';
}
if (def && isAvailable(def, this.ctxFor(sel.id))) {
return canAfford(def, this.minerals()) ? 'build' : 'unaffordable';
return canAfford(def, this._wallet()) ? 'build' : 'unaffordable';
}
return 'locked';
}
@ -1343,7 +1349,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
this.dCost.setText(`COST ◆ ${costStr} · PAID`);
this.dCost.setColor(toCss(C.faint));
} else {
const afford = canAfford(def, this.minerals());
const afford = canAfford(def, this._wallet());
this.dCost.setText(`COST ◆ ${costStr}${afford ? '' : ' · INSUFFICIENT MINERALS'}`);
this.dCost.setColor(toCss(afford ? C.neon : C.amber));
}

519
js/ui/shop/ShopView.js Normal file
View File

@ -0,0 +1,519 @@
/**
* ShopView — the buy/sell (and buyback) screen over the shop clip video.
*
* ┌─────────────────────────────────────────────────────────────┐
* │ [ Buy/Sell | Buyback ] ← tabs │
* │ │
* │ ┌SELL 4×4┐ ┌ NET ┐ ┌BUY 4×4┐ │
* │ │ bays │ │ +420 │ │ stock │ │
* │ │ (cargo)│ │ cr │ │ (12–16)│ │
* │ └────────┘ │[Conf]│ └────────┘ │
* │ │[Clear]│ │
* └─────────────────────────────────────────────────────────────┘
*
* The two sides are TradeGrids (js/ui/shop/TradeGrid.js):
* · SELL (left) — 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.
* · BUY (right) — the shop's stock, each with a −/qty/+ stepper.
* The live NET preview (green ≥ 0, red < 0) and Confirm/Clear live in the
* center; Confirm is disabled while the trade is invalid (can't go negative,
* hold would overflow). SELL→BUY happens atomically (sell to free room for a
* buy).
*
* BUYBACK (session-local only) — the second tab. After the player SELLs
* something, the goods they sold are listed here and can be bought BACK —
* priced at the sale price (a neutral undo, so sell→buyback can't loop into
* free credits). It disappears when the player leaves the shop (no
* persistence; data/shopui.json).
*
* This component owns the SESSION state (soldThisSession, the active tab, the
* selection). The PRICING + preview math is js/economy/Economy.js (fixed
* per-shop prices + tradePreview). The credits/cargo ledger is ship.credits
* + ship.cargo (CargoHold). Headless-testable: the logic (preview / confirm /
* clear / buyback) is plain data; the Phaser rendering is best-effort.
*/
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
import * as E from '../../economy/Economy.js';
import { TradeGrid } from './TradeGrid.js';
export class ShopView extends Phaser.GameObjects.Container {
/**
* @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { ship, galaxy, cls, systemId, gameScene, onLeave }
* ship — the Ship (credits + cargo).
* 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.
* onLeave — called by close() so the SurfaceScene can restore the
* surface loop (the deck's "Leave Shop" does this too).
*/
constructor(scene, o) {
super(scene, 0, 0);
this.scene = scene;
this.ship = o.ship;
this.galaxy = o.galaxy;
this.cls = o.cls;
this.systemId = o.systemId;
this.gameScene = o.gameScene;
this.onLeave = o.onLeave;
this.maxSlots = Math.max(1, Math.floor(Number(config.get('economy.cargo.maxSlots', 16)) || 16));
this.capacity = this.ship.cargo ? this.ship.cargo.capacity : 0;
// --- session state ---------------------------------------------------
this.tab = 'trade'; // 'trade' | 'buyback'
this.soldThisSession = []; // [{ id, name, qty, price }] (buyback source)
this.buybackSel = new Map(); // goodId -> qty to buy back
this.sellGrid = null;
this.buyGrid = null;
this._content = null; // the active content container
this._previewText = null;
this._confirmBtn = null;
this._clearBtn = null;
this._tabBuyback = null;
this._buildTabs();
this.enter();
// Present in the scene, above the shop clip (depth 10) + deck (50), below
// toasts. setScrollFactor(0) = screen-fixed (the SurfaceScene is a full
// overlay). Best-effort so a headless fake scene still constructs.
this.setScrollFactor?.(0);
this.setDepth?.(100);
this.scene.add?.existing?.(this);
}
// ---------------------------------------------------------------------
// ENTRY + TABS
// ---------------------------------------------------------------------
enter() {
this.setTab('trade');
return this;
}
isBuybackEnabled() {
return this.soldThisSession.some((r) => r.qty > 0);
}
setTab(t) {
if (t === 'buyback' && !this.isBuybackEnabled()) return this; // locked
this.tab = t;
this._destroyContent();
if (t === 'trade') this._showTrade();
else this._showBuyback();
this._refreshTabs();
return this;
}
_buildTabs() {
const tabs = config.get('shopui.tabs', {});
const W = this._W();
const y = tabs.y ?? 44;
const tw = tabs.w ?? 184, th = tabs.h ?? 46, gap = tabs.gap ?? 14;
const cx = W / 2;
const sellBtn = this._makeButton(tabs.buySell ?? 'Buy/Sell', tw, th, tabs.activeAccent ?? '#00e5ff');
sellBtn.setPosition(cx - gap / 2 - tw / 2, y);
this.add?.(sellBtn.container);
sellBtn.tap(() => this.setTab('trade'));
this._tabTrade = sellBtn;
const bbBtn = this._makeButton(tabs.buyback ?? 'Buyback', tw, th, tabs.activeAccent ?? '#00e5ff');
bbBtn.setPosition(cx + gap / 2 + tw / 2, y);
this.add?.(bbBtn.container);
bbBtn.tap(() => { if (this.isBuybackEnabled()) this.setTab('buyback'); });
this._tabBuyback = bbBtn;
}
_refreshTabs() {
const tabs = config.get('shopui.tabs', {});
const dim = tabs.disabledAlpha ?? 0.34;
const accent = tabs.activeAccent ?? '#00e5ff';
// active tab = bright, inactive = dimmer; buyback disabled = dim + no-op.
const tradeOn = this.tab === 'trade';
const bbOn = this.tab === 'buyback';
const bbEnabled = this.isBuybackEnabled();
this._tabTrade?.setAccent(accent);
this._tabTrade?.setAlpha(tradeOn ? 1 : 0.55);
this._tabBuyback?.setAccent(bbOn ? accent : '#7d92c4');
this._tabBuyback?.setAlpha(bbOn ? 1 : (bbEnabled ? 0.55 : dim));
}
// ---------------------------------------------------------------------
// CONTENT — the trade panel (SELL grid + BUY grid + preview + buttons)
// ---------------------------------------------------------------------
_showTrade() {
const grid = config.get('shopui.grid', {});
const centerW = grid.centerW ?? 208;
const W = this._W();
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),
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
});
const buyGrid = new TradeGrid(this.scene, {
side: 'buy', maxSlots: this.maxSlots,
cls: this.cls, systemId: this.systemId,
sellUnit: (id) => E.sellPrice(id, this.cls, this.systemId, this.galaxy),
buyUnit: (id) => E.buyPrice(id, this.cls, this.systemId, this.galaxy),
});
this.sellGrid = sellGrid;
this.buyGrid = buyGrid;
// Layout: [SELL grid] [center] [BUY grid], centered in the viewport.
const totalW = sellGrid.width + centerW + buyGrid.width;
const leftX = Math.max(8, (W - totalW) / 2);
const contentY = (config.get('shopui.tabs.y', 44) + (config.get('shopui.tabs.h', 46))) + 26;
sellGrid.setPosition(leftX, contentY);
const rightX = leftX + sellGrid.width + centerW;
buyGrid.setPosition(rightX, contentY);
const content = new Phaser.GameObjects.Container(this.scene, 0, 0);
content.add?.(sellGrid);
content.add?.(buyGrid);
// center column: NET preview + Confirm + Clear
const cx = leftX + sellGrid.width + centerW / 2;
const cy = contentY + sellGrid.height / 2;
this._previewText = this._makeText(cx, cy - 30, 'NET 0 cr', {
fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`,
color: config.get('shopui.preview.zeroColor' , '#7d92c4'), align: 'center',
});
this._previewLabel = this._makeText(cx, cy - 62, config.get('shopui.preview.label', 'NET'), {
fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`,
color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center',
});
content.add?.(this._previewLabel);
content.add?.(this._previewText);
const bw = config.get('shopui.buttons.w', 158), bh = config.get('shopui.buttons.h', 54);
this._confirmBtn = this._makeButton(config.get('shopui.buttons.confirmLabel', 'Confirm'), bw, bh, config.get('shopui.buttons.confirmAccent', '#7ce8a4'));
this._confirmBtn.setPosition(cx, cy + 34);
this._confirmBtn.tap(() => this.confirm());
this._confirmBtn.setAlpha(0);
content.add?.(this._confirmBtn.container);
this._clearBtn = this._makeButton(config.get('shopui.buttons.clearLabel', 'Clear'), bw, bh, config.get('shopui.buttons.clearAccent', '#7d92c4'));
this._clearBtn.setPosition(cx, cy + 104);
this._clearBtn.tap(() => this.clear());
this._clearBtn.setAlpha(0);
content.add?.(this._clearBtn.container);
this._content = content;
this.add?.(content);
// Data + live preview wiring.
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
buyGrid.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
sellGrid.onSelectionChange = () => this._syncPreview();
buyGrid.onSelectionChange = () => this._syncPreview();
this._syncPreview();
// Reveal: SELL slides in from the left, BUY from the right.
sellGrid.slideIn();
buyGrid.slideIn();
}
// ---------------------------------------------------------------------
// CONTENT — the buyback panel (goods sold this session, buy back)
// ---------------------------------------------------------------------
_showBuyback() {
const W = this._W();
const content = new Phaser.GameObjects.Container(this.scene, 0, 0);
const rows = this._bbRows();
const contentY = (config.get('shopui.tabs.y', 44) + (config.get('shopui.tabs.h', 46))) + 26;
const cx = W / 2;
const title = this._makeText(cx, contentY + 10, 'BUY BACK', {
fontSize: '21px', color: '#eaf6ff', align: 'center' });
content.add?.(title);
let y = contentY + 56;
const rowH = 56;
for (const r of rows) {
const line = this._makeText(cx - 120, y, `${r.name} · sold ${r.qty} @ ${Math.round(r.price)} cr`, {
fontSize: '15px', color: '#eaf6ff', align: 'left' });
content.add?.(line);
const minus = this._makeText(cx + 60, y, '−', { fontSize: '20px', color: '#7d92c4', align: 'center' });
const qty = this._makeText(cx + 110, y, String(r.sel), { fontSize: '20px', color: '#00e5ff', align: 'center' });
const plus = this._makeText(cx + 160, y, '+', { fontSize: '20px', color: '#7d92c4', align: 'center' });
for (const el of [line, minus, qty, plus]) content.add?.(el);
const hit = [-16, -14, 32, 28];
this._bindTap(minus, () => this.bumpBuyback(r.id, -1), hit);
this._bindTap(plus, () => this.bumpBuyback(r.id, +1), hit);
y += rowH;
}
const cy = y + 24;
this._previewLabel = this._makeText(cx, cy - 8, config.get('shopui.preview.label', 'NET'), {
fontSize: `${config.get('shopui.preview.labelFontSize', 13)}px`, color: config.get('shopui.preview.labelColor', '#7d92c4'), align: 'center' });
content.add?.(this._previewLabel);
this._previewText = this._makeText(cx, cy + 22, 'NET 0 cr', {
fontSize: `${config.get('shopui.preview.valueFontSize', 34)}px`, color: config.get('shopui.preview.zeroColor', '#7d92c4'), align: 'center' });
content.add?.(this._previewText);
const bw = config.get('shopui.buttons.w', 158), bh = config.get('shopui.buttons.h', 54);
this._confirmBtn = this._makeButton(config.get('shopui.buttons.confirmLabel', 'Confirm'), bw, bh, config.get('shopui.buttons.confirmAccent', '#7ce8a4'));
this._confirmBtn.setPosition(cx, cy + 92);
this._confirmBtn.tap(() => this.confirm());
this._confirmBtn.setAlpha(0);
content.add?.(this._confirmBtn.container);
this._content = content;
this.add?.(content);
this._syncPreview();
}
_bbRows() {
return this.soldThisSession.map((r) => ({
id: r.id, name: r.name ?? r.id, qty: r.qty, price: r.price,
sel: this.buybackSel.get(r.id) ?? 0,
}));
}
bumpBuyback(id, d = 1) {
const row = this.soldThisSession.find((r) => r.id === id);
if (!row) return this;
const cur = this.buybackSel.get(id) ?? 0;
const next = Math.max(0, Math.min(row.qty, cur + Math.floor(Number(d) || 0)));
if (next === 0) this.buybackSel.delete(id);
else this.buybackSel.set(id, next);
this._syncPreview();
return this;
}
getBuybackMap() {
const m = {};
for (const [id, q] of this.buybackSel) if (q > 0) m[id] = q;
return m;
}
// ---------------------------------------------------------------------
// PREVIEW + CONFIRM + CLEAR (the testable core)
// ---------------------------------------------------------------------
preview() {
const cargo = this.ship.cargo ? this.ship.cargo.snapshot() : [];
const capacity = this.capacity;
const credits = this.ship.credits;
if (this.tab === 'trade') {
return E.tradePreview({
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
cargo, capacity, credits,
sellMap: this.sellGrid ? this.sellGrid.getSellMap() : {},
buyMap: this.buyGrid ? this.buyGrid.getBuyMap() : {},
});
}
// buyback — priced at the sale price (buyOverrides), a neutral undo.
const buyMap = {};
const buyOverrides = {};
for (const r of this._bbRows()) {
if (r.sel > 0) { buyMap[r.id] = r.sel; buyOverrides[r.id] = r.price; }
}
return E.tradePreview({
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
cargo, capacity, credits,
sellMap: {}, buyMap, buyOverrides,
});
}
confirm() {
const pv = this.preview();
if (!pv.valid) {
this._setStatus(`CAN'T TRADE — ${pv.errors.join(', ')}`, true);
return false;
}
this._applyTrade(pv);
this._afterTrade(pv);
return true;
}
clear() {
if (this.tab === 'trade') {
this.sellGrid?.clearSelection();
this.buyGrid?.clearSelection();
} else {
this.buybackSel = new Map();
}
this._syncPreview();
return this;
}
_applyTrade(pv) {
const ship = this.ship;
if (pv.sellTotal > 0) ship.addCredits(pv.sellTotal);
if (pv.buyTotal > 0) ship.spendCredits(pv.buyTotal);
for (const s of pv.sell) ship.cargo.remove(s.id, s.qty); // free bays first
for (const b of pv.buy) ship.cargo.add(Array(b.qty).fill(b.id));
}
_afterTrade(pv) {
if (this.tab === 'trade') {
for (const s of pv.sell) this._addSold(s.id, s.qty, s.unit, s.name);
this.sellGrid?.clearSelection();
this.buyGrid?.clearSelection();
} else {
for (const b of pv.buy) this._reduceSold(b.id, b.qty);
this.buybackSel = new Map();
}
this.sellGrid?.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
this.refreshHud();
this._syncPreview();
this._refreshTabs();
this._setStatus(`CONFIRMED — ${pv.net >= 0 ? '+' : ''}${Math.round(pv.net)} cr → hold ${pv.cargoAfter}/${this.capacity}`, false);
}
_addSold(id, qty, price, name) {
let row = this.soldThisSession.find((r) => r.id === id);
if (!row) { row = { id, name: name ?? id, qty: 0, price: price ?? 0 }; this.soldThisSession.push(row); }
row.qty += qty;
if (price) row.price = price;
}
_reduceSold(id, qty) {
const row = this.soldThisSession.find((r) => r.id === id);
if (!row) return;
row.qty -= qty;
if (row.qty <= 0) this.soldThisSession = this.soldThisSession.filter((r) => r.id !== id);
}
refreshHud() {
this.gameScene?.refreshCreditsHud?.();
}
// ---------------------------------------------------------------------
// PREVIEW SYNC (live NET + Confirm/Clear visibility)
// ---------------------------------------------------------------------
_syncPreview() {
const pv = this.preview();
const anySel = this._hasAnySelection();
if (this._previewText && typeof this._previewText.setText === 'function') {
const sign = pv.net >= 0 ? '+' : '';
this._previewText.setText(`NET ${sign}${Math.round(pv.net)} cr`);
const col = pv.net === 0
? config.get('shopui.preview.zeroColor', '#7d92c4')
: (pv.net > 0 ? config.get('shopui.preview.posColor', '#7ce8a4') : config.get('shopui.preview.negColor', '#ff2d6f'));
if (typeof this._previewText.setColor === 'function') this._previewText.setColor(col);
}
const confirmOn = anySel;
const confirmEnabled = anySel && pv.valid;
this._confirmBtn?.setAlpha(confirmOn ? 1 : 0);
this._confirmBtn?.setAccent(confirmEnabled
? config.get('shopui.buttons.confirmAccent', '#7ce8a4')
: config.get('shopui.buttons.disabled', '#7d92c4'));
this._clearBtn?.setAlpha(confirmOn ? 1 : 0);
}
_hasAnySelection() {
if (this.tab === 'trade') return (this.sellGrid?.hasSelection() ?? false) || (this.buyGrid?.hasSelection() ?? false);
return [...this.buybackSel.values()].some((q) => q > 0);
}
_setStatus(msg, bad) {
// Best-effort status line (headless no-ops). The credits HUD count-up is
// the primary feedback; this is the in-panel confirmation.
if (this._statusText && typeof this._statusText.setText === 'function') {
this._statusText.setText(msg);
if (typeof this._statusText.setColor === 'function')
this._statusText.setColor(bad ? config.get('shopui.preview.negColor', '#ff2d6f') : config.get('shopui.preview.posColor', '#7ce8a4'));
}
}
// ---------------------------------------------------------------------
// LIFECYCLE
// ---------------------------------------------------------------------
close() {
this._destroyContent();
this.destroy();
if (typeof this.onLeave === 'function') this.onLeave();
return this;
}
_destroyContent() {
if (this._content) {
this._content.destroy?.();
this._content = null;
}
this.sellGrid?.destroy?.();
this.buyGrid?.destroy?.();
this.sellGrid = null;
this.buyGrid = null;
this._previewText = null;
this._previewLabel = null;
this._confirmBtn = null;
this._clearBtn = null;
}
destroy() {
this._destroyContent();
this._tabTrade?.destroy?.();
this._tabBuyback?.destroy?.();
if (typeof super.destroy === 'function') super.destroy();
}
// ---------------------------------------------------------------------
// SMALL HELPERS (Phaser-agnostic; the headless fake provides add.text/
// graphics and the stub Container; real Phaser provides the rest)
// ---------------------------------------------------------------------
_W() {
return this.scene.scale?.width ?? 1280;
}
_makeText(x, y, str, style = {}) {
// Owned by whichever container the caller adds it to (or this) — real
// Phaser tears the whole tree down on destroy(); the headless stub doesn't
// recurse, but the tests build a fresh scene each time so that's fine.
const t = this.scene.add?.text?.(x, y, str, { align: 'center', ...style });
if (t) t.setOrigin?.(0.5, 0.5);
return t;
}
_makeButton(label, w, h, accent) {
const g = this.scene.add?.graphics?.();
const t = this.scene.add?.text?.(0, 0, label, { fontSize: '18px', color: '#eaf6ff', align: 'center' });
const btn = new Phaser.GameObjects.Container(this.scene, 0, 0);
if (g) { this._drawBtn(g, w, h, accent); btn.add?.(g); }
if (t) { t.setOrigin?.(0.5, 0.5); btn.add?.(t); }
const api = {
container: btn,
setPosition: (x, y) => { btn.setPosition?.(x, y); },
setAlpha: (a) => { btn.setAlpha?.(a); },
setAccent: (color) => { this._drawBtn(g, w, h, color); },
tap: (fn) => { this._bindTap(g ?? t, fn, [-w / 2, -h / 2, w, h]); },
destroy: () => { g?.destroy?.(); t?.destroy?.(); btn.destroy?.(); },
};
return api;
}
_drawBtn(g, w, h, accent) {
if (!g || typeof g.clear !== 'function') return;
g.clear?.();
if (typeof g.fillStyle === 'function') {
const a = _hex(accent ?? '#00e5ff');
g.fillStyle(a, 0.16);
g.lineStyle?.(1, a, 1);
}
g.fillRect?.(-w / 2, -h / 2, w, h);
}
_bindTap(obj, fn, hitRect) {
if (!obj || typeof obj.setInteractive !== 'function' || typeof obj.on !== 'function') return;
// Phaser v4 (Giedi): a bare setInteractive() leaves no hitAreaCallback and
// crashes the input (MenuButton sets an explicit hitArea + callback for this).
const opts = { useHandCursor: true };
const Rect = Phaser.Geom?.Rectangle;
if (Rect && hitRect) {
const area = new Rect(hitRect[0], hitRect[1], hitRect[2], hitRect[3]);
opts.hitArea = area;
opts.hitAreaCallback = (p, px, py) => Rect.Contains(p, px, py);
}
obj.setInteractive(opts).on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); });
}
}
function _hex(v) {
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
return m ? parseInt(m[1], 16) : 0xffffff;
}

453
js/ui/shop/TradeGrid.js Normal file
View File

@ -0,0 +1,453 @@
/**
* TradeGrid — ONE side of the shop trade panel: a 4×4 grid of goods cells.
*
* Two modes, one component:
* · SELL (the left panel) — the grid is the player's CARGO. Each cell is a
* BAY (one good each, data/economy.json → cargo). The first `capacity`
* cells are live bays (filled with a good, or empty); the rest are LOCKED
* (the hold's upgrade room — the ship can't carry there yet). Tapping a
* filled bay checks it off to sell (a bay = 1 unit); the sell quantity for
* a good is just the number of its bays that are checked.
* · BUY (the right panel) — the grid is the SHOP'S STOCK (12–16 distinct
* goods from Economy.shopFor). Each cell has a −/qty/+ stepper; the buy
* quantity is whatever the stepper says.
*
* The PRICING lives in js/economy/Economy.js (buyPrice/sellPrice — the fixed
* per-(good, shop) price). This component only shows the price and tracks the
* SELECTION (which bays to sell / how many of each to buy). The ShopView
* (js/ui/shop/ShopView.js) composes a SellGrid + BuyGrid, runs the trade
* preview (Economy.tradePreview) and applies it to the ship.
*
* Headless-safe: the Phaser rendering is best-effort (guarded), and the
* selection LOGIC (toggleBay / bumpBuy / getSellMap / getBuyMap) is plain data
* so dev/shop-ui.test.mjs can drive it with no scene.
*/
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
import { GOODS_TEXTURE_KEY, goodFrame } from '../../economy/Economy.js';
export class TradeGrid extends Phaser.GameObjects.Container {
/**
* @param {object} scene — the SurfaceScene (or a headless fake).
* @param {object} o — { side: 'sell'|'buy', maxSlots, capacity,
* cls, systemId, galaxy, sellUnit(id), buyUnit(id) }
* sellUnit/buyUnit are the per-unit price lookups (from the ShopView) so
* the grid can show "sell for N" / "buy for N" without re-deriving.
*/
constructor(scene, o) {
super(scene, 0, 0);
this.scene = scene;
this.side = o.side;
this.maxSlots = Math.max(1, Math.floor(o.maxSlots ?? 16));
this.capacity = Math.max(0, Math.floor(o.capacity ?? 0));
this.cls = o.cls;
this.systemId = o.systemId;
this.sellUnit = (id) => o.sellUnit?.(id) ?? 0;
this.buyUnit = (id) => o.buyUnit?.(id) ?? 0;
const g = config.get('shopui.grid', {});
this.cols = g.cols ?? 4;
this.rows = g.rows ?? 4;
this.slot = g.slot ?? 96;
this.gap = g.gap ?? 10;
const pad = config.get('shopui.panel.pad', 16);
this.pad = pad;
this.titleH = config.get('shopui.panel.title.h', 34);
const sl = config.get('shopui.slot', {});
this.sl = sl;
this.cellW = this.cols * this.slot + (this.cols - 1) * this.gap;
this.cellH = this.rows * this.slot + (this.rows - 1) * this.gap;
this.width = this.cellW + pad * 2;
this.height = this.titleH + this.cellH + pad * 2;
// --- state -----------------------------------------------------------
// SELL: bays in fill order (goodId | null), length = maxSlots; usable =
// index < capacity. BUY: stock entries { id, name, price }, length =
// stock size (≤ maxSlots).
this.bays = [];
this.stock = [];
this.sellSel = new Set(); // sell: checked bay indices
this.buyQty = new Map(); // buy: goodId -> qty
// --- objects ---------------------------------------------------------
this._objs = []; // everything we create (destroy())
this.plate = null; // the semi-transparent panel background
this.title = null;
this.cells = []; // per cell: { plate, sel, icon, name, price, stepQty, stepMinus, stepPlus }
this._buildPanel();
this._buildTitle();
this._buildCells();
}
// --- geometry ---------------------------------------------------------
_cellXY(i) {
const c = i % this.cols;
const r = Math.floor(i / this.cols);
const x = this.pad + c * (this.slot + this.gap) + this.slot / 2;
const y = this.titleH + this.pad + r * (this.slot + this.gap) + this.slot / 2;
return { x, y };
}
_buildPanel() {
const p = config.get('shopui.panel', {});
const gr = this.scene.add?.graphics?.();
if (gr) {
this.plate = gr;
this._objs.push(gr);
this._drawPlate();
}
this.plateStyle = p;
}
_drawPlate() {
const gr = this.plate;
if (!gr || typeof gr.clear !== 'function') return;
const p = this.plateStyle ?? {};
const a = this.side === 'sell' ? (p.bgAlpha ?? 0.7) : (p.bgAlpha ?? 0.7);
gr.clear?.();
if (typeof gr.fillStyle === 'function') {
gr.fillStyle(_hex(p.bgColor ?? '#0a1120'), a);
gr.lineStyle?.(1, _hex(p.border ?? '#22405f'), Math.floor(a * 255));
}
gr.fillRect?.(0, 0, this.width, this.height);
}
_buildTitle() {
const t = this.scene.add?.text?.(
this.width / 2, 8,
this.side === 'sell' ? 'SELL' : 'BUY',
{ fontSize: `${config.get('shopui.panel.title.fontSize', 21)}px`, color: config.get('shopui.panel.title.color', '#eaf6ff'), align: 'center' },
);
if (t) {
t.setOrigin?.(0.5, 0);
this.title = t;
this._objs.push(t);
}
}
_buildCells() {
for (let i = 0; i < this.cols * this.rows; i++) {
const { x, y } = this._cellXY(i);
const cell = { index: i, objects: [] };
// base plate
const plate = this.scene.add?.graphics?.();
if (plate) { cell.plate = plate; cell.objects.push(plate); }
// selection ring (sell) — cyan highlight when the bay is checked
if (this.side === 'sell') {
const sel = this.scene.add?.graphics?.();
if (sel) { cell.sel = sel; cell.objects.push(sel); }
}
// icon (guarded — headless has no texture)
if (this.scene.textures && typeof this.scene.textures.exists === 'function' &&
this.scene.textures.exists(GOODS_TEXTURE_KEY)) {
const icon = this.scene.add?.image?.(0, 0, GOODS_TEXTURE_KEY, 0);
if (icon) { icon.setOrigin?.(0.5, 0.5); icon.setAlpha?.(0); cell.icon = icon; cell.objects.push(icon); }
}
// name
const name = this.scene.add?.text?.(x, y - 4, '', {
fontSize: `${this.sl.name?.fontSize ?? 11}px`, color: this.sl.name?.color ?? '#eaf6ff',
align: 'center', wordWrap: { width: this.slot - 8 },
});
if (name) { name.setOrigin?.(0.5, 0.5); name.setAlpha?.(0); cell.name = name; cell.objects.push(name); }
// price (sell price green / buy price gold)
const price = this.scene.add?.text?.(x, y + (this.side === 'buy' ? 16 : 20), '', {
fontSize: `${this.sl.price?.fontSize ?? 12}px`,
color: this.side === 'sell' ? (this.sl.price?.sell ?? '#7ce8a4') : (this.sl.price?.buy ?? '#ffc94d'),
align: 'center',
});
if (price) { price.setOrigin?.(0.5, 0.5); price.setAlpha?.(0); cell.price = price; cell.objects.push(price); }
// BUY stepper: − n + (bottom of the cell)
if (this.side === 'buy') {
const mk = (dx, str) => {
const el = this.scene.add?.text?.(x + dx, y + 26, str, {
fontSize: `${config.get('shopui.stepper.fontSize', 19)}px`,
color: this.sl.price?.buy ?? '#ffc94d', align: 'center',
});
if (el) { el.setOrigin?.(0.5, 0.5); return el; }
return null;
};
const minus = mk(-26, '−');
const qty = mk(0, '0');
const plus = mk(26, '+');
if (minus) cell.objects.push(minus);
if (qty) { cell.stepQty = qty; cell.objects.push(qty); }
if (plus) cell.objects.push(plus);
cell.stepMinus = minus; cell.stepPlus = plus;
const hit = [-18, -14, 36, 28];
this._bindTap(minus, () => this.bumpBuy(this._stockIdAt(i), -1), hit);
this._bindTap(plus, () => this.bumpBuy(this._stockIdAt(i), +1), hit);
this._bindTap(qty, () => this.bumpBuy(this._stockIdAt(i), +1), hit);
}
// SELL: the whole cell is a tappable bay
if (this.side === 'sell') {
const tap = (cell.plate ?? cell.name) || null;
this._bindTap(tap, () => this.toggleBay(i), [x - this.slot / 2, y - this.slot / 2, this.slot, this.slot]);
}
this.cells.push(cell);
this.add?.(cell.plate);
if (cell.sel) this.add?.(cell.sel);
if (cell.icon) this.add?.(cell.icon);
if (cell.name) this.add?.(cell.name);
if (cell.price) this.add?.(cell.price);
if (cell.stepMinus) this.add?.(cell.stepMinus);
if (cell.stepQty) this.add?.(cell.stepQty);
if (cell.stepPlus) this.add?.(cell.stepPlus);
}
}
// A tap only works when the object supports interactivity (real Phaser); a
// headless fake returns no-op objects, and the test drives the logic
// methods (toggleBay / bumpBuy) directly.
_bindTap(obj, fn, hitRect) {
if (!obj || typeof obj.setInteractive !== 'function' || typeof obj.on !== 'function') return;
// Phaser v4 (Giedi): a bare setInteractive() leaves no hitAreaCallback and
// crashes the input (MenuButton sets an explicit hitArea + callback for this).
const opts = { useHandCursor: true };
const Rect = Phaser.Geom?.Rectangle;
if (Rect && hitRect) {
const area = new Rect(hitRect[0], hitRect[1], hitRect[2], hitRect[3]);
opts.hitArea = area;
opts.hitAreaCallback = (p, px, py) => Rect.Contains(p, px, py);
}
obj.setInteractive(opts).on('pointerdown', (p) => { p?.stopPropagation?.(); fn(); });
}
// --- DATA (the testable core) -----------------------------------------
/**
* Set the grid's contents. SELL: `cargo` is the filled-bay id list (order
* preserved — bay i holds cargo[i]). BUY: `stock` is the shop's stock list
* ({ id, name, price } | { id }). Resets the selection (a fresh shop view).
*/
refresh(o = {}) {
if (this.side === 'sell') {
const cargo = Array.isArray(o.cargo) ? o.cargo : [];
this.bays = [];
for (let i = 0; i < this.maxSlots; i++) this.bays.push(i < cargo.length ? cargo[i] : null);
this.sellSel = new Set();
} else {
const stock = Array.isArray(o.stock) ? o.stock : [];
this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price }));
this.buyQty = new Map();
}
this._renderAll();
return this;
}
_stockIdAt(i) {
return this.stock[i]?.id ?? null;
}
_bayIdAt(i) {
return this.bays[i] ?? null;
}
/** SELL: check / uncheck a bay (1 unit of its good). No-op if empty/locked. */
toggleBay(i) {
if (this.side !== 'sell') return this;
if (i < 0 || i >= this.capacity) return this; // locked bay
const id = this._bayIdAt(i);
if (!id) return this; // empty bay
if (this.sellSel.has(i)) this.sellSel.delete(i);
else this.sellSel.add(i);
this._renderCell(i);
this._emitSelChange();
return this;
}
/** SELL: check / uncheck by good id (the Nth unchecked/checked bay of it). */
toggleGood(id) {
if (this.side !== 'sell') return this;
const idx = this.bays.findIndex((b, i) => b === id && i < this.capacity);
if (idx >= 0) this.toggleBay(idx);
else {
// already all checked → uncheck one
const idx2 = [...this.sellSel].find((i) => this.bays[i] === id);
if (idx2 !== undefined) this.toggleBay(idx2);
}
return this;
}
/** BUY: adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
bumpBuy(id, d = 1) {
if (this.side !== 'buy' || !id) return this;
if (!this.stock.some((s) => s.id === id)) return this; // not in stock
const cur = this.buyQty.get(id) ?? 0;
const next = Math.max(0, cur + Math.floor(Number(d) || 0));
if (next === 0) this.buyQty.delete(id);
else this.buyQty.set(id, next);
this._renderStockRow(id);
this._emitSelChange();
return this;
}
/** SELL: { goodId: qty } from the checked bays. */
getSellMap() {
const m = {};
for (const i of this.sellSel) {
const id = this._bayIdAt(i);
if (id) m[id] = (m[id] ?? 0) + 1;
}
return m;
}
/** BUY: { goodId: qty } from the steppers. */
getBuyMap() {
const m = {};
for (const [id, q] of this.buyQty) if (q > 0) m[id] = q;
return m;
}
hasSelection() {
return this.side === 'sell' ? this.sellSel.size > 0 : this.buyQty.size > 0;
}
clearSelection() {
if (this.side === 'sell') this.sellSel = new Set();
else this.buyQty = new Map();
this._renderAll();
this._emitSelChange();
return this;
}
/** The ShopView hooks this to live-update the preview + Confirm/Clear. */
set onSelectionChange(fn) { this._onSel = fn; }
_emitSelChange() {
if (typeof this._onSel === 'function') this._onSel(this);
}
// --- RENDERING (best-effort; the logic above is the source of truth) ---
_renderAll() {
for (let i = 0; i < this.cells.length; i++) this._renderCell(i);
}
_renderCell(i) {
const cell = this.cells[i];
if (!cell) return;
const { x, y } = this._cellXY(i);
const sl = this.sl;
let goodId = null;
let locked = false;
let unitPrice = 0;
if (this.side === 'sell') {
goodId = this._bayIdAt(i);
locked = i >= this.capacity;
unitPrice = goodId ? this.sellUnit(goodId) : 0;
} else {
goodId = this._stockIdAt(i);
locked = false;
unitPrice = goodId ? this.buyUnit(goodId) : 0;
}
const usable = !locked && !!goodId;
// plate border (selected sell bays get the cyan ring)
if (cell.plate && typeof cell.plate.clear === 'function') {
const selected = this.side === 'sell' && this.sellSel.has(i);
cell.plate.clear?.();
if (typeof cell.plate.fillStyle === 'function') {
cell.plate.fillStyle(_hex(sl.bgColor ?? '#0e1728'), usable ? 0.92 : (sl.lockAlpha ?? 0.3));
cell.plate.lineStyle?.(selected ? 2 : 1, _hex(selected ? (sl.selected ?? '#00e5ff') : (sl.border ?? '#1d2e4c')), 255);
}
cell.plate.fillRect?.(x - this.slot / 2, y - this.slot / 2, this.slot, this.slot);
}
// icon
if (cell.icon) {
const show = usable;
if (show) {
cell.icon.setTexture?.(GOODS_TEXTURE_KEY, goodFrame(goodId));
cell.icon.setAlpha?.(1);
cell.icon.setScale?.(this.sl.iconScale ?? 0.58);
cell.icon.setPosition?.(x, y - 12);
} else {
cell.icon.setAlpha?.(0);
}
}
// name
if (cell.name && typeof cell.name.setText === 'function') {
const label = usable ? (this._goodName(goodId) || goodId)
: (this.side === 'sell' ? (locked ? (sl.lockLabel ?? 'locked') : (sl.emptyLabel ?? '—'))
: (sl.emptyLabel ?? '—'));
cell.name.setText(label);
cell.name.setAlpha?.(usable ? 1 : (sl.lockAlpha ?? 0.3));
}
// price
if (cell.price && typeof cell.price.setText === 'function') {
cell.price.setText(usable ? `${Math.round(unitPrice)} cr` : '');
cell.price.setAlpha?.(usable ? 1 : 0);
}
// buy stepper
if (this.side === 'buy' && cell.stepQty) {
const q = goodId ? (this.buyQty.get(goodId) ?? 0) : 0;
if (typeof cell.stepQty.setText === 'function') cell.stepQty.setText(String(q));
const on = usable;
for (const el of [cell.stepMinus, cell.stepQty, cell.stepPlus]) el?.setAlpha?.(on ? (el === cell.stepQty ? 1 : 0.8) : 0);
}
}
_renderStockRow(id) {
for (let i = 0; i < this.cells.length; i++) if (this._stockIdAt(i) === id) this._renderCell(i);
}
_goodName(id) {
if (this.side === 'buy') return this.stock.find((s) => s.id === id)?.name ?? id;
const list = config.get('goods.goods', []);
return list.find((g) => g.id === id)?.name ?? id;
}
// --- ANIMATION + LIFECYCLE -------------------------------------------
/** Slide the whole grid in from its side (sell: left, buy: right). */
/**
* Reveal the panel: SELL slides in from the left, BUY from the right.
* Both settle at their LAYOUT position (set earlier via setPosition) — the
* tween target is that position, not the origin (tweening to x:0 stacked
* both panels at the left edge, hiding the cargo/sell panel behind the buy).
*/
slideIn() {
const ms = config.get('shopui.animation.slideMs', 340);
const ease = config.get('shopui.animation.ease', 'Cubic.Out');
const off = this.width + 24;
const targetX = this.x; // the final x (set by ShopView.setPosition before this)
const targetY = this.y;
const from = this.side === 'sell' ? targetX - off : targetX + off;
if (this.scene.tweens && typeof this.scene.tweens.add === 'function') {
this.x = from;
this.y = targetY;
this.scene.tweens.add({ targets: this, x: targetX, y: targetY, duration: ms, ease, delay: this.side === 'buy' ? 40 : 0 });
} else {
this.x = targetX;
}
return this;
}
destroy() {
for (const o of this._objs) o?.destroy?.();
for (const c of this.cells) for (const o of c.objects) o?.destroy?.();
this._objs = [];
this.cells = [];
if (typeof super.destroy === 'function') super.destroy();
}
}
// '#rrggbb' → int (Phaser color) — safe on hex + '#'-prefixed.
function _hex(v) {
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
return m ? parseInt(m[1], 16) : 0xffffff;
}