Add shop buy/sell UI, cargo hold, and cargo bay upgrades
- Introduce CargoHold (pure class) as the ship's trade-goods carrier: fixed bays, one good each, capacity = unlocked bays, save/restore round-trip. Ship.cargo + setCargoHold/cargoRoom wired into stats. - Add three credit-costed cargo builds (cargo-bay-8/12/16) that expand the hold 4→8→12→16 (MAX wins). BuildWindow affordability now covers both minerals and credits; GameScene.beginBuild debits credit lines and _applyCargoUpgrade expands the hold on completion. - Add tradePreview/resultingCargo to Economy.js as the shop's pure calculator: sell/buy pricing, net credits, the two caps (can't go negative, end hold fits capacity), and atomic sell-to-free-room-for-buy. - New ShopView + TradeGrid compose the buy/sell screen over the shop clip: SELL grid of cargo bays (tap to check), BUY grid with quantity steppers, live NET preview, Confirm/Clear, and a session-local Buyback tab (neutral undo at the sale price, locked until a sale). Wired into SurfaceScene's SHOP button. - data/shopui.json drives the shop layout/animation knobs; economy.json gains cargo.maxSlots (16-slot grid); ship.json base cargoHold drops to 4 unlocked bays. - Headless tests: dev/cargo.test.mjs (hold rules + trade preview caps), dev/shop-ui.test.mjs (construct, sell, buy, caps, buyback, clear), plus updated builds/ship-behavior suites; phaser-stub gains Geom.Rectangle for the hitArea path.
This commit is contained in:
parent
5f54cc327e
commit
1b4cba9f4f
Binary file not shown.
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"quests.json",
|
||||
"comms.json",
|
||||
"goods.json",
|
||||
"economy.json"
|
||||
"economy.json",
|
||||
"shopui.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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue