orbit/js/economy/CargoHold.js

243 lines
8.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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());
}
}