/** * 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). * * COST RECORD: alongside the ids the hold keeps a PARALLEL array of purchase * costs — the credits/unit the player ACTUALLY paid when each bay was bought * (js/ui/shop/ShopView.js → _applyTrade stamps the trade's unit price). It is * the per-bay cost BASIS (what the ship console's manifest compares the exit * sell prices against), not a price engine: sell/buy PRICING still lives in * js/economy/Economy.js. A bay with no recorded cost (the quest package's * handoff, a save predating the field) reads null — "no cost on file". */ 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 = []; // PARALLEL to items (index-aligned, same length): the purchase cost of // each bay in credits/unit — a non-negative integer, or null when the // cost isn't on file (the quest package's handoff; a save that predates // the field). Every mutation below keeps the two arrays aligned. this.costs = []; } /** A bay's cost: a non-negative integer (credits/unit) or null (unknown). * null in → null out — "no cost on file" is a RECORDED absence, not a 0 * price (Number(null) === 0 would blur the two). */ static normalizeCost(v) { if (v == null) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null; } /** 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(); this.costs.pop(); // the shed bay's cost goes with it } } /** 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. `unitCost` (optional) is the * credits/unit the caller PAID for these units (the shop's trade price, * the buyback's sale price) and is stamped onto every bay added — the * per-bay cost the ship console's manifest prices its exits against. Omit * it (or pass null) for a bay with no cost on file (the quest package). * Returns the number actually added. */ add(goodIds, unitCost = null) { const ids = Array.isArray(goodIds) ? goodIds : [goodIds]; const cost = CargoHold.normalizeCost(unitCost); let added = 0; for (const id of ids) { if (this.room <= 0) break; if (typeof id !== 'string' || !id) continue; this.items.push(id); this.costs.push(cost); 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); this.costs.splice(i, 1); // the sold bay's cost leaves with it i--; removed++; } } return removed; } /** * Shed the first bay that is NOT `exceptId` (the quest package's handoff * makes room — the package outranks trade). The bay's cost leaves with it. * Returns the shed good's id, or null when there is nothing else aboard. */ dropFirstNot(exceptId) { const i = this.items.findIndex((x) => x !== exceptId); if (i < 0) return null; const id = this.items[i]; this.items.splice(i, 1); this.costs.splice(i, 1); return id; } /** Empty the hold entirely. */ clear() { this.items.length = 0; this.costs.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). `costs` (optional) is the * parallel purchase-cost array — missing/short entries read null (a save * predating the field loads with no costs on file). Returns the number kept. */ set(items, costs = null) { const clean = (Array.isArray(items) ? items : []).filter( (id) => typeof id === 'string' && id, ); const src = Array.isArray(costs) ? costs : clean.map(() => null); this.items = clean.slice(0, this._capacity); this.costs = this.items.map((_id, i) => CargoHold.normalizeCost(src[i])); return this.items.length; } /** A copy of the filled-bay id list (safe to iterate / spread). */ snapshot() { return [...this.items]; } /** The purchase cost (credits/unit) recorded for bay `i` — null when the * bay is empty or its cost isn't on file. */ costAt(i) { const c = this.costs[i]; return c != null && Number.isFinite(c) ? c : null; } /** * The purchase costs of the FIRST `n` bays of `goodId` (fill order — the * same bays `remove(goodId, n)` would take), each a non-negative integer * or null (no cost on file). The sale's PROFIT BASIS: the shop's sell * price minus each bay's recorded cost is what that unit earned above * what the player paid for it (js/ui/shop/ShopView.js → the 'trade' XP). * Asking for more than are aboard returns however many exist (0 … n). * Read-only — it never mutates the hold. */ costsFor(goodId, n) { const k = Math.max(0, Math.floor(Number(n) || 0)); if (k === 0) return []; const out = []; for (let i = 0; i < this.items.length && out.length < k; i++) { if (this.items[i] === goodId) { const c = this.costs[i]; out.push(c != null && Number.isFinite(c) ? c : null); } } return out; } /** The bays in fill order, each with its recorded cost (or null). */ list() { return this.items.map((id, i) => ({ id, cost: this.costAt(i) })); } /** The credits PAID for the bays aboard (null-cost bays count as 0). */ totalCost() { return this.costs.reduce((s, c) => s + (c != null && Number.isFinite(c) ? c : 0), 0); } /** How many bays aboard carry a recorded cost. */ get costBays() { return this.costs.filter((c) => c != null && Number.isFinite(c)).length; } /** Serialize (SaveData.js → captureState → record.ship.cargo/cargoCosts). */ toJSON() { return { capacity: this._capacity, items: [...this.items], costs: [...this.costs] }; } /** * Deserialize (GameScene.applyRestore). Missing fields degrade to an empty * hold (a save predating cargo loads as an empty one) and missing costs to * null (a save predating the cost record loads with no costs on file). */ static fromJSON(o) { const h = new CargoHold(o?.capacity ?? 0); h.set(o?.items ?? [], o?.costs ?? null); return h; } /** A deep copy (tests, previews). */ clone() { return CargoHold.fromJSON(this.toJSON()); } }