Implement economy model v2: one world, one good with daily batch stock

- Replace producer/demand class pricing with strict 1:1 origin assignment;
  each settled system sells exactly its signature good at a discounted origin
  price, and the catalog remainder becomes a hub pool resold by stations/capitals
- Make banded hop distance the dominant sell-price factor (x1.0 to x7.0),
  with planet-type demand as a secondary +10% modifier, and cap every shop's
  sell price at its own buy price to eliminate the v1 infinite-credit loop
- Add Stock.js for the time-gated daily batch (6 units per origin per wall-clock
  day) — the economy's only stateful piece; wire it through GameScene,
  SaveData capture/restore/reset, and the shop UI stepper clamp + SOLD OUT strip
- Update Economy.js with assignOrigins pass, new buyPrice/sellPrice/shopFor
  logic, tradePreview stock-cap backstop (buyback exempt), and distanceFactor
- Rework shop UI: BuyList shows "N left"/"SOLD OUT" per batch-limited row and
  clamps the stepper; ScoutPanel shows MADE IN (origin system) instead of GROWN ON;
  ShopView wires stockRemaining/stockCapsFor/spendStock into preview and confirm
- Update all dev tests (economy, cargo, shop-ui, saves) to assert model v2
  invariants including the anti-loop sweep, batch clamp/backstop, and save round-trip
This commit is contained in:
Brian Fertig 2026-09-14 18:22:21 -06:00
parent f0578f1a38
commit 828a5b8c38
15 changed files with 1217 additions and 527 deletions

View File

@ -1,43 +1,52 @@
{
"_comment": "ECONOMY TUNING (the knobs, not the data). goods.json is WHAT exists; this file is HOW it prices and stocks. Everything here is a tunable number so you can rebalance the whole economy without touching code or the catalog. MODEL (full version in docs/ECONOMY.md §5): for a good at a place there are TWO FIXED prices in credits — a BUY price (cheap where the place's class produces the good) and a SELL price (a premium where the class wants it, growing with the hops between this system and the good's home region). Prices are fixed per (good, location), decided once at generation, and never depend on where the player bought the good or what's in their hold. Distance is enforced by REGIONAL AVAILABILITY (goods only cheap in their home region), not a live per-trade multiplier. Credits are the go-between — buying spends them, selling earns them, no barter.",
"_comment": "ECONOMY TUNING (the knobs, not the data). goods.json is WHAT exists; this file is HOW it prices and stocks. MODEL v2 — ONE WORLD, ONE GOOD (full version in docs/ECONOMY.md §5): every tradeable good has exactly one ORIGIN system (seeded strict assignment, 'origin-assign' pass in Economy.buildEconomy) — the single world whose shop sells it, cheap, in a DAILY BATCH. Every other system's shop does NOT sell it. The catalog's remainder (goods with no origin, the HUB POOL) is resold by stations/capitals at standard price (distance measured to the nearest hub). SELL price = baseValue × distanceBand(hops to origin) × (1 + demandBonus if the class wants it), CAPPED at the shop's own sale price for the good (no shop may ever be a positive same-shop spread). The banded distance term is the DOMINANT factor in profit; planet type (demand) is a secondary modifier. Prices are fixed per (good, location), a pure function of the seed; the only state is the day's remaining batch (save: economyStock). Credits are the go-between — buying spends them, selling earns them, no barter.",
"shop": {
"_comment": "Stock size per settlement kind. `station` and `capital` get wider stock (more variety to resell) than a single world.",
"minStock": 8,
"maxStock": 16,
"_comment": "Floor size per settlement KIND (model v2). A WORLD's floor is exactly ONE product — its system's signature good (the strict 1:1 origin assignment; see economy.json → 'stock'). Stations and capitals (hubs) resell from the HUB POOL — wider floors (variety), standard price.",
"minStock": 1,
"maxStock": 3,
"stockByKind": {
"colony": { "min": 8, "max": 12 },
"miningStation":{ "min": 8, "max": 12 },
"cloudBase": { "min": 8, "max": 12 },
"capital": { "min": 12, "max": 16 },
"deepSpaceStation": { "min": 10, "max": 16 }
"capital": { "min": 12, "max": 16 },
"deepSpaceStation": { "min": 10, "max": 16 }
}
},
"pricing": {
"_comment": "All multipliers are dimensionless and multiply baseValue. BUY price = baseValue * (1 - producerDiscount if the place's class produces the good) * jitter. SELL price = baseValue * (1 + demandBonusPerClass * #demand-classes) * (1 + distancePerHop * min(hops, cap)) * jitter. `jitter` is a per-good, per-place seeded wobble (±%) so no two shops are identical.",
"demandBonusPerClass": 0.12,
"producerDiscount": 0.35,
"distancePerHop": 0.22,
"distanceCapHops": 6,
"_comment": "Multipliers are dimensionless and multiply baseValue. ORIGIN sale (the good's home world, batch-limited) = baseValue × (1 − originDiscount) × jitter — the only cheap source in the galaxy. HUB resale (station/capital, hub-pool goods only) = baseValue × hubPriceFactor × jitter — standard, never below origin. SELL price (the shop pays you) = baseValue × distanceBand(min(hops to origin, band)) × (1 + demandBonus if the class wants it) × jitter, CAPPED at the shop's own sale price for that good (anti-loop). `distanceBands` are ascending minHops → factor; the band with the largest minHops ≤ hops wins (no cap — the jump tree's diameter ~17 hops is the natural ceiling). `jitter` is a per-good, per-place seeded wobble (±%) so no two shops feel formulated.",
"originDiscount": 0.50,
"hubPriceFactor": 1.00,
"demandBonus": 0.10,
"distanceBands": [
{ "minHops": 0, "factor": 1.0 },
{ "minHops": 2, "factor": 1.8 },
{ "minHops": 4, "factor": 3.2 },
{ "minHops": 7, "factor": 5.0 },
{ "minHops": 11, "factor": 7.0 }
],
"jitter": 0.10
},
"stock": {
"_comment": "THE TIME-GATED BATCH — the only stateful part of the economy (and the only bookkeeping a shop needs). Each ORIGIN world's signature good comes to market as a daily batch of `dailyBatch` units; a DAY is `dayMs` of wall-clock time past `epoch`, so the batch regenerates while the player is away (the local buy→sell loop in the origin system — buy at the world, sell to the same-system station — is a BOUNDED pocket change, never infinite money, while long-haul SELLs stay unlimited: the market is a sink). The sell-back side is NEVER stock-limited. Save state per system: { day, left } — tiny, save-clone-safe (js/economy/Stock.js).",
"dailyBatch": 6,
"dayMs": 3600000,
"epoch": "2026-01-01T00:00:00Z"
},
"cargo": {
"_comment": "The hold is a set of fixed BAYS — one good per bay (unitPerGood=1). `capacitySource` is the UNLOCKED-bay count (ship.stats.cargoHold, starts at 0 — the run's ship arrives with an empty hold; the bar scene's DONE beat or a cargo build unlocks bays). `maxSlots` is the fixed grid the shop UI always shows (16 slots) — locked bays are just capacity the ship hasn't unlocked yet. You CARRY goods between shops; that's the whole game loop.",
"_comment": "The hold is a set of fixed BAYS — one good per bay (unitPerGood=1). `capacitySource` is the UNLOCKED-bay count (ship.stats.cargoHold). `maxSlots` is the fixed grid the shop UI always shows (16 slots) — locked bays are just capacity the ship hasn't unlocked yet. You CARRY goods between shops; that's the whole game loop.",
"capacitySource": "ship.stats.cargoHold",
"maxSlots": 16,
"unitPerGood": 1
},
"credits": {
"_comment": "The trade currency (the go-between). Displayed next to the minerals HUD (upper-right). Player starts at 0 (startingCredits). Trading is LOCKED until the third story quest (not yet written); until then first credits come from quest rewards and, later, asteroid gemstone mining. Buying spends credits; selling earns them.",
"_comment": "The trade currency (the go-between). Displayed next to the minerals HUD (upper-right). Player starts at 0 (startingCredits). Earning credits = selling goods + mission/quest credit rewards + the bounded local batch loop. Spending = builds, upgrades, and buying goods. Buying spends credits; selling earns them.",
"startingCredits": 0,
"display": { "nextTo": "mineralHud", "anchor": "upper-right" },
"tradingLockedUntilQuest": 3,
"earning": ["sellGoods", "questRewards", "miningLaserGems (later)"]
"earning": ["sellGoods", "questRewards", "localBatchLoop", "miningLaserGems (later)"]
},
"restock": {
"_comment": "Shops are NON-persistent. When the player leaves and later returns, the shop re-derives its stock from the seed — the same goods and prices every time, so there is no inventory to track and no 'you just bought out their stock' bookkeeping. The engine treats a shop's stock as a pure function of (seed, place, economy version).",
"policy": "deterministic-derivation",
"_comment": "Prices + FLOOR are NON-persistent (re-derived from the seed — no per-player sold/bought counters, 'you bought out their stock' bookkeeping lives ONLY in the day's batch: stock.dailyBatch/remaining). A shop's stock and prices are a pure function of (seed, place, economy version) — the batch remainder is the one saved number per origin system.",
"policy": "deterministic-derivation + daily-batch",
"stockIsPureFunction": true,
"note": "No per-player sold/bought counters are stored. Revisiting a shop yields identical stock & prices."
"note": "Reopening a shop re-derives identical floor & prices; the origin's batch regenerates on the day clock."
},
"reputation": {
"_comment": "DEFERRED (decision #3 — wanted, later). Hook: standing with a place's owner faction nudges prices (good standing = better deal). `maxPriceShift` is the max +/- fraction of price. Wired to js/reputation/Reputation.js standingFor(place). OFF for v1.",
@ -49,5 +58,5 @@
"tariffRange": 0.05,
"patronGoods": {}
},
"version": 1
"version": 2
}

View File

@ -1,5 +1,5 @@
{
"_comment": "TRADE GOODS CATALOG (the economy's inventory of things that exist in the universe). This is DATA, not code — the shop UI (later) renders it, the generator prices it (data/economy.json), and the spritesheet (`sheet` below + assets/images/goods.png) maps an `icon` frame per id. Every good has: `id` (stable key), `name`, `category` (groups it in the shop UI — see `categories`), `tier` (1 basic … 4 exotic, drives price band + how far it should travel), `baseValue` (credits, the good's intrinsic worth before local/distance factors), `producer` (economic class(es) that naturally make it — cheap to BUY there), `demand` (class(es) that want it — good to SELL there), `weight` (1 rare … 5 common, how often it appears in a shop's 8–16 stock and how plentiful), `flavor` (one-line shop-keep description), `icon` (the good frame index on the spritesheet — see `sheet`). Economic classes: terran / arid / rocky / gas / ice / lava (planet classes, data/planets.json) + station (deep-space station) + capital (a faction seat). The opposition web the design leans on: ice sells cool to lava while lava sells heat to ice; terran sells water/food to arid while arid sells salt/solar/spice to terran; gas sells fuel to rocky while rocky sells metal to gas. Every class also yields a signature GEMSTONE (category gemstone) that other classes covet. NOTE: this is a DRAFT catalog — names/values are placeholders until you weigh in.",
"_comment": "TRADE GOODS CATALOG (the economy's inventory of things that exist in the universe). This is DATA, not code — the shop UI (later) renders it, the generator prices it (data/economy.json), and the spritesheet (`sheet` below + assets/images/goods.png) maps an `icon` frame per id. Every good has: `id` (stable key), `name`, `category` (groups it in the shop UI — see `categories`), `tier` (1 basic … 4 exotic, drives price band + how far it should travel), `baseValue` (credits, the good's intrinsic worth before local/distance factors), `producer` (economic class(es) that naturally make it — cheap to BUY there), `demand` (class(es) that want it — good to SELL there), `weight` (1 rare … 5 common, how heavily it is weighted in the hub-pool selection and how plentiful), `flavor` (one-line shop-keep description), `icon` (the good frame index on the spritesheet — see `sheet`). Economic classes: terran / arid / rocky / gas / ice / lava (planet classes, data/planets.json) + station (deep-space station) + capital (a faction seat). The opposition web the design leans on: ice sells cool to lava while lava sells heat to ice; terran sells water/food to arid while arid sells salt/solar/spice to terran; gas sells fuel to rocky while rocky sells metal to gas. Every class also yields a signature GEMSTONE (category gemstone) that other classes covet. NOTE: this is a DRAFT catalog — names/values are placeholders until you weigh in.",
"classes": {
"terran": { "label": "Terran World", "economy": "temperate biosphere — food, water, living things" },
"arid": { "label": "Arid World", "economy": "dry sun-world — salt, silica, solar, spice" },

View File

@ -112,14 +112,16 @@ 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).
// 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 = [...galaxy.byId.keys()][0];
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 good = goods.find((g) => g.id) ; // any catalog good
const goodId = good.id;
const goodId = sigId; // THE good this shop sells (origin batch price)
const maxSlots = config.get('economy.cargo.maxSlots', 16);
const CAP = 4; // the player\u2019s starting unlocked bays
const CAP = 4; // the player's starting unlocked bays
// --- sell side: priced at sellPrice, net positive ------------------------
{
@ -217,21 +219,69 @@ const CAP = 4; // the player\u2019s starting unlocked bays
// --- a mixed trade (different goods, sell + buy at once) ----------------
{
const a = goods[0].id;
const b = goods[1].id;
// 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, systemId: sysId, galaxy, cargo,
cls: hubCls, systemId: hubSys, 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.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');

View File

@ -5,25 +5,29 @@
*
* Drives the real trade engine (js/economy/Economy.js) against a real
* generated galaxy (js/galaxy/Galaxy.js) and the real catalog + tuning
* (data/goods.json, data/economy.json). Asserts the model in
* docs/ECONOMY.md §5:
* (data/goods.json, data/economy.json). Asserts MODEL v2 in
* docs/ECONOMY.md §5 (ONE WORLD, ONE GOOD):
*
* - THE PASS RAN: galaxy.economy has classBySystem (every system) + a
* producerHops map for every good; the home system carries terran
* (the home world) + its composition classes.
* - DETERMINISM: same seed ⇒ identical prices + shop floors; a different
* seed ⇒ a different economy (the trade map is a pure function of the
* seed, lazy-safe and save-free).
* - FIXED PRICES: buyPrice/sellPrice are stable for (good, class, system)
* — independent of where the player got the good or what's in their hold.
* - THE LOOP: carrying a good far from its home region sells for MORE than
* it cost to buy at the source (buy cheap where produced, dear where
* wanted + far) — the trip is the reward.
* - DISTANCE: the further a system is from a good's producer, the higher
* the good sells there (the hop term, capped).
* - SHOP FLOOR: 8–16 goods; locally produced goods present and dominant;
* the class's wanted goods in the buy-back list; a station carries
* resold goods (variety); a capital buys back EVERYTHING (universal sink).
* - THE PASS RAN: galaxy.economy has classBySystem (every system), a
* STRICT 1:1 signature/origin assignment (every settled system has
* exactly one signature good; the catalog's remainder is the hub
* pool), + producerHops for every good.
* - DETERMINISM: same seed ⇒ identical prices + floors; a different
* seed ⇒ a different economy (pure function of the seed, lazy-safe,
* save-free).
* - THE ORIGIN: a good is on sale ONLY at its origin (its world's
* signature, the daily batch) or — hub-pool goods — at hubs; off-
* origin the buy price is 0. Sells are a fixed positive function.
* - THE LOOP: the origin sells at ~half base (the discount); a far
* demander (≥ 4 hops) pays MORE — the trip is the reward, distance
* the dominant term.
* - DISTANCE: the further from the origin, the higher the sell price
* (the banded term, monotone).
* - SHOP FLOOR: a plain world's floor is EXACTLY its one signature;
* hubs carry the hub-pool selection (wide, variety) + their own
* signature; a capital buys back EVERYTHING (universal sink).
* - THE ANTI-LOOP INVARIANT: no shop pays more than it charges for any
* good on its own floor (sellPrice ≤ buyPrice, every system × class).
*/
process.env.NODE_ENV = 'dev';
@ -85,11 +89,32 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
const missing = wantClasses.filter((c) => !present.has(c));
check(`every catalog class exists in the galaxy (missing: ${missing.join(', ') || 'none'})`, missing.length === 0);
// Every TRADEABLE good has at least one producing system (the loop is
// possible) — the quest cargo is exempt: nothing produces the package,
// it arrives by the quest's handoff, not by the trade map.
const unproducible = tradeable.filter((x) => !tradeable.length || ![...g.economy.classBySystem.values()].some((s) => x.producer.some((c) => s.has(c))));
check('every tradeable good is produced somewhere', unproducible.length === 0);
// MODEL v2 — ONE WORLD, ONE GOOD: the origin assignment is a strict
// 1:1 (a settled system ↔ a signature good); the catalog's remainder is
// the hub pool (resold by stations/capitals). The quest cargo is exempt
// (nothing produces the package — the quest's handoff delivers it).
const sigs = g.economy.signatures;
const origins = g.economy.origins;
const sigSystems = Object.keys(sigs);
check('every settled system has a signature good (≥ 60 of ~90 systems)', sigSystems.length >= 60);
check('every signature good has exactly ONE origin (1:1)',
tradeable.every((x) => !(x.id in origins) || (origins[x.id] && sigs[origins[x.id]] === x.id)));
const assigned = new Set(Object.values(sigs));
const poolSet = new Set(g.economy.hubPool);
check('the hub pool is the catalog remainder (disjoint + complete)',
tradeable.every((x) => assigned.has(x.id) !== poolSet.has(x.id)) &&
assigned.size + poolSet.size === tradeable.length);
// Thematic fit: the origin system hosts a producing class for its
// signature (the assignment prefers fit; a fallback only where the
// catalog leaves a good with no fitting system).
let fit = 0, unfit = 0;
for (const [sys, gid] of Object.entries(sigs)) {
const good = goods.find((x) => x.id === gid);
const set = g.economy.classBySystem.get(sys);
if (good && Array.isArray(good.producer) && good.producer.some((c) => set.has(c))) fit++;
else unfit++;
}
check(`signatures are thematically placed (${fit} fit / ${unfit} fallback)`, fit >= sigSystems.length - 15 && unfit <= 15);
}
// ---------------------------------------------------------------------------
@ -97,23 +122,32 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
// ---------------------------------------------------------------------------
{
const g2 = Galaxy.create(SEED);
const sysA = g.byId.keys().next().value;
const rockySys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
const goodId = goods[0].id;
// THE ORIGIN (model v2) — a good is on sale ONLY at its origin (its
// world's signature, the daily batch) or, for hub-pool goods, at hubs.
// Everywhere else the buy price is 0 (the good isn't there).
const sigSys = Object.keys(g.economy.signatures)[0];
const sigGood = g.economy.signatures[sigSys];
const sigCls = [...g.economy.classBySystem.get(sigSys)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
const buyA = E.buyPrice(sigGood, sigCls, sigSys, g);
check('buyPrice at the origin is positive + deterministic', buyA > 0 && E.buyPrice(sigGood, sigCls, sigSys, g2) === buyA);
// Off-origin (a non-hub world): the good is simply NOT on sale.
const foreign = Object.keys(g.economy.signatures).find((s) => s !== sigSys &&
![...g.economy.classBySystem.get(s)].some((c) => c === 'station' || c === 'capital'));
const fCls = foreign ? ([...g.economy.classBySystem.get(foreign)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky') : null;
check('buyPrice is 0 off-origin (the good is not on sale there)', !!foreign && E.buyPrice(sigGood, fCls, foreign, g) === 0);
// The SELL side is a fixed positive function of (good, class, system) —
// the market is always a sink (the anti-loop cap only lowers it).
const sellA = E.sellPrice(sigGood, sigCls, sigSys, g);
check('sellPrice is fixed + deterministic', sellA > 0 && E.sellPrice(sigGood, sigCls, sigSys, g2) === sellA);
check('shopFor is deterministic', JSON.stringify(E.shopFor(sigCls, sigSys, g)) === JSON.stringify(E.shopFor(sigCls, sigSys, g2)));
check('prices do not depend on call order (lazy-safe)', E.buyPrice(sigGood, sigCls, sigSys, g) === buyA);
check('buyPrice is fixed + deterministic', E.buyPrice(goodId, 'rocky', rockySys, g) === E.buyPrice(goodId, 'rocky', rockySys, g2) && E.buyPrice(goodId, 'rocky', rockySys, g) > 0);
check('sellPrice is fixed + deterministic', E.sellPrice(goodId, 'rocky', rockySys, g) === E.sellPrice(goodId, 'rocky', rockySys, g2) && E.sellPrice(goodId, 'rocky', rockySys, g) > 0);
check('shopFor is deterministic', JSON.stringify(E.shopFor('rocky', rockySys, g)) === JSON.stringify(E.shopFor('rocky', rockySys, g2)));
check('prices do not depend on call order (lazy-safe)', E.buyPrice(goodId, 'rocky', rockySys, g) === E.buyPrice(goodId, 'rocky', rockySys, g));
void sysA;
}
{
const other = Galaxy.create('orbit-economy-other-seed');
const rockyA = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
const rockyB = [...other.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
const goodId = goods[0].id;
check('a different seed yields a different trade map', JSON.stringify(E.shopFor('rocky', rockyA, g)) !== JSON.stringify(E.shopFor('rocky', rockyB, other)));
const otherSigSys = Object.keys(other.economy.signatures)[0];
const otherSigGood = other.economy.signatures[otherSigSys];
const oCls = [...other.economy.classBySystem.get(otherSigSys)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
check('a different seed yields a different trade map',
JSON.stringify(E.shopFor(sigCls, sigSys, g)) !== JSON.stringify(E.shopFor(oCls, otherSigSys, other)) || sigGood !== otherSigGood);
}
// ---------------------------------------------------------------------------
@ -123,25 +157,37 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
const base = (id) => (goods.find((x) => x.id === id) || {}).baseValue || 0;
let checked = 0;
let ok = 0;
for (const good of goods) {
// A system that PRODUCES it (buy it cheap there).
const prod = [...g.economy.classBySystem.entries()].find(([, s]) => good.producer.some((c) => s.has(c)));
if (!prod) continue;
const [prodId] = prod;
const buyCls = classFor(prodId, good, good.producer);
// A system that WANTS it, ≥ 3 hops from the nearest producer.
for (const good of tradeable) {
// THE ORIGIN (model v2) — buy the signature cheap at its ONE origin
// system (the daily batch; the only cheap source in the galaxy).
const origin = g.economy.origins[good.id];
if (!origin) continue; // hub-pool goods are bought at hubs (no origin)
const oCls = [...g.economy.classBySystem.get(origin)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
const buy = E.buyPrice(good.id, oCls, origin, g);
// A system that WANTS it, ≥ 4 hops from the origin (the long haul).
const far = [...g.economy.classBySystem.entries()].find(
([id, s]) => good.demand.some((c) => s.has(c)) && (g.economy.producerHops[good.id][id] ?? 0) >= 3,
([id, s]) => id !== origin && good.demand.some((c) => s.has(c)) && (g.economy.producerHops[good.id]?.[id] ?? 0) >= 4,
);
if (!far) continue;
const [farId] = far;
const sellCls = classFor(farId, good, good.demand);
const buy = E.buyPrice(good.id, buyCls, prodId, g);
const farSet = far[1];
const sellCls = [...farSet].find((c) => good.demand.includes(c));
const sell = E.sellPrice(good.id, sellCls, farId, g);
checked++;
if (sell > buy && sell > base(good.id) * 0.9) ok++;
// The distance term dominates: a far demander (≥ 4 hops) pays MORE than
// the origin's half-price, even before the demand bonus.
if (sell > buy) ok++;
}
check(`carry-far beats buy-local on a meaningful share of goods (${ok}/${checked})`, checked > 0 && ok / checked >= 0.6);
check(`carry-far beats the origin buy (≥ 4 hops: ${ok}/${checked})`, checked >= 40 && ok / checked >= 0.9);
// THE BASELINE — the origin's sale is the galaxy's cheapest source of the
// good (baseValue × (1 − originDiscount) ± jitter), so the trip's profit
// is measured from a real discount, not a formula.
const sigGood2 = g.economy.signatures[Object.keys(g.economy.signatures)[0]];
const sigSys2 = g.economy.origins[sigGood2];
const oCls2 = [...g.economy.classBySystem.get(sigSys2)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
const b2 = base(sigGood2);
const originPrice = E.buyPrice(sigGood2, oCls2, sigSys2, g);
check(`the origin sells at ~half base (origin ${originPrice} / base ${b2})`, b2 > 0 && originPrice < b2 && originPrice >= b2 * 0.35);
}
// ---------------------------------------------------------------------------
@ -178,44 +224,62 @@ const classFor = (sysId, good, list) => (list ?? []).find((c) => g.economy.class
// 5. The shop floor (8–16, local-dominant, buy-back, hubs)
// ---------------------------------------------------------------------------
{
// A PLAIN WORLD (model v2) — the floor is EXACTLY its signature good:
// one world, one good (the identity of the place).
const rockySys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky') && !s.has('station') && !s.has('capital'))[0];
const shop = E.shopFor('rocky', rockySys, g);
check('plain-world floor is 8–16 goods', shop.buy.length >= 8 && shop.buy.length <= 16);
check('locally produced goods are on the floor', shop.buy.some((b) => b.produced));
check('more than a third of the floor is locally produced', shop.buy.filter((b) => b.produced).length >= shop.buy.length / 3);
const sig = g.economy.signatures[rockySys];
check('plain-world floor is EXACTLY its signature good (one world, one good)', shop.buy.length === 1 && shop.buy[0].id === sig);
check('the signature row is priced at the origin (the discount)', shop.buy[0].price < (goods.find((x) => x.id === sig)?.baseValue ?? Infinity));
check('the buy-back list is non-empty (it wants things)', shop.sell.length > 0);
// No good appears on the floor twice.
const ids = shop.buy.map((b) => b.id);
check('no duplicate goods on the floor', new Set(ids).size === ids.length);
// HUBS — the hub pool's resellers: wide floors (variety), standard price.
const stationSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
const sshop = E.shopFor('station', stationSys, g);
check('station floor is 8–16 goods', sshop.buy.length >= 8 && sshop.buy.length <= 16);
check('a station carries resold goods (variety)', sshop.buy.some((b) => !b.produced));
check('station floor is 10–17 goods (hub-pool selection + own signature)', sshop.buy.length >= 10 && sshop.buy.length <= 17);
check('a station carries hub-pool resales (variety)', sshop.buy.some((b) => !b.produced));
if (g.economy.signatures[stationSys]) {
const row = sshop.buy.find((b) => b.id === g.economy.signatures[stationSys]);
check('the hub\u2019s own signature rides its floor (produced)', !!row && row.produced === true);
}
const capSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('capital'))[0];
const cshop = E.shopFor('capital', capSys, g);
check('a capital buys back EVERY tradeable good (universal sink)', cshop.sell.length === tradeable.length);
check('a capital floor is 8–16 goods', cshop.buy.length >= 8 && cshop.buy.length <= 16);
check('a capital floor is 12–17 goods (hub-pool selection + own signature)', cshop.buy.length >= 12 && cshop.buy.length <= 17);
// THE WORLD'S JEWEL (§4/§6): every shop carries ITS class's signature gem —
// a guaranteed premium good + a guaranteed local buy in every shop. Sweep
// every system of every class (a gem that's only present by catalog luck
// would fail here: the floor must always include the class's gemstone).
// ONE WORLD, ONE GOOD — the sweep: every plain world's floor is exactly
// its one signature, and the signature's origin is that system (the
// strict 1:1 the economy's identity rests on).
let gemWorlds = 0;
let gemMissing = 0;
for (const [id, set] of g.economy.classBySystem) {
if (set.has('station') || set.has('capital')) continue; // hubs: pool + own signature
const wCls = [...set].find((c) => c !== 'station' && c !== 'capital');
if (!wCls) continue;
gemWorlds++;
const s = E.shopFor(wCls, id, g);
if (s.buy.length !== 1 || s.buy[0].id !== g.economy.signatures[id] || g.economy.origins[g.economy.signatures[id]] !== id) gemMissing++;
}
check(`every world's floor is exactly its one signature (${gemWorlds - gemMissing}/${gemWorlds} worlds)`, gemWorlds > 0 && gemMissing === 0);
// THE ANTI-LOOP INVARIANT (the exploit's tombstone) — no shop, for any
// good on its OWN floor, pays more than it charges: sellPrice ≤ buyPrice
// at the same shop (the anti-loop cap). Sweep every system × class.
let floors = 0;
let loops = 0;
for (const [id, set] of g.economy.classBySystem) {
for (const cls of set) {
const gem = goods.find((x) => x.category === 'gemstone' && Array.isArray(x.producer) && x.producer.includes(cls));
if (!gem) continue;
gemWorlds++;
const shop = E.shopFor(cls, id, g);
const row = shop.buy.find((b) => b.id === gem.id);
// the jewel is a premium good (tier 3–4) + a local buy (produced).
if (!row || row.produced !== true || (Number(gem.tier) || 0) < 3) gemMissing++;
floors++;
for (const b of E.shopFor(cls, id, g).buy) {
if (E.sellPrice(b.id, cls, id, g) > b.price + 1e-6) loops++;
}
}
}
check(`every shop carries its class's signature gem (${gemWorlds - gemMissing}/${gemWorlds} worlds)`, gemWorlds > 0 && gemMissing === 0);
check(`no shop is a positive same-shop spread (${floors} floors, ${loops} loops)`, floors > 0 && loops === 0);
}
// ---------------------------------------------------------------------------

View File

@ -71,6 +71,9 @@ const fakeScene = () => {
],
},
playTimeMs: 123456,
// THE ORIGIN WORLDS' REMAINING DAILY BATCHES (model v2 — the economy's
// only state): a partial batch for the current day rides the save.
economyStock: { [system.id]: { day: 1234, left: 2 } },
};
};
@ -223,6 +226,8 @@ const makeStorage = (fail = false) => {
check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0'));
check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
check('capture: playtime carried', rec.playTimeMs === 123456);
check('capture: the batch remainders are carried (economyStock — the economy\u2019s only state)',
JSON.stringify(rec.economyStock) === JSON.stringify({ [system.id]: { day: 1234, left: 2 } }));
check('capture: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`));
check('capture: the comms ledger is carried (record.comms.played)',
Array.isArray(rec.comms?.played) && rec.comms.played.includes('oac-intro') && rec.comms.played.includes('wraith-parting'));
@ -252,6 +257,8 @@ const makeStorage = (fail = false) => {
&& reg.get('commsPlayed').has('oac-intro')
&& reg.get('commsPlayed').has('wraith-parting')
&& reg.get('commsPlayed').size === 2);
check('prepare: the batch remainders are restored (registry.economyStock)',
JSON.stringify(reg.get('economyStock')) === JSON.stringify({ [system.id]: { day: 1234, left: 2 } }));
// A LEGACY record (pre-minerals) still loads — no field, the restore
// side's `typeof === 'number'` guard keeps the ship at 0.
@ -282,6 +289,13 @@ const makeStorage = (fail = false) => {
regNoBuilds.get(PENDING_RESTORE_KEY).builds === null);
check('prepare: a legacy record (no comms field) stages an empty ledger',
regNoBuilds.get('commsPlayed') instanceof Set && regNoBuilds.get('commsPlayed').size === 0);
check('prepare: a legacy record (no economyStock) restores an empty batch state',
JSON.stringify(regNoBuilds.get('economyStock')) === '{}');
// A hand-edited record with a bad shape can't leak into the scene.
const regBadStock = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(regBadStock, makeRec({ economyStock: 'garbage' }));
check('prepare: a malformed economyStock normalizes to an empty batch state',
JSON.stringify(regBadStock.get('economyStock')) === '{}');
// consumeRestore: exactly once.
const first = consumeRestore(reg);
@ -298,6 +312,7 @@ const makeStorage = (fail = false) => {
check('reset: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null);
check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null);
check('reset: the comms ledger is cleared (the welcome arms again)', reg.get('commsPlayed') === null);
check('reset: the batch state is cleared (a fresh run starts full)', reg.get('economyStock') === null);
}
// prepareLoad must reject a record it can't trust.

View File

@ -63,6 +63,7 @@ const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { ShopView } = await import(pathToFileURL(join(__dirname, '../js/ui/shop/ShopView.js')).href);
const { sellsHere } = await import(pathToFileURL(join(__dirname, '../js/economy/TradeScout.js')).href);
const Stock = await import(pathToFileURL(join(__dirname, '../js/economy/Stock.js')).href);
const goods = config.get('goods.goods', []);
assert.ok(goods.length >= 2, 'catalog has goods to trade');
@ -169,9 +170,17 @@ assert.ok(stock.length >= 1, 'the shop has goods to buy');
const A = goods[0].id;
const B = goods[1].id;
const C = stock[0].id; // a good the shop actually sells
const C = stock[0].id; // the world's ONE good (model v2: its signature, batch-limited)
function makeView(ship, { reveal = true, topY = 120, tradeUnlocked = true, worldName = 'Somewhere', homeWorld = null, soldSpy = null } = {}) {
// HUB fixtures — model v2: a plain world sells exactly ONE good (its
// signature), so the multi-row tests (SCROLL, the scout's row-swap) run on
// a station, whose floor is the hub-pool selection + its own signature.
const hubSys = [...galaxy.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
const hubCls = 'station';
const hubStock = E.shopFor(hubCls, hubSys, galaxy).buy;
assert.ok(hubStock.length >= 8, `the hub floor carries the hub-pool selection (${hubStock.length} rows, ≥ 8)`);
function makeView(ship, { reveal = true, topY = 120, tradeUnlocked = true, worldName = 'Somewhere', homeWorld = null, soldSpy = null, sysId: SYS = sysId, cls: CLS = cls } = {}) {
const scene = makeScene();
// gameScene carries the HUD refresh seam + the TRADE gate
// (GameScene.isTradeUnlocked — the 'Special Delivery' capital landing).
@ -183,7 +192,7 @@ function makeView(ship, { reveal = true, topY = 120, tradeUnlocked = true, world
gs.onQuestPackageSold = (n) => { soldSpy?.push(n); return true; };
}
const view = new ShopView(scene, {
ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {},
ship, galaxy, cls: CLS, systemId: SYS, gameScene: gs, onLeave() {},
// topY = the surface HUD's planet-text bottom + padding (what
// SurfaceScene.shopTopY() computes). The panel top should sit here.
topY,
@ -563,13 +572,73 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
}
console.log('BUY: OK');
// ===========================================================================
console.log('\n== BATCH — the origin\u2019s daily batch: "N left", the clamp, SOLD OUT ==');
{
const batch = Stock.dailyBatch(); // the daily batch (data/economy.json)
const ship = makeShip(100000);
ship.cargo.capacity = batch; // room for the full batch (the default 4 bays are the starting ship)
// A REAL stock seam: the scene owns economyStock + spendStock (the
// reference-swap the ShopView reads live on every query).
const gs = {
refreshed: 0,
refreshCreditsHud() { this.refreshed += 1; },
isTradeUnlocked: () => true,
economyStock: {}, // a fresh day → every batch starts full
spendStock(sys, qty) {
const day = Stock.economyDay();
const cur = this.economyStock[sys] ?? { day, left: batch };
const left = Math.max(0, (cur.day === day ? cur.left : batch) - qty);
this.economyStock = { ...this.economyStock, [sys]: { day, left } }; // NEW reference (the seam)
return left;
},
};
const scene = makeScene();
const view = new ShopView(scene, { ship, galaxy, cls, systemId: sysId, gameScene: gs, onLeave() {}, topY: 120, worldName: 'Somewhere' });
view.setTab('trade');
const row = view.buyList.rows.find((r) => r.id === C);
assert.ok(row, 'the world\u2019s signature row is on the floor');
assert.equal(row.left, batch, `… showing its FULL daily batch ("${batch} left")`);
// The stepper allows EXACTLY the batch, no more.
view.buyList.bumpBuy(C, batch);
assert.deepEqual(view.buyList.getBuyMap(), { [C]: batch }, 'stepping to the full batch is allowed');
let pv = view.preview();
const unit = E.buyPrice(C, cls, sysId, galaxy);
assert.equal(pv.buyTotal, unit * batch, '… all batch units at the origin price');
assert.equal(pv.valid, true, '… affordable and room for the batch');
view.buyList.bumpBuy(C, 1);
assert.equal(view.buyList.getBuyMap()[C], batch, '… the stepper refuses to exceed the batch');
// Confirm drains the batch (the save state) + the row goes SOLD OUT.
const before = ship.credits;
assert.equal(view.confirm(), true, 'Confirm goes through');
assert.equal(ship.credits, before - unit * batch, '… credits fall by the full-batch spend');
assert.equal(gs.economyStock[sysId].left, 0, '… and the origin\u2019s batch is now 0 (the saved state)');
const row2 = view.buyList.rows.find((r) => r.id === C);
assert.equal(row2.left, 0, '… the re-rendered row drops to 0 left');
assert.equal(String(row2.objs.left.text), 'SOLD OUT', '… and it reads SOLD OUT');
// A fresh selection is blocked at BOTH seams: the stepper clamps at 0,
// and even a forced selection fails the engine's stock-cap backstop.
view.buyList.bumpBuy(C, 1);
assert.deepEqual(view.buyList.getBuyMap(), {}, '… the stepper can\u2019t select a sold-out batch');
view.buyList.buyQty.set(C, 1); // force a selection past the clamp (backstop check)
pv = view.preview();
assert.equal(pv.valid, false, '… and the engine refuses it (insufficient-stock)');
assert.ok(pv.errors.includes('insufficient-stock'), '… flagged as insufficient-stock');
}
console.log('BATCH: OK');
// ===========================================================================
console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, clip ==');
{
const ship = makeShip(10000);
const { view } = makeView(ship);
// A HUB (model v2 — a plain world's floor is exactly ONE row, no scroll).
const { view } = makeView(ship, { sysId: hubSys, cls: hubCls });
const list = view.buyList;
assert.ok(list.rows.length >= 8, `the shop stock has ${list.rows.length} rows (≥ 8)`);
assert.ok(list.rows.length >= 8, `the hub stock has ${list.rows.length} rows (≥ 8)`);
// 64+6 px per row vs a ~420 px viewport → the content is TALLER than the
// window, so the list must scroll (rows that don't fit were the old bug).
assert.ok(list.maxScroll > 0, `content outgrows the viewport → scrollable (maxScroll=${list.maxScroll})`);
@ -647,6 +716,8 @@ console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, cli
assert.equal(String(list.rows[list.rows.length - 1].objs.qty.text), '1', '… and its qty text updates');
list.clearSelection();
assert.equal(list.hasSelection(), false, 'Clear resets the list selection');
// Hub-pool rows are UNLIMITED (left === null) — the stepper has no batch cap.
assert.equal(list.rows[0].left, null, 'hub rows carry no "N left" (unlimited resale)');
}
console.log('SCROLL: OK');
@ -878,7 +949,9 @@ console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the re
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view } = makeView(ship);
// A HUB — the scout's row-swap test needs ≥ 2 stock rows (a plain world
// sells exactly one good, model v2).
const { view } = makeView(ship, { sysId: hubSys, cls: hubCls });
// The scout exists (over the cargo grid) and starts closed.
assert.ok(view._scout, 'the scout panel exists with the trade content');
@ -890,7 +963,7 @@ console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the re
// valid exit in the report (any stock row would do for the UI
// assertions; rows[0] is not guaranteed to be bought locally).
const row =
view.buyList.rows.find((r) => classesOf(sysId).some((c) => sellsHere(r.id, c))) ??
view.buyList.rows.find((r) => classesOf(hubSys).some((c) => sellsHere(r.id, c))) ??
view.buyList.rows[0];
// TAP THE ROW'S BOX → the scout opens on that good.
@ -910,7 +983,7 @@ console.log('\n== TRADE SCOUT — tap a BUY row: the cargo grid swaps for the re
assert.ok(d.exits.every((e) => e.profit === e.price - d.localBuy), 'profit = exit price − local buy');
assert.ok(d.exits.every((e) => e.systemName && e.clsLabel), 'exits carry system + class labels');
assert.ok(d.exits.every((e, i) => i === 0 || d.exits[i - 1].price >= e.price), 'exits ranked high → low');
assert.ok(d.exits.some((e) => e.systemId === sysId), 'the current (charted) system is a candidate exit');
assert.ok(d.exits.some((e) => e.systemId === hubSys), 'the current (charted) system is a candidate exit');
// TAP THE SAME BOX AGAIN → closes, cargo grid restored.
view._onRowTap(row);
@ -944,14 +1017,14 @@ console.log('\n== SET DESTINATION — tap a best-exit row on the scout: the scen
{
const ship = makeShip(10000);
ship.cargo.add([A]);
const { view, scene } = makeView(ship);
const { view, scene } = makeView(ship, { sysId: hubSys, cls: hubCls });
const calls = [];
scene.askSetDestination = (exit) => { calls.push(exit); };
// A row whose report has exits to tap (one the current system wants —
// rows[0] is not guaranteed to be bought locally).
const row =
view.buyList.rows.find((r) => classesOf(sysId).some((c) => sellsHere(r.id, c))) ??
view.buyList.rows.find((r) => classesOf(hubSys).some((c) => sellsHere(r.id, c))) ??
view.buyList.rows[0];
view._onRowTap(row);
assert.equal(view._scoutOpen, row.id, 'scout open on the row');

View File

@ -1,60 +1,72 @@
# Orbit — Trade & Economy (design)
This is the design for the game's trade/economy. It is written to be built on top of what
Orbit already has — the jump network, factions, planet classes, settlements, the SHOP button
that already exists on the surface deck, and the `ship.stats.cargoHold` slot that's already
reserved for cargo. Nothing here requires new world-structure; it all hangs off the existing
deterministic galaxy.
This is the design for the game's trade/economy. It hangs off the deterministic
galaxy Orbit already has — the jump network, factions, planet classes, settlements,
the SHOP button on the surface deck, and the `ship.stats.cargoHold` slot — so nothing
here requires new world-structure.
**Status: DRAFT / design + data only.** The goods catalog and tuning knobs are written to
`data/` so they can be iterated on. The JS plumbing (HUD, credits, shop logic, generator
hook) is intentionally **not** written yet — the open questions at the bottom of this doc need
your call first.
**Status: MODEL v2 — IMPLEMENTED.** The goods catalog (`data/goods.json`) and the
tuning knobs (`data/economy.json`) are the source of truth; the engine
(`js/economy/Economy.js`), the batch clock (`js/economy/Stock.js`), and the shop
UI (`js/ui/shop/*`) are built. Model v2 — **ONE WORLD, ONE GOOD** — was locked in
after v1's price model (producer/demand class discounts) turned out to make
**distance a weak signal** and, worse, left a **buy→sell→buyback infinite-credit
loop** at any shop. v2's invariants are enforced by `dev/economy.test.mjs`.
---
## 1. Design goals
## 1. Design goals (v2)
1. **Trade is a thing the player does, not a thing the player manages.** No market simulation,
no stock you can manipulate, no persistent shop state. The economy is a *landscape* you
travel through and exploit; it doesn't react to you (or only very lightly, via reputation).
2. **Every world has a market.** Every settled planet and station gets a shop of 8–16 goods,
priced so that what's local is cheap and what's foreign is dear.
3. **The trip is the reward.** A good's profit scales with how far it is from where it's made.
Local trade (buy low in-system, sell nearby) works and is safe; long-haul trade pays much
more and costs jumps/time/risk. Longer journey ⇒ meaningfully better reward.
4. **Planet type drives production.** A terran world makes food; a lava world makes heat and
rare metals; a gas giant makes fuel. Opposing types want each other's signature goods —
ice wants heat, lava wants cool, terran wants salt/solar, arid wants water/food, gas wants
metal, rocky wants fuel.
5. **Local money is possible without crossing the galaxy.** You never *have* to cross the map
to make a living; you choose to, for more.
6. **Each planet type yields a gemstone that other types want** (a universal premium good, and
a later mining/loot hook).
1. **Distance is the dominant factor in profit.** A good's sell price grows with
its hop distance from its ORIGIN (the one world that makes it). Longer journey
⇒ meaningfully better reward. This is the business model.
2. **Planet type is a secondary modifier.** A world's class contributes a small
demand bonus (the class *wants* the good) and drives the THEMATIC FIT of the
origin assignment (a gas world is where fuel is made) — but it never overrides
distance.
3. **Goods are regionally produced — one world, one good.** Every tradeable good
has exactly ONE origin system (a seeded strict 1:1 assignment); every settled
system sells exactly ONE product — its signature good, cheap, in a daily batch.
The catalog's remainder is the **hub pool**, resold by stations/capitals at
standard price.
4. **Same-system trading earns money, but it is not the business model.** A
buy-local/sell-local loop in one system is *bounded* by the daily batch
(a few units a day) — pocket change. Long-haul SELLs are unlimited: the market
is a sink.
5. **Interact, don't control.** No market simulation, no stock you can
manipulate, no persistent shop state beyond the day's remaining batch. The
economy is a landscape you travel through and exploit.
6. **Local money is possible without crossing the galaxy.** You never *have* to
cross the map to make a living; you choose to, for much more.
---
## 2. The core loop
```
LAND on a world SHIP a good LAND on another world
┌──────────────┐ (jump) ┌──────────────┐
│ open SHOP │ ◄────────────────────────── │ SELL for │
│ BUY cheap │ │ CREDITS │
│ (local/low) │ ──────────────────────────► │ (dear/hi) │
└──────────────┘ carry in cargoHold └──────────────┘
LAND on the ORIGIN world SHIP a good LAND far away
┌──────────────┐ (jump) ┌──────────────┐
│ open SHOP │ ◄──────────────────────────────── │ SELL for │
│ BUY the │ │ CREDITS │
│ signature │ ─────────────────────────────────► │ (dear — it │
│ (half price,│ carry in cargoHold │ is far from │
│ daily batch│ │ its home) │
└──────────────┘ └──────────────┘
▲ │
└──────────────── spend credits on builds/quests ◄──────┘
```
- **BUY** where a good is cheap (it's produced there, or it's local).
- **BUY** a world's signature good at its ORIGIN — the only place it is cheap
(half of base value), limited to the day's batch.
- **CARRY** it in your cargo hold (`ship.stats.cargoHold`).
- **SELL** where it's dear (it's wanted there, or it's far from its source).
- **PROFIT** = sell price − buy price. The gap is bigger the further the good has travelled.
- **SELL** it wherever a class wants it — the price rises with hop distance
from the origin (the banded term). The further you carry it, the more it's
worth.
- **PROFIT** = sell price − buy price. The gap is *dominated* by distance.
- **SPEND** credits on ship builds, quest rewards, upgrades.
You are never *balancing* a market. You are *finding* an arbitrage the galaxy already set up
and running it. That is the "interact, don't control" requirement made concrete.
You are never *balancing* a market. You are *finding* an arbitrage the galaxy
already set up and running it.
---
@ -62,117 +74,167 @@ and running it. That is the "interact, don't control" requirement made concrete.
`data/goods.json` defines them; they map straight onto existing structures:
| Class | Where it exists | Naturally PRODUCES (cheap to buy) | Strongly WANTS (dear to sell) |
| Class | Where it exists | Thematic producers (origin fit) | Strongly WANTS (+10% demand bonus) |
|---|---|---|---|
| **terran** | `terran` planets | food, water, timber, pharma, biology, gems: **Verdant Emerald** | rare metals, fuel, heat goods, high-end tech |
| **arid** | `arid` planets | salt, silica, solar, spice, gems: **Dune Topaz** | water, humidity, coolants, food |
| **rocky** | `rocky` planets | ores, metal, stone, gems: **Karnite** | fuel, food, water, processed tech |
| **gas** | `gas` planets | fuel, gases, isotopes, gems: **Jovian Sapphire** | solid metal, food, water, hull parts |
| **ice** | `ice` planets | water, ice, cryo, volatiles, gems: **Frost Diamond** | heat, energy, food, fuel |
| **lava** | `lava` planets | heat, rare metals, volcanic glass, gems: **Magma Ruby** | coolants, water, food, fuel |
| **station** | deep-space stations | processed/refined goods, services, gems: **Aether Crystal** | raw ore, food, water, bulk fuel |
| **capital** | a faction's seat | luxury, finance, high-end tech, gems: **Imperial Diamond** | everything, at a premium (the universal buyer) |
| **terran** | `terran` planets | food, water, timber, pharma, biology | rare metals, fuel, heat goods, high-end tech |
| **arid** | `arid` planets | salt, silica, solar, spice | water, humidity, coolants, food |
| **rocky** | `rocky` planets | ores, metal, stone | fuel, food, water, processed tech |
| **gas** | `gas` planets | fuel, gases, isotopes | solid metal, food, water, hull parts |
| **ice** | `ice` planets | water, ice, cryo, volatiles | heat, energy, food, fuel |
| **lava** | `lava` planets | heat, rare metals, volcanic glass | coolants, water, food, fuel |
| **station** | deep-space stations | (hubs — resellers, not producers) | raw ore, food, water, bulk fuel |
| **capital** | a faction's seat | (hubs — resellers, not producers) | everything, at a premium (the universal buyer) |
This is the supply/demand web. It's deliberately symmetric on the opposition pairs you asked
for:
In v2 the classes play TWO roles, both secondary to distance:
- **ice ⇄ lava** — ice sells cool (coolants, cryo, water) to lava; lava sells heat (thermal
batteries, plasma, volcanic energy) to ice.
- **terran ⇄ arid** — terran sells water/food to arid; arid sells salt/solar/spice to terran.
- **gas ⇄ rocky** — gas sells fuel to rocky; rocky sells metal to gas.
- **Origin fit** — the seeded origin assignment prefers systems whose classes
*produce* the good (a gas world gets fuel, an ice world gets cryo). The
`producer` list is the fit signal; where the catalog leaves no fitting
system, the assignment falls back (a few goods are "anywhere" — the
deterministic seed still fixes exactly where).
- **Demand** — a class in the good's `demand` list pays a +10% bonus
(`economy.json → pricing.demandBonus`). The `capital` class wants everything:
the great sink, the universal buyer, the long-haul ceiling.
The `capital` class is the great sink: it buys almost everything at a premium, which is what
makes a run to a faction seat lucrative and gives long-haul a ceiling.
The opposition pairs (ice ⇄ lava, terran ⇄ arid, gas ⇄ rocky) still give the
catalog its flavor and keep the demand web symmetric — but the *price* no longer
derives from "produces here, wants there"; it derives from **how far from the
origin you sell it**.
---
## 4. The gemstone system
## 4. One world, one good (the v2 identity)
One signature gem per class, category `gemstone`. They are:
v1 gave every shop a floor of 8–16 class-appropriate goods and a per-class
signature gem. v2 replaces both with a single rule:
- **Universal demand** — every gem is wanted by *other* classes (see each entry's `demand`),
so there is always a premium outlet for a world's signature stone.
- **Higher value band** (tier 3–4, baseValue 105–300) than most commodity goods — they are the
"jewel" trade that makes even a short trip worthwhile.
- **A future mining/loot hook** — right now they are tradeable goods; later they can also drop
from mining/loot, as you noted. The catalog already treats them as goods, so a drop source
just adds a second way to acquire the same id.
> **Every settled system has exactly ONE signature good — and every signature
> good has exactly ONE origin system.**
The eight gems: Verdant Emerald (terran), Dune Topaz (arid), Karnite (rocky), Jovian Sapphire
(gas), Frost Diamond (ice), Magma Ruby (lava), Aether Crystal (station), Imperial Diamond
(capital — the measure by which the rest are weighed).
- **The assignment is a seeded strict bijection** (the `origin-assign` pass in
`Economy.buildEconomy`, greedy most-constrained-first with zone→tier fit and
a seeded tie-break). 78 settled systems ↔ 78 signature goods out of the
114-goods catalog; the 12 jump-gate-only (barren) systems host nothing —
there is no world to sell from.
- **The catalog's remainder (36 goods) is the HUB POOL** — no origin world;
stations and capitals resell a seeded selection of it (standard price, wide
floors for variety).
- **Thematic fit is near-total** — the origin assignment prefers systems whose
classes produce the good (in the test seed, 78/78 fit; the fallback only
fires where the catalog leaves a good with no fitting system).
- **Gems are no longer special.** The eight gemstones are still in the catalog
(and can be a world's signature, earning half-price origin + the demand
bonus), but there is no more "one per class, always on every shelf" rule.
Why: one product per world makes each place *legible* — you can name a system
by what it makes ("the fuel world"), the buy menu is trivially small, and the
only economic question a player has to answer is **where does it go?**
---
## 5. Pricing — fixed per location, distance baked in by regionality
## 5. Pricing — fixed per location, distance dominant
**The prices are FIXED per (good, location) pair — decided once at generation, and they do NOT
depend on where you got the good or what's in your hold.** A good's sell price at a planet is the
same whether you mined it two systems away or carried it across the galaxy. This is what makes
the shops stateless (no per-player stock bookkeeping) and the whole economy a pure function of
the seed.
**Prices are FIXED per (good, location) pair — a pure function of the seed.**
They do not depend on where you got the good or what's in your hold. The shops
are stateless; the whole economy re-derives from `(seed, placeId, economyVersion)`.
The only state is the day's remaining batch (see §5.3).
For a good `g` at a place `p` (class `C`, system `S`) there are TWO fixed prices, both in credits:
For a good `g` at a system `S` (class `C`) there are TWO fixed prices:
- **BUY price** (what you pay `p`): cheap where `C` produces the good (its home region), standard
where a hub resells it.
- **SELL price** (what `p` pays you): a premium where `C` wants the good, growing with the hops
between `S` and the good's home region. That hop distance is the "trip is the reward" term.
### 5.1 The BUY price (what you pay)
```
price(g,p) = baseValue(g)
× (1 + demandBonus if p.class is in g.demand) ← it's wanted here
× (1 − producerDiscount if p.class is in g.producer)← it's made here (cheaper)
× (1 + distancePerHop × min(hops, cap)) ← how far from its source
× jitter
origin sale (S is g's ORIGIN) = baseValue × (1 − originDiscount) × jitter
hub resale (hub-pool good, S is a hub) = baseValue × hubPriceFactor × jitter
everywhere else = NOT ON SALE (buyPrice = 0)
```
- `baseValue` — the good's intrinsic worth (goods.json).
- **demandBonus** (+12%/class in the draft) — a place that *wants* the good pays more.
- **producerDiscount** (−35% in the draft) — a place that *makes* the good sells it cheap.
**This is the "local = cheap" rule.**
- **distance** (+22%/hop, capped at 6 hops) — the good is worth more the further it is from the
nearest class that produces it. **This is the "trip is the reward" rule.** It uses the jump
graph (hops), which PROJECT_NOTES already establishes as ≈ map distance, so it's consistent
with how the galaxy is generated.
- **jitter** (±10% seeded) — per-good, per-place wobble so no two shops are identical and no
number feels "formulated."
- **originDiscount** = 50% — the origin sells at ~half base. **The origin is the
galaxy's only cheap source of its good.**
- **hubPriceFactor** = 1.0 — hubs resell at standard (never below the origin —
there is exactly one cheap place).
- A world's floor is *only* its signature (its one product); a hub's floor is
the hub-pool selection **plus** its own system's signature (same day's batch,
same source — a station in the fuel system resells fuel at the origin price).
- **Regional availability is how distance is enforced.** A good is only offered for sale (cheap)
where its class produces it — its HOME region. You must be in that region to buy it cheap; a
far region doesn't carry it locally, so the only way to get it there is to carry it. That
regional lock — not a live per-trade hop-multiplier — is the distance mechanism.
- **Credits are the go-between — there is no barter.** Buying is one credit transaction (you
spend); selling is a separate one (you earn). Your profit is simply
`SELL price (where you sell) − BUY price (where you bought)`.
### 5.2 The SELL price (what you get paid)
**Concrete example (draft numbers):** `Refined Fuel` (base 14, gas producer) is on sale on a gas
world for `14 × 0.65 ≈ 9`. A rocky world 2 hops out, which wants fuel, buys it back for
`14 × 1.12 × 1.44 ≈ 22`. Profit ≈ **13 credits** on a 2-hop run. Same good, 5 hops out:
`14 × 1.12 × 2.10 ≈ 33` → profit ≈ **24** — same good, same hold-space, more than double, because
it was carried further. Every one of those numbers is fixed for its location; none changes based
on your inventory.
```
sell(g, C, S) = baseValue(g)
× distanceBand(hops(S → origin(g))) ← THE dominant term
× (1 + demandBonus if C wants g) ← the secondary modifier
× jitter
, CAPPED at the shop's own BUY price for g ← the anti-loop rule
```
- **distanceBands** (banded, not linear — the trip pays in steps):
| hops from origin | 0 | 2 | 4 | 7 | 11+ |
|---|---|---|---|---|---|
| factor | ×1.0 | ×1.8 | ×3.2 | ×5.0 | ×7.0 |
The jump tree's diameter (~17 hops) is the natural ceiling. No cap: the band
with the largest `minHops ≤ hops` wins.
- **demandBonus** = +10% when the class wants the good (`g.demand` contains `C`)
— planet type's entire price role.
- **jitter** = ±10% seeded per (good, place) — no two shops feel formulated.
- **THE ANTI-LOOP RULE** — a shop's sell price for a good is **capped at its own
buy price for that good**. No shop may ever be a positive same-shop spread.
This is the v1 exploit's tombstone (buy→sell→buyback at one shop used to print
infinite credits); `dev/economy.test.mjs` sweeps every system × class and
asserts **zero** positive same-shop spreads.
### 5.3 The daily batch (the economy's one stateful seam)
Each origin world's signature good comes to market as a **daily batch of 6
units** (`economy.json → stock`):
- `dayMs = 3 600 000` (an hour of wall-clock), `epoch = 2026-01-01T00:00:00Z` —
the batch regenerates while the player is away.
- **Buy side only** is batch-limited. The SELL side is unlimited (the market is
a sink — selling never "uses up" anything).
- This bounds the same-system loop: in the origin system you can buy at most 6
units a day at half price and flip them to the same system's station — a
*bounded pocket change*, not infinite money. Long-haul selling stays
unlimited, so the incentive clearly favors the trip.
- **Save state** is tiny and save-clone-safe:
`economyStock = { [systemId]: { day, left } }` — one integer pair per origin
the player has drawn from (`js/economy/Stock.js` is a pure module; `spend()`
returns a NEW map, `GameScene.spendStock` swaps the reference;
`SaveData.js` captures/restores/cleans it).
### 5.4 Credits are the go-between — no barter
Buying spends credits; selling earns them; profit = sell − buy. All fixed per
location; none changes based on your inventory.
**Concrete example (real numbers, test seed):** `Cryo Serum` (base 66) is
Keth's signature (an ice world — thematic fit). At Keth it sells for
`66 × 0.5 ≈ 31` (6 units a day). A rocky world 2 hops out, which wants cryo,
pays `66 × 1.8 × 1.1 ≈ 131` → **≈ 100/unit profit on a 2-hop run.** Seven hops
out: `66 × 5.0 × 1.1 ≈ 363` → **≈ 330/unit** — same hold-space, three times the
money, because it was carried further. The same-system flip (Keth world → Keth
station) nets ~0 per unit (the anti-loop cap) and is limited to 6 units a day —
pocket change by design.
---
## 6. Shop stock rules (8–16 goods)
## 6. Shop floors (v2)
Each settlement gets a shop with a stock size from `economy.json → shop.stockByKind`
(colonies/mining 8–12, stations/capitals 10–16). The stock is **seeded**, not random-at-open,
so it's stable per place. The mix follows the class:
| Shop | Floor |
|---|---|
| **A world** | EXACTLY its signature good — one row, origin price, the "N left" batch strip. |
| **A station** | a seeded 10–16 from the hub pool (standard price) + its own system's signature (origin price, batch-limited). |
| **A capital** | a seeded 12–16 from the hub pool (standard price) + its own system's signature. Its SELL list is the **entire catalog** (the universal sink). |
- **Locally produced goods** appear in *higher* stock and *lower* price (the `producer` rule).
- **Goods the class wants** appear at a *premium* or are *absent* — a place that needs fuel
often won't sell fuel cheap; it sells it dear or not at all, so your imported fuel is what
they're buying.
- **Signature gem** of the local class is always present (the world's "jewel" is always on the
shelf) — guarantees a premium good in every shop and a guaranteed local buy.
- **Stations/capitals** carry wider, more exotic stock (they're resellers/finance), so they
list more tier-3/4 and tech/luxury goods than a mining station does.
Every world's BUYBACK list is its class's wanted goods (the `demand` rule) — a
place that wants fuel buys back fuel; a capital buys back everything.
The exact pick is `seed → (place class, distance profile) → weighted sample of 8–16 goods`,
weighted by the good's `weight` field and its relevance to the class. It's a pure function, so
it's deterministic and re-derivable (see §8).
The scout panel (tap a buy row — `js/ui/shop/ScoutPanel.js` + `TradeScout.js`)
reports the good's **MADE IN** (its origin system, or "HUB RESALE"), its
**SOUGHT ON** classes, and the top-3 charted exits ranked by sell price — with
tap-to-**SET DESTINATION** on each exit. That is the player's strategy tool:
the economy is a landscape, and this is the map of it.
---
@ -180,223 +242,149 @@ it's deterministic and re-derivable (see §8).
| Existing system | Economy uses it |
|---|---|
| **Jump network / hops** (`Galaxy.js`, `data/galaxy.json`) | the distance term in pricing — hops ≈ map distance, per PROJECT_NOTES. Longer path ⇒ higher sell price. |
| **Jump network / hops** (`Galaxy.js`, `data/galaxy.json`) | the distance bands in pricing — hops ≈ map distance. Longer path ⇒ higher sell price. |
| **Faction territories** (`data/factions.json`) | `capital` class = faction seats (universal premium buyers). Faction tariff/patron hooks (`economy.json → faction`). |
| **Planet classes** (`data/planets.json`) | the six world classes + their producer/demand roles. |
| **Settlements** (`data/settlements.json`, every planet settled) | each settlement = a shop. `settledKindByClass` already tells us the shop's "personality." |
| **Stations** (`data/stations.json`) | the `station` class (refiner/reseller, wider stock). |
| **Zones** (`data/galaxy.json → zones`) | optional regional price bands — far zones (Far Reach / Outer Verge) run wilder prices. |
| **Planet classes** (`data/planets.json`) | the six world classes — origin-fit (assignment) + demand bonus (pricing). |
| **Settlements** (`data/settlements.json`, every planet settled) | each settled system is a shop; the origin assignment's candidate pool. |
| **Stations** (`data/stations.json`) | the `station` class (hub-pool resellers, wider floors). |
| **Zones** (`data/galaxy.json → zones`) | the origin assignment's tie-break (zone → tier fit). |
| **Reputation** (`js/reputation/Reputation.js`) | standing with a place's owner faction nudges its prices (hook, `economy.json → reputation`). |
| **SHOP button** (surface deck, `data/actionbar.json`) | the existing surface `shop` action becomes the buy/sell UI. |
| **SHOP button** (surface deck, `data/actionbar.json`) | the existing surface `shop` action opens the buy/sell UI. |
| **cargoHold** (`data/ship.json`) | your carrying capacity for goods. |
| **Credits** (builds, quest rewards already reference `credits`) | the currency. HUD next to minerals. |
Determinism: all of this is a **pure function of the seed**. Shop stock and prices are derived
from `(seed, placeId, economyVersion)` exactly like system contents are derived today
(`Rng.derive(seed, 'economy', …)`). That means lazy and eager generation agree, saves stay
tiny (we store only *your* credits + your carried goods, never the shops), and the economy can
never drift or corrupt.
Determinism: all of this is a **pure function of the seed** (lazy and eager
generation agree; saves stay tiny — we store only your credits, your carried
goods, and the batch remainders, never the shops).
---
## 8. The credits currency (decided)
- **Displayed next to the minerals HUD** (upper-right). Minerals are *bulk matter*; credits are
*trade value*. They coexist because they're different resources: you mine minerals, you earn
credits.
- **Displayed next to the minerals HUD** (upper-right). Minerals are *bulk
matter*; credits are *trade value*.
- **You start at 0** (`economy.json → credits.startingCredits`).
- **Earning credits** = selling goods + mission/quest credit rewards. **Spending** = builds,
upgrades, and buying goods.
- **The cargo bays are the first credit sink** — the hold expands 4 → 8 → 12 → 16 through three
credit-costed ship builds (`data/builds.json → cargo-bay-8/12/16`, `targets: ["ship"]`,
`cost: { credits: 300/1200/3000 }`, `effects.cargo.bays`). Built from any planet's console and
applied to the ship (MAX wins, never a downgrade). The build console was extended to pay out
credits (`BuildWindow` affordability covers both minerals + credits; `GameScene.beginBuild`
debits `ship.spendCredits` for credit lines) — so trading income now has somewhere to go
before the next sink exists.
- **Trading is LOCKED until the third story quest** (not written yet). Until then the economy
exists in the world (shops, prices, gems) but the player's buy/sell UI is gated. Initial
credits come from **mission/quest rewards** (the quest reward object already has a `credits`
field — `data/quests.json` → `reward.credits`, surfaced in `_questRewardLabel`) and, later,
from **mining-laser upgrades that periodically yield gemstones from asteroids** — the gems are
already tradeable goods, so selling them is the credit faucet. (Decision #1.)
- **Earning credits** = selling goods + mission/quest credit rewards + the
bounded local batch loop. **Spending** = builds, upgrades, and buying goods.
- **The cargo bays are the first credit sink** — the hold expands 4 → 8 → 12 → 16
through three credit-costed ship builds (`data/builds.json → cargo-bay-8/12/16`).
- **Trading is LOCKED until the third story quest.** Until then the economy
exists in the world (shops, prices, batches) but the buy/sell UI is gated.
---
## 9. Interact, don't control (your requirement)
What the player **does**: pick goods, buy low, travel, sell high, spend. Nothing else.
What the player **does**: pick goods, buy at the origin, travel, sell far,
spend. Nothing else.
What the player **cannot do**: change prices, stock a shop, corner a market, set a price, or
make a good appear that isn't produced. The economy is a fixed landscape generated from the
seed. The only "control" surface that exists is *light* and comes from your existing systems:
reputation slightly moves a place's prices, and (later) factions may patron/tax specific goods.
That's flavor, not management.
What the player **cannot do**: change prices, stock a shop, corner a market,
or make a good appear that isn't made. The only "control" surface that exists
is *light* and comes from the existing systems: reputation slightly moves a
place's prices, and (later) factions may patron/tax specific goods. That's
flavor, not management. The daily batch is the one place the economy has a
memory — and it only ever *refills* in the player's favor.
---
## 10. Files
## 10. Files (v2)
| File | What it is |
|---|---|
| `data/goods.json` | **114 goods** across 9 categories, each with producer/demand/weight/tier/value/flavor. The catalog. |
| `data/economy.json` | All the tuning knobs (shop size, price multipliers, distance curve, cargo, credits start, trading lock, reputation/faction hooks). |
| `data/creditshud.json` | The CREDITS readout styling (upper-right, stacked above the minerals HUD). |
| `js/economy/Economy.js` | The pure price/stock engine (`buyPrice`/`sellPrice`/`shopFor`) + the `buildEconomy` generation pass (classBySystem + producerHops). |
| `js/ui/CreditsHud.js` | The CREDITS line, upper-right, above the minerals HUD (count-up on change). |
| `data/goods.json` | **114 goods** across 9 categories, each with producer/demand/weight/tier/value/flavor. The catalog (unchanged by v2 — the assignment works on it). |
| `data/economy.json` | All the tuning knobs (model v2: originDiscount, hubPriceFactor, demandBonus, distanceBands, jitter, the daily batch, cargo, credits). `version: 2`. |
| `js/economy/Economy.js` | The pure price/stock engine: `originOf`/`signatureOf`/`isHubPool`, `buyPrice`/`sellPrice`/`shopFor`, `tradePreview` (with the stock-cap backstop), and the `buildEconomy` generation pass (classBySystem + the strict signatures/origins/hubPool assignment + producerHops). |
| `js/economy/Stock.js` | **NEW (v2)** — the time-gated daily batch: `dailyBatch`, `economyDay`, `batchRemaining`, `spend` (pure, reference-swap; state = `{ [systemId]: { day, left } }`). |
| `js/economy/TradeScout.js` | **The strategy layer** (pure): `scoutGood()` — the good's origin (MADE IN), demand, and the top-N charted exits ranked by sell price. |
| `js/economy/CargoHold.js` | The trade-goods hold — a pure, framework-free class. One good per bay. |
| `js/ui/shop/TradeGrid.js` | The shop's SELL side: a 4×4 grid of the player's cargo bays. |
| `js/ui/shop/BuyList.js` | The shop's BUY side: the floor as wide rows — icon + name + flavor + price + stepper, plus the **"N left" / "SOLD OUT" batch strip** (v2) and the stepper clamp at the remaining batch. |
| `js/ui/shop/ScoutPanel.js` | **The goods scout** (v2): the panel that replaces the cargo grid when a buy row is tapped — MADE IN / SOUGHT ON / BEST EXITS, with tap-to-set-destination rows. |
| `js/ui/shop/ShopView.js` | The buy/sell (and buyback) screen: the two grids + the scout + a live NET preview + Confirm/Clear. Owns the batch wiring (`stockRemaining`/`stockCapsFor`, `confirm()` spends the batch, `_afterTrade` re-renders the "N left" strip). |
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
| `js/entities/Ship.js` | `ship.credits` + `canAfford`/`addCredits`/`spendCredits`/`setCredits` (the ledger) and `ship.cargo` (the `CargoHold`) + `maxCargoSlots`/`setCargoHold`/`cargoRoom`. |
| `js/economy/CargoHold.js` | The trade-goods hold — a **pure, framework-free** class (capacity, items, add/remove/count/has/clear/set/snapshot/save-clone). One good per bay. |
| `js/ui/shop/TradeGrid.js` | The shop's SELL side: a 4×4 grid of the player's cargo bays (tap to check off, hover tooltip with name + price). |
| `js/ui/shop/BuyList.js` | The shop's BUY side: the stock as a scrollable list of WIDE ROWS (icon + name + one-line description + price + −/qty/+ stepper; drag/wheel to scroll). |
| `js/ui/shop/ShopView.js` | The buy/sell (and buyback) screen: the two grids + a live NET preview + Confirm/Clear, a Buy/Sell ↔ Buyback tab bar, the session-local `soldThisSession` buyback, and the transaction apply. |
| `data/shopui.json` | The shop screen's layout/animation knobs (tabs, panels, grid geometry, slot colors, stepper, preview, buttons) — presentation only; the economy is `Economy.js`. |
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
| `js/save/SaveData.js` | Credits + cargo captured/restored with the ship record. |
| `js/scenes/GameScene.js` | Builds the credits HUD (minerals HUD shifted below it), refreshes it on credit changes, pays quest `reward.credits` on claim, restores cargo. |
| `js/scenes/SurfaceScene.js` | Opens the `ShopView` over the shop clip on the deck's SHOP button (resolving this world's economic class) and tears it down on LEAVE SHOP. |
| `data/builds.json` | The **cargo bays** (`cargo-bay-8/12/16`) — the first credit sink. Credit-costed ship builds that expand the hold (4 → 8 → 12 → 16), built from any planet's console, MAX wins. |
| `js/ui/BuildWindow.js` | The build console. Its affordability wallet now covers **both** minerals + credits (`_wallet()`), so the credit-costed cargo bays are buyable. |
| `js/scenes/GameScene.js` (build) | `beginBuild` debits credit lines via `ship.spendCredits` (mineral lines via `ship.setMinerals`); `_applyCargoUpgrade` expands the hold on completion (MAX wins). |
| `dev/economy.test.mjs` | The engine's invariants: determinism, fixed prices, the loop, distance, shop floor, hubs. |
| `dev/cargo.test.mjs` | The hold's rules (capacity, dups, drop-sheds, save/clone) + the `tradePreview` caps (credits/cargo, sell→buy in one confirm).
| `dev/shop-ui.test.mjs` | The shop screen's LOGIC headlessly: construct, sell, buy, the two caps, the session-local buyback (a neutral undo), the lock, and Clear.
| `docs/ECONOMY.md` | This document. |
Implemented this pass: the **price/stock engine**, the **generation pass**, **credits state + save + HUD**, **quest-credit payout**, the **cargo hold** (`CargoHold` + `ship.cargo` + save/restore), and the full **shop buy/sell/buyback UI** (`TradeGrid` + `ShopView`, wired into the SurfaceScene over the existing shop clip). Trading is locked until the third story quest.
| `js/entities/Ship.js` | `ship.credits` + the ledger + `ship.cargo` (the `CargoHold`). |
| `js/save/SaveData.js` | Credits + cargo + **`economyStock`** (the batch remainders) captured/restored; `cleanEconomyStock` normalizes the shape; a new run clears it. |
| `js/scenes/GameScene.js` | Owns `economyStock` (registry-backed), `spendStock()` (the reference swap), the credits HUD, quest payouts. |
| `js/scenes/SurfaceScene.js` | Opens the `ShopView` over the shop clip; the SET DESTINATION offer from a scout exit. |
| `data/builds.json` | The **cargo bays** (`cargo-bay-8/12/16`) — the first credit sink. |
| `dev/economy.test.mjs` | The engine's invariants: the strict 1:1 assignment, hub-pool completeness, determinism, origin-only sales, the loop, the distance bands, the shop floors, and the **anti-loop sweep** (zero positive same-shop spreads, every system × class). |
| `dev/cargo.test.mjs` | The hold's rules + the `tradePreview` caps (credits/cargo/**stock**, sell→buy in one confirm, the buyback exemption from the batch). |
| `dev/shop-ui.test.mjs` | The shop screen's LOGIC headlessly: sell, buy, the **daily batch** ("N left" → clamp → SOLD OUT → engine backstop), scroll/clip, the two caps, the buyback, the scout panel + set-destination. |
| `dev/trade-scout.test.mjs` | The scout report's invariants (origin field, exits ranked, determinism). |
| `dev/saves.test.mjs` | The save bank + the `economyStock` round-trip (capture/restore/reset/legacy/malformed). |
---
## 11. Decisions (locked in this pass)
## 11. Decisions (locked)
1. **Initial credits** — trading is gated until the third story quest; first credits come from
**mission/quest rewards** (the `reward.credits` field already exists in `data/quests.json`)
and, later, **asteroid gemstone mining** (mining-laser upgrades yield gems you sell).
2. **Cargo weight** — the hold is a set of fixed **bays, one good each** (`unitPerGood = 1`).
The player starts with **4 unlocked bays** (`cargoHold = 4`); cargo upgrades raise it toward
the **16-slot grid** the shop UI always shows (`cargo.maxSlots = 16`). Locked bays are just
capacity the ship hasn't bought yet.
3. **Reputation & faction prices** — **deferred** (the hooks stay in `economy.json`; wired later).
4. **Zones / regional bands** — **hop-distance only** (no extra per-zone price swing).
5. **Station vs. capital** — stations = wide *variety* (resellers), capitals = wide *premium*
(the universal buyer). Locked.
6. **Gems** — **purely tradeable this pass** (the asteroid mining faucet is later).
7. **Catalog** — the 114-goods list is **good for the first pass**.
### v2 decisions (this pass)
**Model confirmations (the two "before you code" points):**
- Prices are **fixed per (good, location)** — independent of where you got the good or your
hold. Distance is enforced by **regional availability** (goods only cheap in their home
region), not a live per-trade hop-multiplier.
- **Credits are the go-between** — buy and sell are two separate credit transactions; there is
no good-for-good barter.
1. **ONE WORLD, ONE GOOD** — every tradeable good has exactly one origin
system; every settled system sells exactly one product (its signature).
Strict seeded 1:1; barren (jump-gate-only) systems host nothing.
2. **Distance is the dominant profit factor** — banded hops from the origin
(×1.0 / ×1.8 / ×3.2 / ×5.0 / ×7.0), no cap (the tree's diameter is the
ceiling). Planet type contributes at most +10% (demand bonus).
3. **The daily batch bounds the local loop** — 6 units per origin per
wall-clock day (1h); sell side unlimited. Same-system trading is bounded
pocket change, never infinite money.
4. **The anti-loop cap** — sell price ≤ own buy price at the same shop; no shop
may ever be a positive same-shop spread (the v1 exploit, dead).
5. **The catalog is reused as-is** (114 goods) — the 36 remainder form the hub
pool; no new goods, no per-class gems.
### v1 decisions (still in force)
1. **Initial credits** — trading gated until the third story quest; first
credits from quest rewards + (later) asteroid gemstone mining.
2. **Cargo** — fixed bays, one good each; 4 unlocked → 16-slot grid.
3. **Reputation & faction prices** — deferred (hooks in `economy.json`).
4. **Zones** — hop-distance only (no per-zone price swing).
5. **Station vs. capital** — stations = wide variety (resellers); capitals =
the universal buyer.
6. **Credits are the go-between** — no barter.
---
## 12. The shop screen (player-facing UI)
Where the loop is actually played. You open it from the surface deck's **SHOP** button (the
existing `data/actionbar.json` slot); the shop clip is already live, and the screen sits **over**
it. It's one component — `js/ui/shop/ShopView.js` — composing a `TradeGrid` (the SELL
side) + a `BuyList` (the BUY side).
Where the loop is actually played. You open it from the surface deck's **SHOP**
button; the screen sits **over** the shop clip. One component —
`js/ui/shop/ShopView.js` — composing a `TradeGrid` (the SELL side) + a `BuyList`
(the BUY side) + the `ScoutPanel` (the goods scout).
**Entry state (showcase the clip).** When you first open the shop, **only the tab sub-bar**
is up — one console-blue bar in the deck's cyberpunk **MenuButton** style
(`[ BUY/SELL | BUYBACK ] │ [ CLEAR | CONFIRM ] │ NET 0 cr` — the tabs, the trade
actions, and the live **NET** readout, past hairline dividers; Buyback grayed, the
actions hidden until you start picking, the readout dim `0 cr`), the same style +
placement as the deck's **Menu** sub-bar (`js/ui/MenuSubBar.js`). It rests **above
the command deck**, right-aligned to the deck's **Shop** button — so it reads as the same family
of UI as the Menu drawer. The screen stays open on the shop video so the clip plays. Clicking
**Buy/Sell** reveals the trade panel in the middle of the screen: the **SELL grid** and the
**BUY list** side by side with just a bit of padding between (the panels slide in). The
panel **spans the screen** — the SELL grid's left edge is **flush with the planet name**
(the HUD's left margin), and the BUY list stretches to the **same padding** in from the
right edge.
**Entry state (showcase the clip).** First open shows **only the tab sub-bar**
(`[ BUY/SELL | BUYBACK ] │ [ CLEAR | CONFIRM ] │ NET 0 cr`) above the command
deck. Clicking **Buy/Sell** reveals the trade panel: the **SELL grid** (your
cargo bays, tap to check off) and the **BUY list** side by side, spanning the
screen.
```
# first click — shop video + the tab sub-bar (rests above the deck):
┌─────────────────────────────────────────────────────────────────┐
│ (clip plays) │
│ ┌────────────────────────────────────────────────┐ │
│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET 0 cr │ │
│ └────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│
└─────────────────────────────────────────────────────────────────┘
**BUY list (v2).** A world's list is ONE wide row — its signature good: icon,
name, one-line flavor, the **origin price**, a **"N left" strip** (the day's
remaining batch — the stepper can't exceed it; 0 reads **SOLD OUT** in magenta),
and the −/qty/+ stepper. A hub's list is its 10–16 hub-pool rows (unlimited —
no strip) + its own signature (with the strip). Tapping a row's body (not the
stepper) opens the **TRADE SCOUT**: MADE IN (the origin system, or HUB RESALE),
SOUGHT ON (the demand classes), and the **BEST EXITS** ranked by sell price —
tapping an exit row offers to **SET DESTINATION** to that system. Tapping
anywhere else closes the scout.
# after clicking Buy/Sell — the trade panel reveals (middle of the screen):
│ SELL and BUY sit NEXT TO each other (a small gap between);
│ the trade actions + NET readout ride on the sub-bar below.
┌─────────────────────────────────────────────────────────────────┐
│ ┌─────────────────┐ ┌───────────────────────────────────┐ │
│ │ SELL (4×4 bays) │ │[i] Fresh Produce 24 cr − 0 +│ │
│ │ your cargo │ │ Crisp, clean, alive. A small mir… │ │
│ │ (tap = sell) │ │[i] Refined Fuel 41 cr − 1 +│ │
│ │ │ │ The lifeblood of the fleet. │ │
│ └─────────────────┘ │ … (scrolls) … │ │
│ └───────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────┐ │
│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET +420 cr │ │
│ └────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│
└─────────────────────────────────────────────────────────────────┘
```
**NET readout** — the live credit delta on the sub-bar (green up, red spend,
dim at zero), the same `preview()` the Confirm button is gated on. **SELL → BUY
is atomic** — one Confirm executes credits `+ sale − buy`, cargo `− sold +
bought`. **BUYBACK** (second tab) lists what you sold this visit, buyable back
at the sale price — a *neutral* undo (sell→buyback nets zero; it never draws on
the batch).
- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons, the **Confirm/Clear**
trade actions, and the live **NET** readout — all `MenuButton`s (the deck's cyberpunk
style: cut-corner panel, neon edge, hover sweep, press punch) in one console-blue panel
that rests above the command deck, anchored to the Shop button — the exact recipe of
the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this
visit. The bar is **extended** past hairline dividers to carry the actions *and* the
**NET** readout — one glance sees the tabs, the pending trade's credit delta, and the
button that executes it.
- **Deck lock (while the shop is up)** — the command deck's other slots (Build, Ship,
Quests, Take Off, Menu) go **standby**: dim, no hover, no press — the same inert
language as the comm-hold pause (`ActionBar.setSlotEnabled`). **LEAVE SHOP is the only
live button on the deck**; the press that opened the shop is the only way out. They
re-arm when you leave. (`ActionBar.setSlotEnabled` + `SurfaceScene.setDeckLocked`; the
deck-wide comm hold still owns input if it fires mid-shop.)
- **Confirm / Clear** — the same `MenuButton` style, on the **tab sub-bar** between the
tabs and the NET readout (they serve BOTH the trade and the buyback tabs, and persist
while you switch between them); Confirm is **disabled** (grayed) while the trade is
invalid (can't go negative, hold would overflow), and both appear once you start
picking.
**Determinism + the one stateful seam.** Prices and floors are **fixed** per
(good, location) — pure functions of the seed; reopening a shop re-derives the
same floor and prices. The **only** state is the origin's remaining daily batch
(`economyStock` — save/restore in `SaveData.js`), and it only ever *regenerates*
while you're away. So the economy stays "interact, don't control": you trade
against a fixed landscape, and the only clock in it runs in your favor.
- **SELL (left)** — your **cargo bays**. 16 cells (the fixed grid); the first `cargoHold`
are live bays (filled with a good, or empty), the rest are **locked** (the hold's upgrade
room). Tap a filled bay to **check it off to sell** (a bay = 1 unit). No stepper — the
quantity is simply the number of its bays that are checked.
- **BUY (right, next to the SELL grid)** — the shop's **stock** (12–16 distinct goods from
`Economy.shopFor`) as a **scrollable list of WIDE ROWS**: each row is the good's
**icon**, its **name** + a **description** (the good's `flavor`, a smaller font that
**wraps to up to two lines** and never overlaps the price/stepper), and the **price** +
a **−/qty/+ stepper** on the right. The stock is longer than the panel, so the list
**scrolls** (drag on the list body or the mouse wheel) — rows outside the window are
clipped; the selection is data, so stepping a good works whether its row is on screen
or not. Buy as many as you like (capped by credits + hold room).
- **NET readout (tab sub-bar)** — the live credit delta, at the bar's right end past the
second divider (right of the tabs and the Confirm/Clear actions).
Green when you're up (a net sale), red when you'd spend, dim at zero. Updates as you check
bays / step goods (it's the same `preview()` the Confirm button is gated on).
- **SELL → BUY is atomic** — check bays to sell *and* step goods to buy, then one Confirm
executes it: credits `+ sale − buy`, cargo `− sold + bought`, in that order (free room
first). Selling to free a bay for a buy lands in the same Confirm.
- **BUYBACK (second tab, session-local only)** — after you SELL something, the goods you sold
are listed here and can be bought **back** — at the **sale price** (a *neutral* undo, so
sell → buyback nets **zero** and can't loop into free credits). It's **locked** (dim, no-op)
until you've sold something, and it **disappears when you leave the shop** (no persistence —
`data/shopui.json`). Buyback is clamped to what you actually sold.
**Determinism + statelessness preserved.** The screen reads **fixed** per-(good, location)
prices (`Economy.buyPrice`/`sellPrice` — the seed + place, never where you got a good or what's
in your hold). There are **no per-player sold/bought counters**; reopening a shop re-derives
the same stock from `(seed, placeId, economyVersion)`. The only session state is the buyback
list (explicitly reset on leave). So the economy stays "interact, don't control": you trade
against fixed regional prices, never against your own history.
**Tested headlessly** (`dev/shop-ui.test.mjs`): construct the SELL grid + BUY list, sell a bay and earn the
sale price, step a stock good and pay the buy price, the two caps (credits / cargo) blocking
Confirm, the sell→buy-in-one-confirm, the buyback lock + neutral undo, and Clear. Pixel layout
and the slide-in animation are verified in the browser (the stub doesn't render).
**Tested headlessly** (`dev/shop-ui.test.mjs`, `dev/economy.test.mjs`,
`dev/cargo.test.mjs`): the full logic — sell, buy, the daily batch (clamp →
SOLD OUT → engine backstop), the two caps, the buyback neutral-undo, the scout
+ set-destination, the anti-loop sweep, and the save round-trip of the batch.
Pixel layout and the slide-in animation are verified in the browser.

View File

@ -1,33 +1,58 @@
/**
* The trade economy — a pure, deterministic price + stock engine.
* The trade economy — a pure, deterministic price engine (model v2).
*
* MODEL (full version in docs/ECONOMY.md §5, tuning in data/economy.json):
* MODEL — ONE WORLD, ONE GOOD (full version in docs/ECONOMY.md §5, tuning in
* data/economy.json):
*
* Scarcity first: every tradeable good has exactly ONE ORIGIN system — the
* single world whose shop sells it, cheap, in a DAILY BATCH (js/economy/
* Stock.js; the only stateful piece of the economy). The seeded strict
* assignment ('origin-assign' pass below) gives every system exactly one
* SIGNATURE good (a world's floor is that one product) and leaves the
* catalog's remainder as the HUB POOL (resold by stations/capitals at
* standard price; its distance is measured to the NEAREST HUB).
*
* For a good at a place there are TWO FIXED prices in credits, decided once
* at generation and NEVER dependent on where the player got the good or what
* is in their hold:
* at generation and NEVER dependent on where the player got the good or
* what is in their hold:
*
* BUY price (you pay the shop) — cheap where the place's class PRODUCES
* the good (its home region), standard
* where a hub resells it.
* SELL price (the shop pays you) — a premium where the class WANTS the
* good, growing with the hops between
* this system and the good's home region.
* BUY price (you pay the shop) — the ORIGIN world's batch price
* (baseValue × (1 − originDiscount)), or
* the hub's standard resale price
* (baseValue × hubPriceFactor) for
* hub-pool goods at stations/capitals.
* Everywhere else the good is simply
* NOT on sale — you carry it.
* SELL price (the shop pays you) — baseValue × distanceBand(hops to
* origin) × (1 + demandBonus if the
* class wants it), CAPPED at the shop's
* own sale price for the good (no shop
* may ever be a positive same-shop
* spread — the anti-loop rule).
*
* Distance is enforced by REGIONAL AVAILABILITY (goods only cheap in their
* home region), not a live per-trade multiplier. Credits are the go-between:
* buying spends them, selling earns them, no good-for-good barter.
* The BANDED DISTANCE term is the dominant factor in profit (it has no
* cap — the jump tree's ~17-hop diameter is the ceiling); planet type
* (demand) is the secondary modifier. Credits are the go-between: buying
* spends them, selling earns them, no good-for-good barter.
*
* Everything here is a pure function of (seed, place, catalog) — the same
* galaxy seed always yields the same shops, so lazy === eager and saves never
* store a shop's stock (see data/economy.json → restock).
* store a shop's floor or prices (see data/economy.json → restock). The one
* saved number per origin system is its day's remaining batch.
*
* The two layers:
* 1. buildEconomy(galaxy) — the generation pass (called once at roster build,
* after the jump network + faction pass). Produces `galaxy.economy`:
* classBySystem — Map systemId → Set of economic classes present
* producerHops — Map goodId → { systemId → hops-to-nearest-producer }
* Both are pure functions of the seed, so they're lazy-safe and save-free.
* signatures — Map systemId → goodId (the system's ONE signature
* good — the strict 1:1 origin assignment)
* origins — Map goodId → systemId (the good's ONE origin system;
* the catalog's remainder — the HUB POOL — has none)
* hubPool — array of good ids resold by stations/capitals
* hubSystems — array of system ids that host a hub (station/capital)
* producerHops — Map goodId → { systemId → hops to the good's origin
* (signatures) or nearest hub (hub pool) }
* All pure functions of the seed, so lazy-safe and save-free.
* 2. buyPrice / sellPrice / shopFor — the per-shop queries the UI and tests
* call with (goodId, class, systemId, galaxy).
*/
@ -94,7 +119,12 @@ export function economicClass(planet = null, settlement = null) {
return null;
}
/** True if a class PRODUCES the good (it's on sale cheap there). */
/**
* Does the catalog's class list mark `cls` as a natural producer of the good?
* CATALOG-LEVEL (data/goods.json → producer) — model v2 prices off the good's
* assigned ORIGIN SYSTEM (originOf), not its producer class; this is used by
* the origin assignment's compatibility (thematic fit) and by reports.
*/
export function produces(goodId, cls) {
const g = goodById(goodId);
return !!(g && cls && Array.isArray(g.producer) && g.producer.includes(cls));
@ -106,6 +136,34 @@ export function wants(goodId, cls) {
return !!(g && cls && Array.isArray(g.demand) && g.demand.includes(cls));
}
/**
* The good's ONE ORIGIN system (the strict 1:1 assignment — the only shop in
* the galaxy that sells it cheap). null = the good is in the HUB POOL
* (resold by stations/capitals, distance to the nearest hub).
*/
export function originOf(goodId, galaxy) {
const o = galaxy?.economy?.origins?.[goodId];
return typeof o === 'string' ? o : null;
}
/** The system's ONE signature good (a world's entire floor). */
export function signatureOf(systemId, galaxy) {
const s = galaxy?.economy?.signatures?.[systemId];
return typeof s === 'string' ? s : null;
}
/** Is the good part of the HUB POOL (resold by stations/capitals)? */
export function isHubPool(goodId, galaxy) {
const hp = galaxy?.economy?.hubPool;
if (!Array.isArray(hp)) return true; // no economy pass ran → hubs carry it
return !originOf(goodId, galaxy) && hp.includes(goodId);
}
/** Is `cls` a HUB class (station/capital — a reseller, not a producer)? */
export function isHubClass(cls) {
return cls === 'station' || cls === 'capital';
}
/**
* Is this good TRADEABLE — part of the shop economy at all? The catalog's
* quest cargo (data/goods.json → tradeable: false — 'Special Delivery''s
@ -164,43 +222,80 @@ function jitter(goodId, cls, systemId, seed) {
return 1 + (r * 2 - 1) * j;
}
/** Hops from a system to the nearest system that produces the good (0 = local). */
/** Hops from a system to the good's origin (signatures) or nearest hub (hub pool); 0 = local. */
function hopsToProducer(goodId, systemId, galaxy) {
const m = galaxy?.economy?.producerHops?.[goodId]?.[systemId];
return Number.isFinite(m) ? m : 0;
}
/**
* The BANDED DISTANCE factor (data/economy.json → pricing.distanceBands —
* ascending minHops → factor, the largest minHops ≤ hops wins; NO cap — the
* jump tree's diameter is the ceiling). This is the dominant term in the
* sell price: distance is the biggest factor in profit.
*/
export function distanceFactor(hops, bands) {
const list = Array.isArray(bands) ? bands : (econ().pricing?.distanceBands ?? []);
const h = Math.max(0, Math.floor(Number(hops) || 0));
let f = 1;
for (const b of list) {
const min = Number(b?.minHops);
if (Number.isFinite(min) && h >= min) f = Number(b?.factor) || 1;
}
return f;
}
// ---------------------------------------------------------------------------
// FIXED PRICES (the two credit numbers per good + shop)
// ---------------------------------------------------------------------------
/**
* The BUY price — what the player pays for one unit at this shop. Cheap where
* the class produces the good (producerDiscount), standard where a hub resells
* it. Fixed per (good, class, system); independent of the player's inventory.
* The BUY price — what the player pays for one unit at this shop (0 = not on
* sale here). Model v2: the good's ORIGIN world sells it at the batch price
* (originDiscount — the only cheap source in the galaxy; the batch itself is
* time-gated — js/economy/Stock.js); a HUB (station/capital) resells HUB-
* POOL goods at standard (hubPriceFactor — never below origin). Everything
* else, everywhere, is simply not on sale — you carry it.
* Fixed per (good, class, system); independent of the player's inventory.
*/
export function buyPrice(goodId, cls, systemId, galaxy) {
const g = goodById(goodId);
if (!g || !cls) return 0;
const p = econ().pricing ?? {};
const prod = produces(goodId, cls) ? (1 - (p.producerDiscount ?? 0)) : 1;
return Math.max(1, Math.round((Number(g.baseValue) || 0) * prod * jitter(goodId, cls, systemId, galaxy?.seed)));
const base = Number(g.baseValue) || 0;
const j = jitter(goodId, cls, systemId, galaxy?.seed);
// 1) THE ORIGIN — the good's home system sells it cheap (any shop there:
// the world, or the capital when it sits in the origin system).
if (systemId != null && originOf(goodId, galaxy) === systemId) {
return Math.max(1, Math.round(base * (1 - (p.originDiscount ?? 0.5)) * j));
}
// 2) HUBS resell the HUB POOL at standard — a convenience source, never a
// cheap one (origin is always strictly cheaper).
if (isHubClass(cls) && isHubPool(goodId, galaxy)) {
return Math.max(1, Math.round(base * (p.hubPriceFactor ?? 1) * j));
}
return 0;
}
/**
* The SELL price — what the shop pays the player for one unit. A premium where
* the class wants the good (demandBonusPerClass), growing with the hops
* between this system and the good's home region (distancePerHop, capped).
* The SELL price — what the shop pays the player for one unit. Model v2:
* baseValue × distanceBand(hops to origin) × (1 + demandBonus if the class
* wants it) — CAPPED at the shop's own sale price for the good (the
* anti-loop rule: no shop may ever be a positive same-shop spread, so a
* reseller's buyback of its own stock is always neutral).
* Fixed per (good, class, system); independent of where the player bought it.
*/
export function sellPrice(goodId, cls, systemId, galaxy) {
const g = goodById(goodId);
if (!g || !cls) return 0;
const p = econ().pricing ?? {};
const demand = wants(goodId, cls) ? 1 + (p.demandBonusPerClass ?? 0) : 1;
const hops = Math.min(hopsToProducer(goodId, systemId, galaxy), p.distanceCapHops ?? 6);
const dist = 1 + (p.distancePerHop ?? 0) * hops;
return Math.max(1, Math.round((Number(g.baseValue) || 0) * demand * dist * jitter(goodId, cls, systemId, galaxy?.seed)));
const demand = wants(goodId, cls) ? 1 + (p.demandBonus ?? 0.1) : 1;
const hops = hopsToProducer(goodId, systemId, galaxy);
const dist = distanceFactor(hops);
let price = (Number(g.baseValue) || 0) * demand * dist * jitter(goodId, cls, systemId, galaxy?.seed);
const own = buyPrice(goodId, cls, systemId, galaxy);
if (own > 0) price = Math.min(price, own); // anti-loop cap
return Math.max(1, Math.round(price));
}
// ---------------------------------------------------------------------------
@ -225,25 +320,28 @@ function seededSelect(items, n, seed, cls, systemId) {
}
/**
* The full shop for a settlement class at a system:
* buy — goods on SALE (what you can buy), 8–16 entries, each { id, price, produced }
* · plain world: its produced goods (abundant, cheap)
* · station: produced (≤ half) + a seeded selection of resold goods (variety)
* · capital: produced (≤ half) + a seeded selection of premium goods (tier 3–4)
* · the class's SIGNATURE GEM is ALWAYS on the floor (the world's
* "jewel" — a guaranteed premium good + a guaranteed local buy)
* sell — goods the shop BUYS BACK (what you can sell), each { id, price }
* · plain world / station: the goods the class wants
* · capital: EVERYTHING (the universal sink)
* The full shop for a settlement class at a system (model v2 — ONE WORLD,
* ONE GOOD):
* buy — what the shop SELLS:
* · world: EXACTLY ONE product — the system's signature good
* (its origin's batch price; the batch's remaining units
* are time-gated — js/economy/Stock.js, the shop UI shows
* the count / sold-out state)
* · station / capital: a seeded selection from the HUB POOL at
* standard price (variety for stations, the wider floor
* for capitals) + the system's own signature good at the
* origin price (when this system IS its origin)
* sell — what the shop BUYS BACK:
* · world / station: the goods the class WANTS (planet type — the
* secondary price modifier), at the banded distance price
* · capital: EVERYTHING (the universal sink — the long-haul exit)
* · always CAPPED at the shop's own sale price for a good (no shop
* is ever a positive same-shop spread)
* Pure in (cls, systemId, galaxy) — deterministic and re-derivable.
*/
export function shopFor(cls, systemId, galaxy) {
if (!cls) return { buy: [], sell: [] };
const shop = econ().shop ?? {};
const kind = cls === 'capital' ? 'capital' : cls === 'station' ? 'deepSpaceStation' : null;
const sk = shop.stockByKind?.[kind] ?? {};
const maxStock = sk.max ?? shop.maxStock ?? 16;
const minStock = sk.min ?? shop.minStock ?? 8;
// THE SHOP FLOOR's catalog — tradeable goods only. The quest cargo
// (tradeable: false, data/goods.json → special-package) is the one good
// the economy does not price: it never stocks, never resells, never
@ -253,53 +351,45 @@ export function shopFor(cls, systemId, galaxy) {
const goods = goodsList().filter((g) => isTradeable(g.id));
const seed = galaxy?.seed;
const core = goods.filter((g) => produces(g.id, cls));
const isHub = cls === 'station' || cls === 'capital';
let buyIds;
if (isHub) {
// Hubs: at most half the floor is their own produce (the rest is their
// reseller role — variety for stations, premium for capitals).
const coreCap = Math.max(1, Math.ceil(maxStock / 2));
const coreIds = seededSelect(core, coreCap, seed, cls, systemId);
const other = goods.filter((g) => !produces(g.id, cls));
const premium = cls === 'capital'
? other.filter((g) => (Number(g.tier) || 1) >= 3)
: other.filter((g) => (Number(g.tier) || 1) <= 3);
const fill = seededSelect(premium.length ? premium : other, maxStock - coreIds.length, seed, cls + '-resold', systemId);
buyIds = [...coreIds, ...fill];
} else {
buyIds = core.slice(0, maxStock).map((g) => g.id);
}
// Guarantee the floor reaches minStock when the catalog allows it.
if (buyIds.length < minStock) {
const have = new Set(buyIds);
for (const g of goods) {
if (buyIds.length >= minStock) break;
if (!have.has(g.id)) { buyIds.push(g.id); have.add(g.id); }
if (isHubClass(cls)) {
// HUBS: a seeded selection from the HUB POOL (the catalog's remainder —
// resellers, standard price), sized by kind (capital widest).
const kind = cls === 'capital' ? 'capital' : 'deepSpaceStation';
const sk = shop.stockByKind?.[kind] ?? {};
const maxStock = sk.max ?? shop.maxStock ?? 16;
const minStock = sk.min ?? shop.minStock ?? 8;
const pool = (galaxy?.economy?.hubPool ?? []).map((id) => goodById(id)).filter(Boolean);
const fill = seededSelect(pool, Math.min(maxStock, pool.length), seed, cls, systemId);
buyIds = [...fill];
// Guarantee the floor reaches minStock when the catalog allows it.
if (buyIds.length < minStock) {
const have = new Set(buyIds);
for (const g of goods) {
if (buyIds.length >= minStock) break;
if (!have.has(g.id)) { buyIds.push(g.id); have.add(g.id); }
}
}
} else {
// WORLDS: the floor is the system's ONE signature good — pure identity
// ("this is the world that makes this"). A world with no signature
// (no economy pass ran) gets an empty floor.
const sig = signatureOf(systemId, galaxy);
buyIds = sig ? [sig] : [];
}
// THE WORLD'S JEWEL (goods.json gemstones, §4/§6): the class's signature
// gem is ALWAYS on the shelf — a guaranteed premium good (tier 3–4) and a
// guaranteed local buy in every shop, whatever the seeded floor picked.
// It's a produced good of `cls`, so it prices at the producer discount;
// swap it into the least-important slot (a hub's resold filler, or an
// extra local good on a plain world) keeping the 8–16 floor size + no dups.
const gem = goods.find(
(g) => g.category === 'gemstone' && Array.isArray(g.producer) && g.producer.includes(cls),
);
if (gem && !buyIds.includes(gem.id)) {
if (buyIds.length < maxStock) buyIds.push(gem.id);
else buyIds[buyIds.length - 1] = gem.id;
}
// The system's OWN signature good rides EVERY shop's floor at the origin
// price (a world sells it as its one product; a capital/station in the
// origin system resells its home product too — batch-limited like the
// world's, since it's the same day's batch at the same source).
const sig = signatureOf(systemId, galaxy);
if (sig && !buyIds.includes(sig)) buyIds.push(sig);
const buy = buyIds.map((id) => ({
id,
name: goodById(id)?.name ?? id,
price: buyPrice(id, cls, systemId, galaxy),
produced: produces(id, cls),
produced: originOf(id, galaxy) === systemId,
}));
const sell = (cls === 'capital' ? goods : goods.filter((g) => wants(g.id, cls)))
@ -348,11 +438,17 @@ export function resultingCargo(cargo, sell = [], buy = []) {
* buyMap = { goodId: qty } — goods to buy (into the hold)
*
* Pricing is the FIXED per-(good, shop) price from buyPrice/sellPrice above —
* independent of where the good came from. The two caps:
* independent of where the good came from. The three caps:
* · credits — creditsAfter must be ≥ 0 (you can't spend into the red);
* · cargo — the END hold (aboard − sold + bought) must fit the unlocked
* bays (capacity). Selling to free room for a buy is allowed
* (the net end-state is what's checked, not the peak).
* (the net end-state is what's checked, not the peak);
* · stock — stockCaps = { goodId: unitsLeft } — the shop's REMAINING
* BATCH for batch-limited goods (the origin's daily stock —
* js/economy/Stock.js; the UI caps the stepper at the same
* number, this is the backstop). A buyback (buyOverrides) never
* draws on the batch — it returns what the shop JUST bought
* from the player.
* Pure in its inputs — the UI calls it on every selection change; a test can
* call it with no scene at all.
*
@ -374,6 +470,9 @@ export function tradePreview(o) {
// into the buy side — "buy it back for what you got for it", a neutral undo
// so sell→buyback can't net-profit into an infinite-credits loop).
const buyOverrides = o.buyOverrides ?? {};
// Per-good STOCK caps (the origin's remaining daily batch — unlimited when
// the good isn't batch-limited here; a buyback never consumes the batch).
const stockCaps = (o.stockCaps && typeof o.stockCaps === 'object') ? o.stockCaps : {};
const credits = Math.max(0, Number(o.credits) || 0);
const capacity = Math.max(0, Math.floor(Number(o.capacity) || 0));
@ -419,6 +518,13 @@ export function tradePreview(o) {
const errors = [];
if (creditsAfter < 0) errors.push('insufficient-credits');
if (cargoAfter > capacity) errors.push('cargo-full');
// THE BATCH CAP — a trade buy can't exceed the shop's remaining daily stock.
// A BUYBACK (an overridden buy — the shop returning what it JUST bought
// from the player) is structurally exempt: it never draws on the batch.
for (const b of buy) {
if (b.id in buyOverrides) continue;
if (b.id in stockCaps && b.qty > (stockCaps[b.id] ?? 0)) errors.push('insufficient-stock');
}
const valid = errors.length === 0;
return {
@ -462,16 +568,104 @@ function multiSourceBFS(sources, adj) {
}
/**
* The ECONOMY PASS — the galaxy-level trade map, computed once at roster build
* (after the jump network + faction pass, before any content generation). A
* pure function of the seed:
* THE ORIGIN ASSIGNMENT (model v2 — the 'origin-assign' pass, the model's
* core): the strict 1:1 matching behind ONE WORLD, ONE GOOD.
*
* · every tradeable good gets exactly ONE origin system (or none — it
* stays in the HUB POOL, resold by stations/capitals);
* · every system gets exactly ONE signature good (a world's floor);
* · the catalog's remainder (goods ≥ systems — 114 vs 90) is the HUB POOL.
*
* Seeded + greedy (most-constrained system first — fewest thematically
* compatible goods first, so the rare combinations land, never a filler),
* with a zone→tier fit (far systems, d→1, take the high tiers) and a seeded
* tie-break. Compatibility = the good's `producer` class list vs the
* system's classes — planet type is the SECONDARY fit signal (a lava world
* rarely grows kelp; a terran homeworld rarely exports obsidian ingots).
* Pure in (seed, version, catalog, galaxy) — the same seed, always the same
* map.
*/
export function assignOrigins(galaxy, classBySystem) {
const seed = galaxy.seed;
const version = econ().version ?? 2;
const goods = goodsList().filter((g) => isTradeable(g.id));
// BARREN systems (jump-gate-only — no planets, no stations, no shop) can't
// host a signature (there's no world to sell it from) — they stay
// signature-less by design; the systems that DO settle all participate.
const systems = [...galaxy.byId.keys()].filter((s) => (classBySystem.get(s)?.size ?? 0) > 0);
const compat = new Map(); // systemId → [goodIds it can plausibly make]
for (const s of systems) {
const classes = classBySystem.get(s) ?? new Set();
compat.set(s, goods.filter((g) => (g.producer ?? []).some((p) => classes.has(p))).map((g) => g.id));
}
const rng = Rng.derive(seed, 'economy', `origin-assign-v${version}`);
// Most-constrained first (fewest compatible goods); the localeCompare
// tie-break keeps the order fully deterministic.
const order = systems.slice().sort((a, b) =>
(compat.get(a).length - compat.get(b).length) || a.localeCompare(b));
const tierOf = (id) => Number(goodById(id)?.tier) || 1;
const dOf = (s) => {
const rec = galaxy.byId.get(s);
return Number.isFinite(rec?.d) ? rec.d : 0.5;
};
const remaining = new Set(goods.map((g) => g.id));
const signatures = {}; // systemId → goodId
const origins = {}; // goodId → systemId
let fallbackCount = 0;
/** Score a candidate: zone→tier fit (near wants tier 1, far wants tier 4)
* + seeded jitter — pick the best. Deterministic (strict > keeps the
* first max on ties). */
const pick = (cands, s) => {
let best = null;
for (const id of cands) {
if (!remaining.has(id)) continue;
const fit = 1 - Math.abs(tierOf(id) - (1 + dOf(s) * 3)) / 3;
const sc = fit + rng.next() * 0.5;
if (!best || sc > best.sc) best = { id, sc };
}
return best ? best.id : null;
};
for (const s of order) {
let id = pick(compat.get(s) ?? [], s);
if (!id) {
// THEMATIC FIT IMPOSSIBLE (or exhausted) — assign ANY remaining good:
// strictness always wins (every good needs an owner), the hub pool
// stays intact, and the thematics report counts it.
id = pick([...remaining], s);
fallbackCount += 1;
}
if (!id) break; // more systems than goods — cannot happen (114 > 90)
remaining.delete(id);
signatures[s] = id;
origins[id] = s;
}
return { signatures, origins, hubPool: [...remaining], fallbackCount };
}
/**
* The ECONOMY PASS — the galaxy-level trade map, computed once at roster
* build (after the jump network + faction pass, before any content
* generation). A pure function of the seed:
* classBySystem — Map systemId → Set of economic classes (the planet classes
* from the SAME roll the content generator uses, + the home
* world's class at home, + 'station' if it has a deep-space
* station, + 'capital' if it's a designated capital).
* producerHops — Map goodId → { systemId → hops to the nearest producer }.
* The distance term for sell prices: the further a system
* is from a good's home region, the higher the good sells.
* signatures — Map systemId → goodId (the system's ONE signature good).
* origins — Map goodId → systemId (the good's ONE origin system).
* hubPool — the catalog's remainder (resold by stations/capitals).
* hubSystems — the systems that host a hub (station/capital).
* producerHops — Map goodId → { systemId → hops to the good's origin
* (signatures) or the NEAREST HUB (hub pool) }.
* The distance the banded sell price is measured over:
* the further a system is from a good's source, the higher
* the good sells (the dominant profit factor).
* Stashed on `galaxy.economy` and read by buyPrice/sellPrice/shopFor.
*/
export function buildEconomy(galaxy) {
@ -479,6 +673,7 @@ export function buildEconomy(galaxy) {
const homeCls = config.get('planets.homePlanet', 'terran');
const classBySystem = new Map();
const hubSystems = [];
for (const [id, record] of galaxy.byId) {
const isHome = id === galaxy.homeSystemId;
const type = typeDefs[record.type] ?? {};
@ -496,19 +691,24 @@ export function buildEconomy(galaxy) {
if ((comp.stations ?? 0) > 0) set.add('station');
if (record.capital) set.add('capital');
classBySystem.set(id, set);
if (set.has('station') || set.has('capital')) hubSystems.push(id);
}
// THE ORIGIN ASSIGNMENT — the strict 1:1 match (the model's core).
const { signatures, origins, hubPool } = assignOrigins(galaxy, classBySystem);
const adj = adjacency(galaxy);
const producerHops = {};
for (const g of goodsList()) {
const producers = [];
for (const [id, set] of classBySystem) {
if (Array.isArray(g.producer) && g.producer.some((c) => set.has(c))) producers.push(id);
}
producerHops[g.id] = multiSourceBFS(producers, adj);
if (!isTradeable(g.id)) { producerHops[g.id] = {}; continue; } // quest cargo: no price, no hops
// Signatures: hops to THEIR origin system (the single source); hub-pool
// goods: hops to the NEAREST HUB (their reseller — that is the distance
// a player's trip is measured over when they carry one off a station).
const sources = origins[g.id] ? [origins[g.id]] : hubSystems;
producerHops[g.id] = multiSourceBFS(sources, adj);
}
galaxy.economy = { classBySystem, producerHops };
galaxy.economy = { classBySystem, signatures, origins, hubPool, hubSystems, producerHops };
return galaxy.economy;
}

99
js/economy/Stock.js Normal file
View File

@ -0,0 +1,99 @@
/**
* Stock — the origin world's time-gated daily batch.
*
* MODEL (model v2 — the one stateful piece of the economy, see
* docs/ECONOMY.md §5 + data/economy.json → stock): every ORIGIN system's
* signature good comes to market as a DAILY BATCH of `stock.dailyBatch`
* units. A DAY is `stock.dayMs` of wall-clock time past `stock.epoch` —
* the batch regenerates while the player is away, so the local buy→sell
* loop (buy at the origin world, sell to the same-system station) is a
* BOUNDED pocket change, never infinite money — while long-haul SELLs stay
* unlimited (the market is a sink; the buy side is the only limit).
*
* The SELL side is NEVER stock-limited: a shop always buys back at its
* fixed price. The state is the player's REMAINING batch per origin
* system — a tiny save-safe map:
*
* economyStock = { [systemId]: { day: <int>, left: <int> } }
*
* (absent key = a full batch for today; a stale `day` = today's full
* batch — the batch is per-DAY, so yesterday's remainder is worthless).
*
* Pure, framework-free, save-clone-safe (plain objects, integers). The
* scene owns the map (GameScene.economyStock — save/restore in
* js/save/SaveData.js); ShopView reads remaining() to render the row
* ("N left" / "SOLD OUT") and to cap the stepper; confirm() decrements
* via spend(). The day clock uses Date.now() (wall time — the batch is a
* real-day game, not a session one); tests pass an explicit `now`.
*/
import { config } from '../config/Config.js';
const stockCfg = () => (config.section('economy.stock', {}) ?? {});
/** Units per origin per day (data/economy.json → stock.dailyBatch). */
export function dailyBatch() {
return Math.max(1, Math.floor(Number(stockCfg().dailyBatch) || 6));
}
/** Milliseconds per DAY (the batch's clock period). */
export function dayMs() {
return Math.max(1000, Math.floor(Number(stockCfg().dayMs) || 3600000));
}
/** The batch epoch (ms) — the day counter starts here (0 = the Unix epoch). */
export function epochMs() {
const e = stockCfg().epoch;
const t = typeof e === 'string' ? Date.parse(e) : (Number.isFinite(e) ? Number(e) : NaN);
return Number.isFinite(t) ? t : 0;
}
/**
* The current DAY (integer) for a wall-clock instant: floor((now − epoch)
* / dayMs). The batch's identity — a purchase belongs to the day it was
* made, and the batch regenerates when the day rolls over (wall clock,
* so an away player finds a fresh batch on return).
*/
export function economyDay(now = Date.now()) {
const t = Number.isFinite(now) ? Number(now) : Date.now();
return Math.floor(Math.max(0, t - epochMs()) / dayMs());
}
/**
* Remaining batch for an origin system on a given day.
* map — the player's stock map { [systemId]: { day, left } } (save).
* systemId — the ORIGIN system (the batch is per origin, per day).
* day — the day to check (default: the live economyDay()).
* @returns {number} 0..dailyBatch() — a full batch when the system has no
* entry for that day (fresh) or its entry is from another day (stale).
*/
export function batchRemaining(map, systemId, day = economyDay()) {
const rec = map && typeof map === 'object' ? map[systemId] : null;
if (rec && Number(rec.day) === Number(day) && Number.isFinite(rec.left)) {
return Math.max(0, Math.min(dailyBatch(), Math.floor(Number(rec.left))));
}
return dailyBatch();
}
/**
* The stock map AFTER a purchase (pure — returns a new map; the scene
* swaps the reference in, the save picks it up). A dry day is recorded as
* `{ day, left: 0 }` (NOT dropped — an absent entry reads as a FULL batch
* for the day, so a dry system must keep its zero entry). qty over the
* remaining batch is clamped (the UI + tradePreview both enforce the cap;
* this is the last line of defense). Stale-day entries for OTHER systems
* are pruned (worthless after the day rolls).
*/
export function spend(map, systemId, qty, day = economyDay()) {
const left = batchRemaining(map, systemId, day) - Math.max(0, Math.floor(Number(qty) || 0));
const out = {};
if (map && typeof map === 'object') {
for (const [k, v] of Object.entries(map)) {
if (k === systemId) continue;
if (v && typeof v === 'object' && Number(v.day) === Number(day) && Number.isFinite(v.left)) {
out[k] = { day: Number(v.day), left: Math.max(0, Math.floor(Number(v.left))) };
}
}
}
out[systemId] = { day: Number(day), left: Math.max(0, Math.min(dailyBatch(), left)) };
return out;
}

View File

@ -6,11 +6,15 @@
* trader's questions:
*
* · WHAT is this good (name, category, tier, flavor)?
* · WHERE is it GROWN (the producer classes — cheap to buy there)?
* · WHO WANTS it (the demand classes — the premium sell exits)?
* · WHERE is it MADE (model v2 — the ONE ORIGIN system: the single world
* that sells it cheap in a daily batch; hub-pool goods name their
* reseller role — stations/capitals) — producer classes shown as the
* secondary "made of" hint
* · WHO WANTS it (the demand classes — the premium sell exits)
* · WHERE does it sell BEST — the top-N CHARTED (visited) systems ranked
* by the SELL price they'd pay, each with the per-unit PROFIT vs. the
* player's local buy price here.
* by the SELL price they'd pay, each with the per-unit PROFIT vs.
* the player's local buy price here (the banded distance price — the
* dominant factor).
*
* Everything is a PURE function of (catalog, galaxy, the run's visited set)
* — the same deterministic pricing Economy.js feeds the shop — so it's
@ -98,6 +102,7 @@ function objectForClass(cls, content, isHome = false, homeCls = null) {
* @returns {{
* id:string, name:string, flavor:string, category:string|null, tier:number|null,
* localBuy:number, localSell:number|null,
* origin:{ systemId:string|null, systemName:string|null, hub:boolean } | null,
* producer:Array<{cls,label,economy}>, demand:Array<{cls,label,economy}>,
* exits:Array<{systemId,systemName,cls,clsLabel,price,profit,hops,objectId,objectName}>,
* bestExit:object|null, maxProfit:number, charted:number, topN:number
@ -112,6 +117,14 @@ export function scoutGood(goodId, o = {}) {
const localBuy = Number.isFinite(o.buyUnit) ? Math.max(0, Math.round(o.buyUnit)) : E.buyPrice(goodId, cls, systemId, galaxy);
const localSell = sellsHere(goodId, cls) ? E.sellPrice(goodId, cls, systemId, galaxy) : null;
// THE ORIGIN (model v2) — the ONE system that makes this good (the world
// whose shop sells it cheap, daily batch). Hub-pool goods have none —
// they ride the station/capital reseller network.
const originSys = E.originOf(goodId, galaxy);
const origin = originSys
? { systemId: originSys, systemName: galaxy?.byId?.get?.(originSys)?.name ?? originSys, hub: false }
: { systemId: null, systemName: null, hub: true };
const producer = (Array.isArray(g.producer) ? g.producer : []).map(classInfo);
const demand = (Array.isArray(g.demand) ? g.demand : []).map(classInfo);
@ -172,6 +185,7 @@ export function scoutGood(goodId, o = {}) {
baseValue: Number.isFinite(g.baseValue) ? g.baseValue : null,
localBuy,
localSell,
origin,
producer,
demand,
exits: top,

View File

@ -32,6 +32,11 @@
* // steps (monotonic witness)
* totalMined: number, // lifetime asteroid mining
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
* economyStock: { [sysId]: { day, left } }, // the origin worlds'
* // REMAINING daily batches
* // (model v2's one
* // stateful economy piece
* // — js/economy/Stock.js)
* story: { capitalLanded, launchedFromCapital, lastLandedWorld, delivered },
* // the 'Special Delivery' witnesses + the surface TRADE gate
* // (GameScene._recordLanding / onSurfaceDeparture /
@ -61,6 +66,27 @@ import { SAVE_FORMAT, SaveManager } from './SaveManager.js';
export const PENDING_RESTORE_KEY = 'orbit.pendingRestore';
/**
* Normalize the economy stock map to its save-safe shape —
* { [systemId]: { day: int, left: int } } (plain, integers only; a
* corrupted entry is dropped, not propagated). The economy's one
* stateful piece: the origin worlds' REMAINING daily batches
* (js/economy/Stock.js — the time-gated buy side; the sell side is
* unlimited and the prices/floors are pure seed derivations).
*/
function cleanEconomyStock(map) {
const out = {};
if (map && typeof map === 'object') {
for (const [k, v] of Object.entries(map)) {
if (typeof k !== 'string' || !k) continue;
if (!v || typeof v !== 'object') continue;
if (!Number.isFinite(v.day) || !Number.isFinite(v.left)) continue;
out[k] = { day: Math.max(0, Math.floor(Number(v.day))), left: Math.max(0, Math.floor(Number(v.left))) };
}
}
return out;
}
/**
* Snapshot the live game into a save record.
*
@ -194,6 +220,13 @@ export function captureState(scene, now) {
// these out (world + map). A save predating it has no field; the
// restore stages an empty set (old saves load).
depletedClusters: Array.from(scene.depletedClusters ?? []),
// THE ORIGIN WORLDS' REMAINING DAILY BATCHES (model v2 — the economy's
// one stateful piece, the time-gated BUY side: the world's signature
// good sells out for the day and regenerates on the day clock;
// js/economy/Stock.js). { [systemId]: { day, left } }. A save
// predating it has no field; the restore stages {} (a full batch for
// today — old saves load).
economyStock: cleanEconomyStock(scene.economyStock),
// The SYSTEM research category's activation keys (which jump gates —
// incl. other systems' return gates — the run has activated; the
// per-run world state GameScene keeps in its registry-backed set).
@ -309,6 +342,11 @@ export function prepareLoad(registry, record) {
'depletedClusters',
new Set(Array.isArray(record.depletedClusters) ? record.depletedClusters : []),
);
// THE ORIGIN WORLDS' REMAINING DAILY BATCHES (model v2 — a save
// predating it has no field → {} — a full batch for today; old saves
// load). Normalized so a hand-edited record can't leak a bad shape into
// the scene (Stock.batchRemaining re-checks the day + range anyway).
registry.set('economyStock', cleanEconomyStock(record.economyStock));
// The run's COMMS played-ledger (a save predating comms has no field →
// an empty set — old saves load; a fresh run's welcome arms on its own
// 'newGame' trigger in GameScene.create()).
@ -367,6 +405,7 @@ export function resetRunState(registry) {
registry.set('quests', null);
registry.set('totalMined', null);
registry.set('depletedClusters', null);
registry.set('economyStock', null); // the new run's batches are full
registry.set('story', null); // the bar, the handoff, the leg — all fresh
registry.set('commsPlayed', null); // the welcome arms again on the new run
registry.set('vignettesPlayed', null); // the scenes re-arm on the new run

View File

@ -35,6 +35,7 @@ import { ActionBar } from '../ui/ActionBar.js';
import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js';
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
import * as Stock from '../economy/Stock.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
@ -362,6 +363,18 @@ export class GameScene extends Phaser.Scene {
// registry), the current system record, and its lazy contents — the
// dossier below and the world's other planets both read them.
this.ensureGalaxy();
// THE ORIGIN WORLDS' DAILY BATCHES (model v2 — the economy's one
// stateful piece, the time-gated BUY side: each origin world's
// signature good sells a batch per day and regenerates on the day
// clock; js/economy/Stock.js). Registry-backed run state like
// depletedClusters (save/restore in js/save/SaveData.js); a fresh run
// starts full everywhere (the empty map reads as "full batch for
// today"), and spendStock() below swaps the reference in place.
this.economyStock = this.registry.get('economyStock') ?? null;
if (!this.economyStock) {
this.economyStock = {};
this.registry.set('economyStock', this.economyStock);
}
// THE EFFECTS DEMO (?fx=<type> — js/visuals/SystemEffects.js): drop
// the run into the richest system of that archetype (ranked in
// js/galaxy/FxSystems.js), fully charted, all gates live — a clean
@ -3426,6 +3439,25 @@ export class GameScene extends Phaser.Scene {
this.creditsHud.set(this.ship.credits);
}
/**
* THE DAILY BATCH SPEND (model v2 — the economy's one stateful seam).
* After a confirmed trade, the ORIGIN system's remaining batch drops by
* the units the player bought of its signature good (the buy side is
* the only stock-limited side — the sell side is unlimited, the market
* is a sink). Pure math in js/economy/Stock.js (spend returns a NEW map
* — the save-safe reference swap); this just owns the reference.
* ShopView.confirm() calls it for each batch-limited good bought (its
* remaining() is finite only when THIS system is that good's origin).
* A dry day is recorded as { day, left: 0 } — an ABSENT entry would
* read as a full batch for the day.
*/
spendStock(systemId, qty) {
if (!systemId) return this;
this.economyStock = Stock.spend(this.economyStock, systemId, qty);
this.registry.set('economyStock', this.economyStock);
return this;
}
/**
* The mining hum (data/sfx.json → mining_loop, assets/fx/mining-01.mp3):
* a loop that starts when the beam goes live (phase 'mining') and

View File

@ -415,7 +415,13 @@ export class BuyList extends Phaser.GameObjects.Container {
*/
refresh(o = {}) {
const stock = Array.isArray(o.stock) ? o.stock : [];
this.stock = stock.map((s) => (typeof s === 'string' ? { id: s } : { id: s.id, name: s.name, price: s.price }));
// `left` = the shop's REMAINING BATCH for this row (the origin's daily
// stock — js/economy/Stock.js). Finite on batch-limited rows (a world's
// signature good); null/absent = unlimited (hubs' resale, the old model).
this.stock = stock.map((s) => (typeof s === 'string'
? { id: s }
: { id: s.id, name: s.name, price: s.price,
left: Number.isFinite(s.left) ? Math.max(0, Math.floor(s.left)) : null }));
this.buyQty = new Map();
this._destroyRows();
for (const s of this.stock) this._addRow(s);
@ -436,7 +442,7 @@ export class BuyList extends Phaser.GameObjects.Container {
const price = Math.round(Number(s.price) || 0);
const flavor = this._flavorOf(id);
const row = { id, name, desc: '', price, objs: {}, y0: 0, shown: true };
const row = { id, name, desc: '', price, left: Number.isFinite(s.left) ? Math.max(0, Math.floor(s.left)) : null, objs: {}, y0: 0, shown: true };
// row plate (bg + border) — the whole row's width, in the viewport
const bg = this.scene.add?.graphics?.();
@ -466,7 +472,7 @@ export class BuyList extends Phaser.GameObjects.Container {
);
if (nameEl) { nameEl.setOrigin?.(0, 0.5); row.objs.name = nameEl; this._objs.push(nameEl); this.add?.(nameEl); }
row.desc = this._fitText(flavor, descFont, this.descLines);
row.desc = this._fitText(flavor, descFont, this.descLines, row.left != null ? this._descMaxW({ left: 1 }) : null);
const descEl = this._makeText(
textX, 0, row.desc,
{ fontSize: `${descFont}px`, color: bl.desc?.color ?? '#7d92c4', align: 'left' },
@ -489,6 +495,19 @@ export class BuyList extends Phaser.GameObjects.Container {
);
if (priceEl) { priceEl.setOrigin?.(1, 0.5); row.objs.price = priceEl; this._objs.push(priceEl); this.add?.(priceEl); }
// REMAINING BATCH (model v2 — the origin world's daily stock, the one
// stateful number in the economy): "3 left" sits just left of the price
// (right-aligned, like it); 0 = SOLD OUT (magenta, the stepper is
// clamped at the same number). Only batch-limited rows have one —
// hubs' resale is unlimited (left === null → no element).
if (row.left != null) {
const leftEl = this._makeText(
0, 0, row.left > 0 ? `${row.left} left` : 'SOLD OUT',
{ fontSize: `${bl.price?.fontSize ?? 14}px`, color: row.left > 0 ? (bl.price?.color ?? '#ffc94d') : '#ff2d6f', align: 'right' },
);
if (leftEl) { leftEl.setOrigin?.(1, 0.5); row.objs.left = leftEl; this._objs.push(leftEl); this.add?.(leftEl); }
}
// stepper: − qty + (the qty tap = +1, the old grid's behavior).
// Centered origin: _btnXs() positions each stepper at its intended
// CENTER, so render the glyph centered on that point (name/desc/price
@ -570,19 +589,24 @@ export class BuyList extends Phaser.GameObjects.Container {
}
// Max description width (text block → price), in px. The price reserves
// its full width ("1,240 cr") so the description never collides with it.
_descMaxW() {
return this._btnXs().price - this.priceW - 14 - this._textX();
// its full width ("1,240 cr") so the description never collides with it;
// batch-limited rows reserve the "N left" / "SOLD OUT" strip between the
// desc and the price (`row.left` finite → the extra reservation).
_descMaxW(row = null) {
let w = this._btnXs().price - this.priceW - 14 - this._textX();
if (row && row.left != null) w -= (this.stockW ?? 66);
return Math.max(20, w);
}
// Truncate `str` to fit ~maxW px over `maxLines` line(s) at fontSize
// (deterministic char estimate — no text metrics needed; the headless
// stubs have none). Real Phaser word-wraps at _descMaxW() per line;
// this cap is the fail-safe that keeps the total inside maxLines lines.
_fitText(str, fontSize, maxLines = 1) {
_fitText(str, fontSize, maxLines = 1, maxW = null) {
const s = String(str ?? '');
if (!s) return '';
const perLine = Math.max(4, Math.floor(this._descMaxW() / (fontSize * 0.55)));
const w = Math.max(20, maxW ?? this._descMaxW());
const perLine = Math.max(4, Math.floor(w / (fontSize * 0.55)));
const maxChars = perLine * Math.max(1, Math.floor(Number(maxLines) || 1));
if (s.length <= maxChars) return s;
return `${s.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
@ -666,11 +690,12 @@ export class BuyList extends Phaser.GameObjects.Container {
o.name?.setPosition?.(tx, y - this.rowH * 0.18);
o.desc?.setPosition?.(tx, y + this.rowH * 0.27);
o.price?.setPosition?.(cx + bx.price, y);
o.left?.setPosition?.(cx + bx.price - this.priceW - 8, y);
o.minus?.setPosition?.(cx + bx.minus, y);
o.qty?.setPosition?.(cx + bx.qty, y);
o.plus?.setPosition?.(cx + bx.plus, y);
if (row.shown) {
for (const el of [o.icon, o.name, o.desc, o.price, o.minus, o.qty, o.plus]) {
for (const el of [o.icon, o.name, o.desc, o.price, o.left, o.minus, o.qty, o.plus]) {
el?.setAlpha?.(this._elInVp(el, vpTop, vpBot) ? 1 : 0);
}
} else {
@ -687,9 +712,14 @@ export class BuyList extends Phaser.GameObjects.Container {
/** Adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
bumpBuy(id, d = 1) {
if (!id) return this;
if (!this.stock.some((s) => s.id === id)) return this; // not in stock
const row = this.stock.find((s) => s.id === id);
if (!row) return this; // not in stock
const cur = this.buyQty.get(id) ?? 0;
const next = Math.max(0, cur + Math.floor(Number(d) || 0));
// THE BATCH CLAMP (model v2) — batch-limited rows (a world's signature
// good) can't be selected past the shop's remaining daily stock;
// unlimited rows (hubs) have left === null → no cap.
const cap = row.left != null ? row.left : Infinity;
const next = Math.max(0, Math.min(cap, cur + Math.floor(Number(d) || 0)));
if (next === 0) this.buyQty.delete(id);
else this.buyQty.set(id, next);
this._renderRow(id);

View File

@ -9,8 +9,8 @@
* │ └────┘ 9 CR │
* │ The lifeblood… │
* │ ────────────────────────── │
* │ GROWN ON Gas Giant │
* │ SOUGHT ON Rocky · Ice … │
* │ MADE IN New Concord … │ ← model v2: the ONE ORIGIN system
* │ SOUGHT ON Rocky · Ice … │ (the world that makes it, daily batch)
* │ ────────────────────────── │
* │ BEST EXITS │
* │ ① New Concord 41 cr │ ← TAP A ROW → "SET DESTINATION" popup
@ -196,8 +196,17 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
return t;
};
let y = y0 + 22;
label(y, 'Grown on');
const pro = chips(y, d.producer);
// THE ORIGIN (model v2) — the ONE system that makes this good (its
// world's shop sells it cheap, in a daily batch). Hub-pool goods have
// no origin world — they ride the station/capital reseller network.
// The producer classes ride along as the "made of" hint (planet type
// — the secondary fit signal, the world's thematic family).
label(y, 'Made in');
const originChips = [
{ label: d.origin?.systemName ?? (d.origin?.hub ? 'HUB RESALE' : '—') },
...(Array.isArray(d.producer) ? d.producer : []).map((c) => ({ label: c.label })),
];
const pro = chips(y, originChips);
y += (pro?.height ?? 14) + 10;
label(y, 'Sought on');
const dem = chips(y, d.demand);

View File

@ -49,6 +49,7 @@
import Phaser from '../../vendor/phaser.js';
import { config } from '../../config/Config.js';
import * as E from '../../economy/Economy.js';
import * as Stock from '../../economy/Stock.js';
import { scoutGood } from '../../economy/TradeScout.js';
import { MenuButton } from '../MenuButton.js';
import { toColor, toCss } from '../../utils/Color.js';
@ -529,7 +530,7 @@ export class ShopView extends Phaser.GameObjects.Container {
// Data + live preview wiring.
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
buyList.refresh({ stock: E.shopFor(this.cls, this.systemId, this.galaxy).buy });
this._refreshBuyStock(); // rows + the "N left" strip (the daily batch)
sellGrid.onSelectionChange = () => this._syncPreview();
buyList.onSelectionChange = () => this._syncPreview();
// The BUY/SELL toggle closed the panel WITH a pending trade? Re-apply
@ -625,6 +626,56 @@ export class ShopView extends Phaser.GameObjects.Container {
return m;
}
// ---------------------------------------------------------------------
// THE DAILY BATCH (model v2 — the economy's one stateful seam)
// ---------------------------------------------------------------------
/**
* The run's stock map (the origin worlds' remaining daily batches —
* GameScene.economyStock, registry-backed, save/restore in
* js/save/SaveData.js). Read LIVE on every query so a confirmed trade
* (which swaps the reference) is visible without a rebuild.
* @returns {{[systemId:string]: {day:number,left:number}}}
*/
stockMap() {
const m = this.gameScene?.economyStock;
return (m && typeof m === 'object') ? m : {};
}
/**
* The remaining batch for a good AT THIS SHOP — finite (0..dailyBatch)
* only when THIS system is the good's ORIGIN (the world's signature
* good, the one batch-limited row; its batch is per day, regenerated
* on the day clock — js/economy/Stock.js). null = unlimited (hub
* resale; the good isn't this system's signature). The buy row's
* "N left" / "SOLD OUT" strip and the stepper's cap both read this.
* @returns {number|null}
*/
stockRemaining(goodId) {
if (!goodId || !this.galaxy) return null;
if (E.originOf(goodId, this.galaxy) !== this.systemId) return null;
return Stock.batchRemaining(this.stockMap(), this.systemId);
}
/** The stockCaps map for a buy side (only batch-limited goods at their
* origin carry a cap — tradePreview's backstop; the UI clamps first). */
stockCapsFor(buyMap) {
const caps = {};
for (const id of Object.keys(buyMap ?? {})) {
const left = this.stockRemaining(id);
if (left != null) caps[id] = left;
}
return caps;
}
/** Refresh the BUY list's rows with the current batch remainders. */
_refreshBuyStock() {
if (!this.buyList) return;
const stock = E.shopFor(this.cls, this.systemId, this.galaxy).buy.map((r) => ({
...r, left: this.stockRemaining(r.id),
}));
this.buyList.refresh({ stock });
}
// ---------------------------------------------------------------------
// PREVIEW + CONFIRM + CLEAR (the testable core)
// ---------------------------------------------------------------------
@ -654,6 +705,11 @@ export class ShopView extends Phaser.GameObjects.Container {
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
cargo, capacity, credits,
sellMap, buyMap,
// THE BATCH CAP (model v2) — a buy of this system's signature good
// can't exceed its remaining daily stock (the UI clamps the stepper
// at the same number; this is the backstop). Hubs' resale + buybacks
// carry no cap (the sell side is always unlimited).
stockCaps: this.stockCapsFor(buyMap),
});
}
@ -664,6 +720,15 @@ export class ShopView extends Phaser.GameObjects.Container {
return false;
}
this._applyTrade(pv);
// THE DAILY BATCH (model v2) — the buy side is the only stock-limited
// side: each batch-limited good bought (finite remaining = this system
// is its origin) drops the origin's remaining batch by its units.
// The SELL side never consumes stock (the market is a sink), and a
// BUYBACK returns what the shop JUST bought from the player (neutral
// undo — it never draws on the batch either).
for (const b of pv.buy) {
if (this.stockRemaining(b.id) != null) this.gameScene?.spendStock?.(this.systemId, b.qty);
}
// The 'Special Delivery' step 3 — the parcel's HOME-WORLD SALE (zero
// credits) is the delivery itself. Only quest cargo (tradeable:false
// — the package) can ride a sell here (everywhere else the grid
@ -722,6 +787,9 @@ export class ShopView extends Phaser.GameObjects.Container {
this._pendingBuy = null;
}
this.sellGrid?.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
// The BUY rows re-render with the batch's NEW remainder (the confirm
// just spent from it — "6 left" → "3 left" → "SOLD OUT").
this._refreshBuyStock();
this.refreshHud();
this._syncPreview();
this._refreshTabs();