427 lines
23 KiB
JavaScript
427 lines
23 KiB
JavaScript
/**
|
||
* 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');
|