orbit/dev/cargo.test.mjs

238 lines
12 KiB
JavaScript
Raw 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.
* - 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).
const galaxy = Galaxy.create('orbit-cargo-math');
const sysId = [...galaxy.byId.keys()][0];
const cls = [...galaxy.economy.classBySystem.get(sysId)].find((c) => c !== 'capital') ?? 'rocky';
const good = goods.find((g) => g.id) ; // any catalog good
const goodId = good.id;
const maxSlots = config.get('economy.cargo.maxSlots', 16);
const CAP = 4; // the player\u2019s 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) ----------------
{
const a = goods[0].id;
const b = goods[1].id;
const cargo = [a, a, b]; // two a, one b
const pv = E.tradePreview({
cls, systemId: sysId, galaxy, cargo,
sellMap: { [a]: 2 }, buyMap: { [b]: 2 },
credits: 100000, capacity: CAP,
});
assert.equal(pv.sellTotal, 2 * E.sellPrice(a, cls, sysId, galaxy), 'a\u2019s sale at its sell price');
assert.equal(pv.buyTotal, 2 * E.buyPrice(b, cls, sysId, 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');
}
console.log('TRADE PREVIEW: sell/buy pricing, both caps, loop, clamps, mix: OK');
console.log('\nAll cargo checks passed \u2714');