diff --git a/assets/images/goods.png b/assets/images/goods.png index 8dfbd3c..29a3912 100644 Binary files a/assets/images/goods.png and b/assets/images/goods.png differ diff --git a/assets/images/goods.psd b/assets/images/goods.psd index 0f78bf1..420656d 100644 Binary files a/assets/images/goods.psd and b/assets/images/goods.psd differ diff --git a/data/builds.json b/data/builds.json index 8776686..e3771de 100644 --- a/data/builds.json +++ b/data/builds.json @@ -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" + } } }, diff --git a/data/economy.json b/data/economy.json index 4829cb4..7cac398 100644 --- a/data/economy.json +++ b/data/economy.json @@ -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": { diff --git a/data/manifest.json b/data/manifest.json index 92704a2..c634a39 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -32,6 +32,7 @@ "quests.json", "comms.json", "goods.json", - "economy.json" + "economy.json", + "shopui.json" ] } diff --git a/data/ship.json b/data/ship.json index 6683b22..21d0f4c 100644 --- a/data/ship.json +++ b/data/ship.json @@ -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, diff --git a/data/shopui.json b/data/shopui.json new file mode 100644 index 0000000..ea2d9ab --- /dev/null +++ b/data/shopui.json @@ -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" + } +} diff --git a/dev/builds.test.mjs b/dev/builds.test.mjs index 78c7df9..1aaf778 100644 --- a/dev/builds.test.mjs +++ b/dev/builds.test.mjs @@ -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); diff --git a/dev/cargo.test.mjs b/dev/cargo.test.mjs new file mode 100644 index 0000000..e55640d --- /dev/null +++ b/dev/cargo.test.mjs @@ -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'); diff --git a/dev/phaser-stub.mjs b/dev/phaser-stub.mjs index be7a1d5..a4cb532 100644 --- a/dev/phaser-stub.mjs +++ b/dev/phaser-stub.mjs @@ -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 }, diff --git a/dev/ship-behavior.test.mjs b/dev/ship-behavior.test.mjs index f9cba01..6a185fd 100644 --- a/dev/ship-behavior.test.mjs +++ b/dev/ship-behavior.test.mjs @@ -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); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs new file mode 100644 index 0000000..8b1f5f0 --- /dev/null +++ b/dev/shop-ui.test.mjs @@ -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'); diff --git a/docs/ECONOMY.md b/docs/ECONOMY.md index f2f593a..388718a 100644 --- a/docs/ECONOMY.md +++ b/docs/ECONOMY.md @@ -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). diff --git a/js/economy/CargoHold.js b/js/economy/CargoHold.js new file mode 100644 index 0000000..adc5dbc --- /dev/null +++ b/js/economy/CargoHold.js @@ -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()); + } +} diff --git a/js/economy/Economy.js b/js/economy/Economy.js index 5fb2fc3..92874d8 100644 --- a/js/economy/Economy.js +++ b/js/economy/Economy.js @@ -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) // --------------------------------------------------------------------------- diff --git a/js/entities/Ship.js b/js/entities/Ship.js index aba5a21..f8af1b1 100644 --- a/js/entities/Ship.js +++ b/js/entities/Ship.js @@ -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 }; diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 3b76a94..316f331 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -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(), diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index f479857..510a600 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 5c4b848..63c1517 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -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, diff --git a/js/ui/BuildWindow.js b/js/ui/BuildWindow.js index 0a663d3..3d458ba 100644 --- a/js/ui/BuildWindow.js +++ b/js/ui/BuildWindow.js @@ -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)); } diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js new file mode 100644 index 0000000..eeebcaa --- /dev/null +++ b/js/ui/shop/ShopView.js @@ -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; +} diff --git a/js/ui/shop/TradeGrid.js b/js/ui/shop/TradeGrid.js new file mode 100644 index 0000000..1a867a2 --- /dev/null +++ b/js/ui/shop/TradeGrid.js @@ -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; +}