orbit/dev/cargo.test.mjs

288 lines
15 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.
* - 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');