288 lines
15 KiB
JavaScript
288 lines
15 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.
|
||
* - 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']);
|
||
}
|
||
|
||
console.log('CARGOHOLD: capacity, dups, drop-sheds, save/load, clone: 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('\nAll cargo checks passed \u2714');
|