243 lines
8.6 KiB
JavaScript
243 lines
8.6 KiB
JavaScript
/**
|
||
* 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());
|
||
}
|
||
}
|