orbit/dev/cargo.test.mjs

427 lines
23 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.

/**
* 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 — plus the
* per-bay PURCHASE-COST ledger (parallel to the items: the credits/unit
* actually paid at the shop, or null where a bay has no cost on file),
* kept aligned by every mutation.
* - 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']);
}
// --- the PURCHASE-COST ledger (parallel to items) ------------------------
{
const h = new CargoHold(4);
assert.equal(h.costAt(0), null, 'an empty bay has no cost on file');
assert.equal(h.totalCost(), 0, '… and nothing paid');
h.add('frost-diamond', 30);
h.add(['iron-ore', 'iron-ore'], 21);
assert.deepEqual(h.costs, [30, 21, 21], 'add() stamps the paid unit cost onto every bay added');
assert.equal(h.costAt(1), 21, 'costAt() reads the bay\u2019s recorded cost');
assert.equal(h.totalCost(), 72, 'totalCost() = the credits actually paid');
assert.equal(h.costBays, 3, 'costBays counts the recorded bays');
assert.deepEqual(h.list(), [
{ id: 'frost-diamond', cost: 30 },
{ id: 'iron-ore', cost: 21 },
{ id: 'iron-ore', cost: 21 },
], 'list() pairs each bay with its cost');
h.remove('iron-ore');
assert.deepEqual(h.costs, [30, 21], 'remove() drops the sold bay\u2019s cost with the good');
h.add('grain-crates');
assert.deepEqual(h.costs, [30, 21, null], 'a bay added without a price reads a null cost (the package handoff)');
assert.equal(h.totalCost(), 51, '… and null-cost bays count as 0 (never invented)');
}
// --- the cost ledger follows the mutations -------------------------------
{
// a capacity drop sheds the bay\u2019s cost with its good
const h = new CargoHold(4);
h.add(['a', 'b', 'c', 'd'], 10);
h.capacity = 2;
assert.equal(h.used, 2);
assert.deepEqual(h.costs, [10, 10], 'a shed bay takes its cost with it');
// set() keeps the two arrays aligned (a save/load round-trip)
const s = new CargoHold(3);
s.set(['a', 'b', 'c'], [5, null]);
assert.deepEqual(s.items, ['a', 'b', 'c']);
assert.deepEqual(s.costs, [5, null, null], 'set() aligns costs to the kept bays (short → null)');
const round = CargoHold.fromJSON(s.toJSON());
assert.deepEqual(round.costs, [5, null, null], 'save/load round-trips the ledger');
const legacy = CargoHold.fromJSON({ capacity: 3, items: ['a', 'b'] });
assert.deepEqual(legacy.costs, [null, null], 'a save predating the ledger loads with no costs on file');
// dropFirstNot — the package handoff makes room (the package outranks trade)
const d = new CargoHold(2);
d.add(['trade-a', 'trade-b'], 9);
assert.equal(d.dropFirstNot('special-package'), 'trade-a', 'it sheds the first bay that isn\u2019t the package');
assert.equal(d.dropFirstNot('special-package'), 'trade-b', '… then the next');
assert.equal(d.dropFirstNot('special-package'), null, '… a hold of nothing else reads null');
assert.deepEqual(d.costs, [], '… and the shed costs leave with the goods');
d.add('special-package');
assert.deepEqual(d.costs, [null], 'the package itself arrives with no cost on file');
// a bad cost never leaks a NaN into the ledger
const bad = new CargoHold(2);
bad.add('x', 'not-a-number');
bad.add('y', -5);
assert.deepEqual(bad.costs, [null, null], 'non-finite / negative costs normalize to null');
}
// --- costsFor — the sale's per-bay PROFIT BASIS (FIFO, matches remove) ----
{
const h = new CargoHold(6);
h.add(['gem', 'gem', 'gem'], 30); // three bays of 'gem' at 30 each
h.add(['iron-ore', 'iron-ore'], 21); // two bays of 'iron-ore' at 21 each
h.add('gem'); // one 'gem' with NO cost on file
assert.deepEqual(h.items, ['gem', 'gem', 'gem', 'iron-ore', 'iron-ore', 'gem']);
assert.deepEqual(h.costs, [30, 30, 30, 21, 21, null]);
// costsFor returns the FIRST n bays of the good, in fill order (the same
// bays remove() would take) — each its recorded cost or null.
assert.deepEqual(h.costsFor('gem', 2), [30, 30], 'two gem bays → their costs, in order');
assert.deepEqual(h.costsFor('gem', 5), [30, 30, 30, null], 'asking for more returns all that exist (incl. the null-cost bay)');
assert.deepEqual(h.costsFor('iron-ore', 1), [21], 'a different good → its own cost');
assert.deepEqual(h.costsFor('nope', 3), [], 'a good that\u2019s not aboard → no basis');
assert.deepEqual(h.costsFor('gem', 0), [], 'n = 0 → nothing');
assert.deepEqual(h.costsFor('gem', 'not-a-number'), [], 'a bad n → nothing (no throw)');
// … and it is READ-ONLY (the hold is untouched).
assert.deepEqual(h.items, ['gem', 'gem', 'gem', 'iron-ore', 'iron-ore', 'gem'], 'costsFor never mutates the hold');
assert.deepEqual(h.costs, [30, 30, 30, 21, 21, null], '… the ledger is unchanged too');
// remove() then agrees: the same FIFO bays leave with the same costs.
h.remove('gem', 2);
assert.deepEqual(h.items, ['gem', 'iron-ore', 'iron-ore', 'gem']);
assert.deepEqual(h.costs, [30, 21, 21, null]);
}
console.log('CARGOHOLD: capacity, dups, drop-sheds, save/load, clone, costsFor: OK');
console.log('\n== TRADE PREVIEW — the shop UI\u2019s calculator ==');
// A real galaxy (for the fixed per-shop prices). Model v2: a good is only
// on sale at its ORIGIN (the world's signature, batch-limited) or at a HUB
// (hub-pool goods) — the preview's fixtures pick the shop's own floor rows.
const galaxy = Galaxy.create('orbit-cargo-math');
const sysId = Object.keys(galaxy.economy.signatures)[0]; // any settled system
const sigId = galaxy.economy.signatures[sysId]; // its signature good (on its floor)
const cls = [...galaxy.economy.classBySystem.get(sysId)].find((c) => c !== 'capital') ?? 'rocky';
const goodId = sigId; // THE good this shop sells (origin batch price)
const maxSlots = config.get('economy.cargo.maxSlots', 16);
const CAP = 4; // the player's 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) ----------------
{
// A HUB sells two different goods (its own signature at the origin price
// + hub-pool goods at standard) — the mixed buy needs both on one floor.
const hubSys = [...galaxy.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
const hubCls = 'station';
const hubShop = E.shopFor(hubCls, hubSys, galaxy);
const a = hubShop.buy.find((r) => r.produced)?.id ?? hubShop.buy[0].id; // the hub's own signature (origin price)
const b = hubShop.buy.find((r) => r.id !== a)?.id ?? hubShop.buy[0].id; // a hub-pool resale (standard price)
const cargo = [a, a, b]; // two a, one b
const pv = E.tradePreview({
cls: hubCls, systemId: hubSys, galaxy, cargo,
sellMap: { [a]: 2 }, buyMap: { [b]: 2 },
credits: 100000, capacity: CAP,
});
assert.equal(pv.sellTotal, 2 * E.sellPrice(a, hubCls, hubSys, galaxy), 'a\u2019s sale at its sell price');
assert.equal(pv.buyTotal, 2 * E.buyPrice(b, hubCls, hubSys, 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');
}
// --- THE BATCH CAP (model v2 — the origin's remaining daily stock) -------
{
// The origin world sells its signature in a DAILY BATCH (Stock.js):
// a buy over the remaining batch is INVALID (the UI clamps the stepper
// at the same number; this is the backstop). Hubs' resale is unlimited
// (no cap in stockCaps → the same buy stands).
const cap = 2; // the shop's remaining batch (a partial day)
const over = E.tradePreview({
cls, systemId: sysId, galaxy, cargo: [], sellMap: {},
buyMap: { [goodId]: cap + 1 }, credits: 100000, capacity: CAP,
stockCaps: { [goodId]: cap },
});
assert.equal(over.valid, false, 'buying over the remaining batch is INVALID');
assert.ok(over.errors.includes('insufficient-stock'), '… flagged as insufficient-stock');
const exact = E.tradePreview({
cls, systemId: sysId, galaxy, cargo: [], sellMap: {},
buyMap: { [goodId]: cap }, credits: 100000, capacity: CAP,
stockCaps: { [goodId]: cap },
});
assert.equal(exact.valid, true, 'buying EXACTLY the remaining batch is valid');
// No cap for this good here (a hub resells unlimited) → the same buy stands.
const hubSys = [...galaxy.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
const hubShop = E.shopFor('station', hubSys, galaxy);
const hubGood = hubShop.buy.find((r) => !r.produced)?.id ?? hubShop.buy[0].id;
const free = E.tradePreview({
cls: 'station', systemId: hubSys, galaxy, cargo: [], sellMap: {},
buyMap: { [hubGood]: cap + 5 }, credits: 100000, capacity: CAP + 10,
});
assert.equal(free.valid, true, 'hubs\u2019 resale is unlimited (no stock cap)');
// A BUYBACK (buyOverrides) never draws on the batch (it returns what the
// shop JUST bought from the player — the neutral undo; the UI's stepper
// already clamps the buyback qty to what was sold this session, and the
// engine's backstop must not block it).
const bb = E.tradePreview({
cls, systemId: sysId, galaxy, cargo: [goodId, goodId],
sellMap: {}, buyMap: { [goodId]: 2 },
buyOverrides: { [goodId]: E.sellPrice(goodId, cls, sysId, galaxy) },
credits: 100000, capacity: CAP + 5, stockCaps: { [goodId]: 0 },
});
assert.equal(bb.buy[0].unit, E.sellPrice(goodId, cls, sysId, galaxy), 'the buyback is priced at the sale price (neutral undo)');
assert.equal(bb.valid, true, '… and the dry batch never blocks a buyback');
}
console.log('TRADE PREVIEW: sell/buy pricing, both caps, loop, clamps, mix: OK');
console.log('\n== PROFIT XP — 1 XP per 1 credit above the good\u2019s purchase cost ==');
// tradeProfitXp(sell, costsFor) is the pure core of the sale's XP: each bay
// earns max(0, sell unit − its recorded cost); bays with no cost on file
// earn nothing; the total is whole credits, clamped ≥ 0.
{
// A single good sold 2 bays at 50, bought at 30 and 20 → (50−30)+(50−20)=50.
const sell = [{ id: 'gem', unit: 50, qty: 2 }];
const costsFor = (id, n) => (id === 'gem' ? [30, 20].slice(0, n) : []);
assert.equal(E.tradeProfitXp(sell, costsFor), 50, 'profit = Σ max(0, unit − cost) per bay (20 + 30)');
// Sold at/below cost earns NOTHING (no negative XP, no loss).
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 30, qty: 2 }], costsFor), 10, 'at-cost bay earns 0; the 20-cost bay earns its 10 margin');
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 20, qty: 2 }], costsFor), 0, 'a sale below every cost earns 0');
// A bay with no cost on file (null) earns nothing — no basis, no profit.
const someCost = (id, n) => (id === 'gem' ? [null, 10].slice(0, n) : []);
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 50, qty: 2 }], someCost), 40, 'the null-cost bay earns 0; the 10-cost bay earns 40');
// A good not aboard / no cost basis at all → 0.
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 50, qty: 2 }], () => []), 0, 'no cost on file anywhere → 0');
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 50, qty: 2 }], null), 0, 'a missing costsFor → 0 (never throws)');
// Multiple goods sum independently; each bay is clamped to its own margin.
const multi = (id, n) => ({ gem: [10, 60], ore: [40] }[id]?.slice(0, n) ?? []);
const sell2 = [
{ id: 'gem', unit: 50, qty: 2 }, // (50−10)=40 + (50−60)→0 = 40
{ id: 'ore', unit: 50, qty: 1 }, // (50−40)=10 = 10
];
assert.equal(E.tradeProfitXp(sell2, multi), 50, 'each good/bay earns its own margin; losses don\u2019t offset gains');
// Fractional credits floor to whole XP; the total is never negative.
assert.equal(E.tradeProfitXp([{ id: 'gem', unit: 50.9, qty: 1 }], () => [50.2]), 0, 'sub-1-credit margins floor out (50.9−50.2 < 1 → 0)');
assert.equal(E.tradeProfitXp([], () => [10]), 0, 'an empty sale earns nothing');
assert.equal(E.tradeProfitXp(null, () => [10]), 0, 'a null sale earns nothing (no throw)');
}
// The REAL seam: CargoHold.costsFor is the basis tradeProfitXp reads, and
// together they price the exact bays the sale removes (FIFO).
{
const h = new CargoHold(4);
h.add(['gem', 'gem'], 30); // bought at 30 each
h.add(['gem', 'gem']); // two more with NO cost on file (a handoff)
const sellLine = { id: 'gem', unit: 50, qty: 2 }; // the shop sells the FIRST two (30-cost)
const xp = E.tradeProfitXp([sellLine], (id, n) => h.costsFor(id, n));
assert.equal(xp, 40, 'the two 30-cost bays earn (50−30)×2 = 40; the null-cost bays are untouched');
// … and selling the null-cost bays (FIFO after the two priced ones) earns 0.
h.remove('gem', 2);
assert.deepEqual(h.costs, [null, null], '… the two priced bays are gone');
assert.equal(E.tradeProfitXp([sellLine], (id, n) => h.costsFor(id, n)), 0, '… the remaining null-cost bays earn nothing');
}
console.log('PROFIT XP: per-bay margin, loss/none→0, multi-good, floor, FIFO basis: OK');
console.log('\nAll cargo checks passed \u2714');