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:
parent
f0578f1a38
commit
828a5b8c38
|
|
@ -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": {
|
"shop": {
|
||||||
"_comment": "Stock size per settlement kind. `station` and `capital` get wider stock (more variety to resell) than a single world.",
|
"_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": 8,
|
"minStock": 1,
|
||||||
"maxStock": 16,
|
"maxStock": 3,
|
||||||
"stockByKind": {
|
"stockByKind": {
|
||||||
"colony": { "min": 8, "max": 12 },
|
"capital": { "min": 12, "max": 16 },
|
||||||
"miningStation":{ "min": 8, "max": 12 },
|
"deepSpaceStation": { "min": 10, "max": 16 }
|
||||||
"cloudBase": { "min": 8, "max": 12 },
|
|
||||||
"capital": { "min": 12, "max": 16 },
|
|
||||||
"deepSpaceStation": { "min": 10, "max": 16 }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"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.",
|
"_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.",
|
||||||
"demandBonusPerClass": 0.12,
|
"originDiscount": 0.50,
|
||||||
"producerDiscount": 0.35,
|
"hubPriceFactor": 1.00,
|
||||||
"distancePerHop": 0.22,
|
"demandBonus": 0.10,
|
||||||
"distanceCapHops": 6,
|
"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
|
"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": {
|
"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",
|
"capacitySource": "ship.stats.cargoHold",
|
||||||
"maxSlots": 16,
|
"maxSlots": 16,
|
||||||
"unitPerGood": 1
|
"unitPerGood": 1
|
||||||
},
|
},
|
||||||
"credits": {
|
"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,
|
"startingCredits": 0,
|
||||||
"display": { "nextTo": "mineralHud", "anchor": "upper-right" },
|
"display": { "nextTo": "mineralHud", "anchor": "upper-right" },
|
||||||
"tradingLockedUntilQuest": 3,
|
"tradingLockedUntilQuest": 3,
|
||||||
"earning": ["sellGoods", "questRewards", "miningLaserGems (later)"]
|
"earning": ["sellGoods", "questRewards", "localBatchLoop", "miningLaserGems (later)"]
|
||||||
},
|
},
|
||||||
"restock": {
|
"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).",
|
"_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",
|
"policy": "deterministic-derivation + daily-batch",
|
||||||
"stockIsPureFunction": true,
|
"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": {
|
"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.",
|
"_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,
|
"tariffRange": 0.05,
|
||||||
"patronGoods": {}
|
"patronGoods": {}
|
||||||
},
|
},
|
||||||
"version": 1
|
"version": 2
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"classes": {
|
||||||
"terran": { "label": "Terran World", "economy": "temperate biosphere — food, water, living things" },
|
"terran": { "label": "Terran World", "economy": "temperate biosphere — food, water, living things" },
|
||||||
"arid": { "label": "Arid World", "economy": "dry sun-world — salt, silica, solar, spice" },
|
"arid": { "label": "Arid World", "economy": "dry sun-world — salt, silica, solar, spice" },
|
||||||
|
|
|
||||||
|
|
@ -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 ==');
|
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 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 cls = [...galaxy.economy.classBySystem.get(sysId)].find((c) => c !== 'capital') ?? 'rocky';
|
||||||
const good = goods.find((g) => g.id) ; // any catalog good
|
const goodId = sigId; // THE good this shop sells (origin batch price)
|
||||||
const goodId = good.id;
|
|
||||||
const maxSlots = config.get('economy.cargo.maxSlots', 16);
|
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 ------------------------
|
// --- 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) ----------------
|
// --- a mixed trade (different goods, sell + buy at once) ----------------
|
||||||
{
|
{
|
||||||
const a = goods[0].id;
|
// A HUB sells two different goods (its own signature at the origin price
|
||||||
const b = goods[1].id;
|
// + 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 cargo = [a, a, b]; // two a, one b
|
||||||
const pv = E.tradePreview({
|
const pv = E.tradePreview({
|
||||||
cls, systemId: sysId, galaxy, cargo,
|
cls: hubCls, systemId: hubSys, galaxy, cargo,
|
||||||
sellMap: { [a]: 2 }, buyMap: { [b]: 2 },
|
sellMap: { [a]: 2 }, buyMap: { [b]: 2 },
|
||||||
credits: 100000, capacity: CAP,
|
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.sellTotal, 2 * E.sellPrice(a, hubCls, hubSys, 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.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.net, pv.sellTotal - pv.buyTotal, 'net = sale − purchase');
|
||||||
assert.equal(pv.cargoAfter, 3 - 2 + 2, 'end hold = 3 − 2 + 2 = 3');
|
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');
|
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('TRADE PREVIEW: sell/buy pricing, both caps, loop, clamps, mix: OK');
|
||||||
|
|
||||||
console.log('\nAll cargo checks passed \u2714');
|
console.log('\nAll cargo checks passed \u2714');
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,29 @@
|
||||||
*
|
*
|
||||||
* Drives the real trade engine (js/economy/Economy.js) against a real
|
* Drives the real trade engine (js/economy/Economy.js) against a real
|
||||||
* generated galaxy (js/galaxy/Galaxy.js) and the real catalog + tuning
|
* generated galaxy (js/galaxy/Galaxy.js) and the real catalog + tuning
|
||||||
* (data/goods.json, data/economy.json). Asserts the model in
|
* (data/goods.json, data/economy.json). Asserts MODEL v2 in
|
||||||
* docs/ECONOMY.md §5:
|
* docs/ECONOMY.md §5 (ONE WORLD, ONE GOOD):
|
||||||
*
|
*
|
||||||
* - THE PASS RAN: galaxy.economy has classBySystem (every system) + a
|
* - THE PASS RAN: galaxy.economy has classBySystem (every system), a
|
||||||
* producerHops map for every good; the home system carries terran
|
* STRICT 1:1 signature/origin assignment (every settled system has
|
||||||
* (the home world) + its composition classes.
|
* exactly one signature good; the catalog's remainder is the hub
|
||||||
* - DETERMINISM: same seed ⇒ identical prices + shop floors; a different
|
* pool), + producerHops for every good.
|
||||||
* seed ⇒ a different economy (the trade map is a pure function of the
|
* - DETERMINISM: same seed ⇒ identical prices + floors; a different
|
||||||
* seed, lazy-safe and save-free).
|
* seed ⇒ a different economy (pure function of the seed, lazy-safe,
|
||||||
* - FIXED PRICES: buyPrice/sellPrice are stable for (good, class, system)
|
* save-free).
|
||||||
* — independent of where the player got the good or what's in their hold.
|
* - THE ORIGIN: a good is on sale ONLY at its origin (its world's
|
||||||
* - THE LOOP: carrying a good far from its home region sells for MORE than
|
* signature, the daily batch) or — hub-pool goods — at hubs; off-
|
||||||
* it cost to buy at the source (buy cheap where produced, dear where
|
* origin the buy price is 0. Sells are a fixed positive function.
|
||||||
* wanted + far) — the trip is the reward.
|
* - THE LOOP: the origin sells at ~half base (the discount); a far
|
||||||
* - DISTANCE: the further a system is from a good's producer, the higher
|
* demander (≥ 4 hops) pays MORE — the trip is the reward, distance
|
||||||
* the good sells there (the hop term, capped).
|
* the dominant term.
|
||||||
* - SHOP FLOOR: 8–16 goods; locally produced goods present and dominant;
|
* - DISTANCE: the further from the origin, the higher the sell price
|
||||||
* the class's wanted goods in the buy-back list; a station carries
|
* (the banded term, monotone).
|
||||||
* resold goods (variety); a capital buys back EVERYTHING (universal sink).
|
* - 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';
|
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));
|
const missing = wantClasses.filter((c) => !present.has(c));
|
||||||
check(`every catalog class exists in the galaxy (missing: ${missing.join(', ') || 'none'})`, missing.length === 0);
|
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
|
// MODEL v2 — ONE WORLD, ONE GOOD: the origin assignment is a strict
|
||||||
// possible) — the quest cargo is exempt: nothing produces the package,
|
// 1:1 (a settled system ↔ a signature good); the catalog's remainder is
|
||||||
// it arrives by the quest's handoff, not by the trade map.
|
// the hub pool (resold by stations/capitals). The quest cargo is exempt
|
||||||
const unproducible = tradeable.filter((x) => !tradeable.length || ![...g.economy.classBySystem.values()].some((s) => x.producer.some((c) => s.has(c))));
|
// (nothing produces the package — the quest's handoff delivers it).
|
||||||
check('every tradeable good is produced somewhere', unproducible.length === 0);
|
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 g2 = Galaxy.create(SEED);
|
||||||
const sysA = g.byId.keys().next().value;
|
// THE ORIGIN (model v2) — a good is on sale ONLY at its origin (its
|
||||||
const rockySys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
|
// world's signature, the daily batch) or, for hub-pool goods, at hubs.
|
||||||
const goodId = goods[0].id;
|
// 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 other = Galaxy.create('orbit-economy-other-seed');
|
||||||
const rockyA = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
|
const otherSigSys = Object.keys(other.economy.signatures)[0];
|
||||||
const rockyB = [...other.economy.classBySystem.entries()].find(([, s]) => s.has('rocky'))[0];
|
const otherSigGood = other.economy.signatures[otherSigSys];
|
||||||
const goodId = goods[0].id;
|
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('rocky', rockyA, g)) !== JSON.stringify(E.shopFor('rocky', rockyB, other)));
|
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;
|
const base = (id) => (goods.find((x) => x.id === id) || {}).baseValue || 0;
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
let ok = 0;
|
let ok = 0;
|
||||||
for (const good of goods) {
|
for (const good of tradeable) {
|
||||||
// A system that PRODUCES it (buy it cheap there).
|
// THE ORIGIN (model v2) — buy the signature cheap at its ONE origin
|
||||||
const prod = [...g.economy.classBySystem.entries()].find(([, s]) => good.producer.some((c) => s.has(c)));
|
// system (the daily batch; the only cheap source in the galaxy).
|
||||||
if (!prod) continue;
|
const origin = g.economy.origins[good.id];
|
||||||
const [prodId] = prod;
|
if (!origin) continue; // hub-pool goods are bought at hubs (no origin)
|
||||||
const buyCls = classFor(prodId, good, good.producer);
|
const oCls = [...g.economy.classBySystem.get(origin)].find((c) => c !== 'capital' && c !== 'station') ?? 'rocky';
|
||||||
// A system that WANTS it, ≥ 3 hops from the nearest producer.
|
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(
|
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;
|
if (!far) continue;
|
||||||
const [farId] = far;
|
const [farId] = far;
|
||||||
const sellCls = classFor(farId, good, good.demand);
|
const farSet = far[1];
|
||||||
const buy = E.buyPrice(good.id, buyCls, prodId, g);
|
const sellCls = [...farSet].find((c) => good.demand.includes(c));
|
||||||
const sell = E.sellPrice(good.id, sellCls, farId, g);
|
const sell = E.sellPrice(good.id, sellCls, farId, g);
|
||||||
checked++;
|
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)
|
// 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 rockySys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('rocky') && !s.has('station') && !s.has('capital'))[0];
|
||||||
const shop = E.shopFor('rocky', rockySys, g);
|
const shop = E.shopFor('rocky', rockySys, g);
|
||||||
check('plain-world floor is 8–16 goods', shop.buy.length >= 8 && shop.buy.length <= 16);
|
const sig = g.economy.signatures[rockySys];
|
||||||
check('locally produced goods are on the floor', shop.buy.some((b) => b.produced));
|
check('plain-world floor is EXACTLY its signature good (one world, one good)', shop.buy.length === 1 && shop.buy[0].id === sig);
|
||||||
check('more than a third of the floor is locally produced', shop.buy.filter((b) => b.produced).length >= shop.buy.length / 3);
|
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);
|
check('the buy-back list is non-empty (it wants things)', shop.sell.length > 0);
|
||||||
// No good appears on the floor twice.
|
// No good appears on the floor twice.
|
||||||
const ids = shop.buy.map((b) => b.id);
|
const ids = shop.buy.map((b) => b.id);
|
||||||
check('no duplicate goods on the floor', new Set(ids).size === ids.length);
|
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 stationSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('station'))[0];
|
||||||
const sshop = E.shopFor('station', stationSys, g);
|
const sshop = E.shopFor('station', stationSys, g);
|
||||||
check('station floor is 8–16 goods', sshop.buy.length >= 8 && sshop.buy.length <= 16);
|
check('station floor is 10–17 goods (hub-pool selection + own signature)', sshop.buy.length >= 10 && sshop.buy.length <= 17);
|
||||||
check('a station carries resold goods (variety)', sshop.buy.some((b) => !b.produced));
|
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 capSys = [...g.economy.classBySystem.entries()].find(([, s]) => s.has('capital'))[0];
|
||||||
const cshop = E.shopFor('capital', capSys, g);
|
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 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 —
|
// ONE WORLD, ONE GOOD — the sweep: every plain world's floor is exactly
|
||||||
// a guaranteed premium good + a guaranteed local buy in every shop. Sweep
|
// its one signature, and the signature's origin is that system (the
|
||||||
// every system of every class (a gem that's only present by catalog luck
|
// strict 1:1 the economy's identity rests on).
|
||||||
// would fail here: the floor must always include the class's gemstone).
|
|
||||||
let gemWorlds = 0;
|
let gemWorlds = 0;
|
||||||
let gemMissing = 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 [id, set] of g.economy.classBySystem) {
|
||||||
for (const cls of set) {
|
for (const cls of set) {
|
||||||
const gem = goods.find((x) => x.category === 'gemstone' && Array.isArray(x.producer) && x.producer.includes(cls));
|
floors++;
|
||||||
if (!gem) continue;
|
for (const b of E.shopFor(cls, id, g).buy) {
|
||||||
gemWorlds++;
|
if (E.sellPrice(b.id, cls, id, g) > b.price + 1e-6) loops++;
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ const fakeScene = () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
playTimeMs: 123456,
|
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: 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: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost');
|
||||||
check('capture: playtime carried', rec.playTimeMs === 123456);
|
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: depleted fields carried', rec.depletedClusters.includes(`${system.id}:c-depleted`));
|
||||||
check('capture: the comms ledger is carried (record.comms.played)',
|
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'));
|
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('oac-intro')
|
||||||
&& reg.get('commsPlayed').has('wraith-parting')
|
&& reg.get('commsPlayed').has('wraith-parting')
|
||||||
&& reg.get('commsPlayed').size === 2);
|
&& 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
|
// A LEGACY record (pre-minerals) still loads — no field, the restore
|
||||||
// side's `typeof === 'number'` guard keeps the ship at 0.
|
// 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);
|
regNoBuilds.get(PENDING_RESTORE_KEY).builds === null);
|
||||||
check('prepare: a legacy record (no comms field) stages an empty ledger',
|
check('prepare: a legacy record (no comms field) stages an empty ledger',
|
||||||
regNoBuilds.get('commsPlayed') instanceof Set && regNoBuilds.get('commsPlayed').size === 0);
|
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.
|
// consumeRestore: exactly once.
|
||||||
const first = consumeRestore(reg);
|
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: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null);
|
||||||
check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === 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 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.
|
// prepareLoad must reject a record it can't trust.
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { 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 { 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', []);
|
const goods = config.get('goods.goods', []);
|
||||||
assert.ok(goods.length >= 2, 'catalog has goods to trade');
|
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 A = goods[0].id;
|
||||||
const B = goods[1].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();
|
const scene = makeScene();
|
||||||
// gameScene carries the HUD refresh seam + the TRADE gate
|
// gameScene carries the HUD refresh seam + the TRADE gate
|
||||||
// (GameScene.isTradeUnlocked — the 'Special Delivery' capital landing).
|
// (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; };
|
gs.onQuestPackageSold = (n) => { soldSpy?.push(n); return true; };
|
||||||
}
|
}
|
||||||
const view = new ShopView(scene, {
|
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
|
// topY = the surface HUD's planet-text bottom + padding (what
|
||||||
// SurfaceScene.shopTopY() computes). The panel top should sit here.
|
// SurfaceScene.shopTopY() computes). The panel top should sit here.
|
||||||
topY,
|
topY,
|
||||||
|
|
@ -563,13 +572,73 @@ console.log('\n== BUY — step a stock good, Confirm spends credits + fills bays
|
||||||
}
|
}
|
||||||
console.log('BUY: OK');
|
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 ==');
|
console.log('\n== SCROLL — the stock outgrows the viewport: scroll, clamp, clip ==');
|
||||||
{
|
{
|
||||||
const ship = makeShip(10000);
|
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;
|
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
|
// 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).
|
// 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})`);
|
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');
|
assert.equal(String(list.rows[list.rows.length - 1].objs.qty.text), '1', '… and its qty text updates');
|
||||||
list.clearSelection();
|
list.clearSelection();
|
||||||
assert.equal(list.hasSelection(), false, 'Clear resets the list selection');
|
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');
|
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);
|
const ship = makeShip(10000);
|
||||||
ship.cargo.add([A]);
|
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.
|
// The scout exists (over the cargo grid) and starts closed.
|
||||||
assert.ok(view._scout, 'the scout panel exists with the trade content');
|
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
|
// valid exit in the report (any stock row would do for the UI
|
||||||
// assertions; rows[0] is not guaranteed to be bought locally).
|
// assertions; rows[0] is not guaranteed to be bought locally).
|
||||||
const row =
|
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.buyList.rows[0];
|
||||||
|
|
||||||
// TAP THE ROW'S BOX → the scout opens on that good.
|
// 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.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) => 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.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.
|
// TAP THE SAME BOX AGAIN → closes, cargo grid restored.
|
||||||
view._onRowTap(row);
|
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);
|
const ship = makeShip(10000);
|
||||||
ship.cargo.add([A]);
|
ship.cargo.add([A]);
|
||||||
const { view, scene } = makeView(ship);
|
const { view, scene } = makeView(ship, { sysId: hubSys, cls: hubCls });
|
||||||
const calls = [];
|
const calls = [];
|
||||||
scene.askSetDestination = (exit) => { calls.push(exit); };
|
scene.askSetDestination = (exit) => { calls.push(exit); };
|
||||||
|
|
||||||
// A row whose report has exits to tap (one the current system wants —
|
// A row whose report has exits to tap (one the current system wants —
|
||||||
// rows[0] is not guaranteed to be bought locally).
|
// rows[0] is not guaranteed to be bought locally).
|
||||||
const row =
|
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.buyList.rows[0];
|
||||||
view._onRowTap(row);
|
view._onRowTap(row);
|
||||||
assert.equal(view._scoutOpen, row.id, 'scout open on the row');
|
assert.equal(view._scoutOpen, row.id, 'scout open on the row');
|
||||||
|
|
|
||||||
590
docs/ECONOMY.md
590
docs/ECONOMY.md
|
|
@ -1,60 +1,72 @@
|
||||||
# Orbit — Trade & Economy (design)
|
# Orbit — Trade & Economy (design)
|
||||||
|
|
||||||
This is the design for the game's trade/economy. It is written to be built on top of what
|
This is the design for the game's trade/economy. It hangs off the deterministic
|
||||||
Orbit already has — the jump network, factions, planet classes, settlements, the SHOP button
|
galaxy Orbit already has — the jump network, factions, planet classes, settlements,
|
||||||
that already exists on the surface deck, and the `ship.stats.cargoHold` slot that's already
|
the SHOP button on the surface deck, and the `ship.stats.cargoHold` slot — so nothing
|
||||||
reserved for cargo. Nothing here requires new world-structure; it all hangs off the existing
|
here requires new world-structure.
|
||||||
deterministic galaxy.
|
|
||||||
|
|
||||||
**Status: DRAFT / design + data only.** The goods catalog and tuning knobs are written to
|
**Status: MODEL v2 — IMPLEMENTED.** The goods catalog (`data/goods.json`) and the
|
||||||
`data/` so they can be iterated on. The JS plumbing (HUD, credits, shop logic, generator
|
tuning knobs (`data/economy.json`) are the source of truth; the engine
|
||||||
hook) is intentionally **not** written yet — the open questions at the bottom of this doc need
|
(`js/economy/Economy.js`), the batch clock (`js/economy/Stock.js`), and the shop
|
||||||
your call first.
|
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,
|
1. **Distance is the dominant factor in profit.** A good's sell price grows with
|
||||||
no stock you can manipulate, no persistent shop state. The economy is a *landscape* you
|
its hop distance from its ORIGIN (the one world that makes it). Longer journey
|
||||||
travel through and exploit; it doesn't react to you (or only very lightly, via reputation).
|
⇒ meaningfully better reward. This is the business model.
|
||||||
2. **Every world has a market.** Every settled planet and station gets a shop of 8–16 goods,
|
2. **Planet type is a secondary modifier.** A world's class contributes a small
|
||||||
priced so that what's local is cheap and what's foreign is dear.
|
demand bonus (the class *wants* the good) and drives the THEMATIC FIT of the
|
||||||
3. **The trip is the reward.** A good's profit scales with how far it is from where it's made.
|
origin assignment (a gas world is where fuel is made) — but it never overrides
|
||||||
Local trade (buy low in-system, sell nearby) works and is safe; long-haul trade pays much
|
distance.
|
||||||
more and costs jumps/time/risk. Longer journey ⇒ meaningfully better reward.
|
3. **Goods are regionally produced — one world, one good.** Every tradeable good
|
||||||
4. **Planet type drives production.** A terran world makes food; a lava world makes heat and
|
has exactly ONE origin system (a seeded strict 1:1 assignment); every settled
|
||||||
rare metals; a gas giant makes fuel. Opposing types want each other's signature goods —
|
system sells exactly ONE product — its signature good, cheap, in a daily batch.
|
||||||
ice wants heat, lava wants cool, terran wants salt/solar, arid wants water/food, gas wants
|
The catalog's remainder is the **hub pool**, resold by stations/capitals at
|
||||||
metal, rocky wants fuel.
|
standard price.
|
||||||
5. **Local money is possible without crossing the galaxy.** You never *have* to cross the map
|
4. **Same-system trading earns money, but it is not the business model.** A
|
||||||
to make a living; you choose to, for more.
|
buy-local/sell-local loop in one system is *bounded* by the daily batch
|
||||||
6. **Each planet type yields a gemstone that other types want** (a universal premium good, and
|
(a few units a day) — pocket change. Long-haul SELLs are unlimited: the market
|
||||||
a later mining/loot hook).
|
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
|
## 2. The core loop
|
||||||
|
|
||||||
```
|
```
|
||||||
LAND on a world SHIP a good LAND on another world
|
LAND on the ORIGIN world SHIP a good LAND far away
|
||||||
┌──────────────┐ (jump) ┌──────────────┐
|
┌──────────────┐ (jump) ┌──────────────┐
|
||||||
│ open SHOP │ ◄────────────────────────── │ SELL for │
|
│ open SHOP │ ◄──────────────────────────────── │ SELL for │
|
||||||
│ BUY cheap │ │ CREDITS │
|
│ BUY the │ │ CREDITS │
|
||||||
│ (local/low) │ ──────────────────────────► │ (dear/hi) │
|
│ signature │ ─────────────────────────────────► │ (dear — it │
|
||||||
└──────────────┘ carry in cargoHold └──────────────┘
|
│ (half price,│ carry in cargoHold │ is far from │
|
||||||
|
│ daily batch│ │ its home) │
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
▲ │
|
▲ │
|
||||||
└──────────────── spend credits on builds/quests ◄──────┘
|
└──────────────── 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`).
|
- **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).
|
- **SELL** it wherever a class wants it — the price rises with hop distance
|
||||||
- **PROFIT** = sell price − buy price. The gap is bigger the further the good has travelled.
|
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.
|
- **SPEND** credits on ship builds, quest rewards, upgrades.
|
||||||
|
|
||||||
You are never *balancing* a market. You are *finding* an arbitrage the galaxy already set up
|
You are never *balancing* a market. You are *finding* an arbitrage the galaxy
|
||||||
and running it. That is the "interact, don't control" requirement made concrete.
|
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:
|
`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 |
|
| **terran** | `terran` planets | food, water, timber, pharma, biology | rare metals, fuel, heat goods, high-end tech |
|
||||||
| **arid** | `arid` planets | salt, silica, solar, spice, gems: **Dune Topaz** | water, humidity, coolants, food |
|
| **arid** | `arid` planets | salt, silica, solar, spice | water, humidity, coolants, food |
|
||||||
| **rocky** | `rocky` planets | ores, metal, stone, gems: **Karnite** | fuel, food, water, processed tech |
|
| **rocky** | `rocky` planets | ores, metal, stone | fuel, food, water, processed tech |
|
||||||
| **gas** | `gas` planets | fuel, gases, isotopes, gems: **Jovian Sapphire** | solid metal, food, water, hull parts |
|
| **gas** | `gas` planets | fuel, gases, isotopes | solid metal, food, water, hull parts |
|
||||||
| **ice** | `ice` planets | water, ice, cryo, volatiles, gems: **Frost Diamond** | heat, energy, food, fuel |
|
| **ice** | `ice` planets | water, ice, cryo, volatiles | heat, energy, food, fuel |
|
||||||
| **lava** | `lava` planets | heat, rare metals, volcanic glass, gems: **Magma Ruby** | coolants, water, food, fuel |
|
| **lava** | `lava` planets | heat, rare metals, volcanic glass | coolants, water, food, fuel |
|
||||||
| **station** | deep-space stations | processed/refined goods, services, gems: **Aether Crystal** | raw ore, food, water, bulk fuel |
|
| **station** | deep-space stations | (hubs — resellers, not producers) | 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) |
|
| **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
|
In v2 the classes play TWO roles, both secondary to distance:
|
||||||
for:
|
|
||||||
|
|
||||||
- **ice ⇄ lava** — ice sells cool (coolants, cryo, water) to lava; lava sells heat (thermal
|
- **Origin fit** — the seeded origin assignment prefers systems whose classes
|
||||||
batteries, plasma, volcanic energy) to ice.
|
*produce* the good (a gas world gets fuel, an ice world gets cryo). The
|
||||||
- **terran ⇄ arid** — terran sells water/food to arid; arid sells salt/solar/spice to terran.
|
`producer` list is the fit signal; where the catalog leaves no fitting
|
||||||
- **gas ⇄ rocky** — gas sells fuel to rocky; rocky sells metal to gas.
|
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
|
The opposition pairs (ice ⇄ lava, terran ⇄ arid, gas ⇄ rocky) still give the
|
||||||
makes a run to a faction seat lucrative and gives long-haul a ceiling.
|
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`),
|
> **Every settled system has exactly ONE signature good — and every signature
|
||||||
so there is always a premium outlet for a world's signature stone.
|
> good has exactly ONE origin system.**
|
||||||
- **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.
|
|
||||||
|
|
||||||
The eight gems: Verdant Emerald (terran), Dune Topaz (arid), Karnite (rocky), Jovian Sapphire
|
- **The assignment is a seeded strict bijection** (the `origin-assign` pass in
|
||||||
(gas), Frost Diamond (ice), Magma Ruby (lava), Aether Crystal (station), Imperial Diamond
|
`Economy.buildEconomy`, greedy most-constrained-first with zone→tier fit and
|
||||||
(capital — the measure by which the rest are weighed).
|
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
|
**Prices are FIXED per (good, location) pair — a pure function of the seed.**
|
||||||
depend on where you got the good or what's in your hold.** A good's sell price at a planet is the
|
They do not depend on where you got the good or what's in your hold. The shops
|
||||||
same whether you mined it two systems away or carried it across the galaxy. This is what makes
|
are stateless; the whole economy re-derives from `(seed, placeId, economyVersion)`.
|
||||||
the shops stateless (no per-player stock bookkeeping) and the whole economy a pure function of
|
The only state is the day's remaining batch (see §5.3).
|
||||||
the seed.
|
|
||||||
|
|
||||||
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
|
### 5.1 The BUY price (what you pay)
|
||||||
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.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
price(g,p) = baseValue(g)
|
origin sale (S is g's ORIGIN) = baseValue × (1 − originDiscount) × jitter
|
||||||
× (1 + demandBonus if p.class is in g.demand) ← it's wanted here
|
hub resale (hub-pool good, S is a hub) = baseValue × hubPriceFactor × jitter
|
||||||
× (1 − producerDiscount if p.class is in g.producer)← it's made here (cheaper)
|
everywhere else = NOT ON SALE (buyPrice = 0)
|
||||||
× (1 + distancePerHop × min(hops, cap)) ← how far from its source
|
|
||||||
× jitter
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `baseValue` — the good's intrinsic worth (goods.json).
|
- **originDiscount** = 50% — the origin sells at ~half base. **The origin is the
|
||||||
- **demandBonus** (+12%/class in the draft) — a place that *wants* the good pays more.
|
galaxy's only cheap source of its good.**
|
||||||
- **producerDiscount** (−35% in the draft) — a place that *makes* the good sells it cheap.
|
- **hubPriceFactor** = 1.0 — hubs resell at standard (never below the origin —
|
||||||
**This is the "local = cheap" rule.**
|
there is exactly one cheap place).
|
||||||
- **distance** (+22%/hop, capped at 6 hops) — the good is worth more the further it is from the
|
- A world's floor is *only* its signature (its one product); a hub's floor is
|
||||||
nearest class that produces it. **This is the "trip is the reward" rule.** It uses the jump
|
the hub-pool selection **plus** its own system's signature (same day's batch,
|
||||||
graph (hops), which PROJECT_NOTES already establishes as ≈ map distance, so it's consistent
|
same source — a station in the fuel system resells fuel at the origin price).
|
||||||
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."
|
|
||||||
|
|
||||||
- **Regional availability is how distance is enforced.** A good is only offered for sale (cheap)
|
### 5.2 The SELL price (what you get paid)
|
||||||
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)`.
|
|
||||||
|
|
||||||
**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
|
sell(g, C, S) = baseValue(g)
|
||||||
`14 × 1.12 × 1.44 ≈ 22`. Profit ≈ **13 credits** on a 2-hop run. Same good, 5 hops out:
|
× distanceBand(hops(S → origin(g))) ← THE dominant term
|
||||||
`14 × 1.12 × 2.10 ≈ 33` → profit ≈ **24** — same good, same hold-space, more than double, because
|
× (1 + demandBonus if C wants g) ← the secondary modifier
|
||||||
it was carried further. Every one of those numbers is fixed for its location; none changes based
|
× jitter
|
||||||
on your inventory.
|
, 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`
|
| Shop | Floor |
|
||||||
(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:
|
| **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).
|
Every world's BUYBACK list is its class's wanted goods (the `demand` rule) — a
|
||||||
- **Goods the class wants** appear at a *premium* or are *absent* — a place that needs fuel
|
place that wants fuel buys back fuel; a capital buys back everything.
|
||||||
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.
|
|
||||||
|
|
||||||
The exact pick is `seed → (place class, distance profile) → weighted sample of 8–16 goods`,
|
The scout panel (tap a buy row — `js/ui/shop/ScoutPanel.js` + `TradeScout.js`)
|
||||||
weighted by the good's `weight` field and its relevance to the class. It's a pure function, so
|
reports the good's **MADE IN** (its origin system, or "HUB RESALE"), its
|
||||||
it's deterministic and re-derivable (see §8).
|
**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 |
|
| 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`). |
|
| **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. |
|
| **Planet classes** (`data/planets.json`) | the six world classes — origin-fit (assignment) + demand bonus (pricing). |
|
||||||
| **Settlements** (`data/settlements.json`, every planet settled) | each settlement = a shop. `settledKindByClass` already tells us the shop's "personality." |
|
| **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 (refiner/reseller, wider stock). |
|
| **Stations** (`data/stations.json`) | the `station` class (hub-pool resellers, wider floors). |
|
||||||
| **Zones** (`data/galaxy.json → zones`) | optional regional price bands — far zones (Far Reach / Outer Verge) run wilder prices. |
|
| **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`). |
|
| **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. |
|
| **cargoHold** (`data/ship.json`) | your carrying capacity for goods. |
|
||||||
| **Credits** (builds, quest rewards already reference `credits`) | the currency. HUD next to minerals. |
|
| **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
|
Determinism: all of this is a **pure function of the seed** (lazy and eager
|
||||||
from `(seed, placeId, economyVersion)` exactly like system contents are derived today
|
generation agree; saves stay tiny — we store only your credits, your carried
|
||||||
(`Rng.derive(seed, 'economy', …)`). That means lazy and eager generation agree, saves stay
|
goods, and the batch remainders, never the shops).
|
||||||
tiny (we store only *your* credits + your carried goods, never the shops), and the economy can
|
|
||||||
never drift or corrupt.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. The credits currency (decided)
|
## 8. The credits currency (decided)
|
||||||
|
|
||||||
- **Displayed next to the minerals HUD** (upper-right). Minerals are *bulk matter*; credits are
|
- **Displayed next to the minerals HUD** (upper-right). Minerals are *bulk
|
||||||
*trade value*. They coexist because they're different resources: you mine minerals, you earn
|
matter*; credits are *trade value*.
|
||||||
credits.
|
|
||||||
- **You start at 0** (`economy.json → credits.startingCredits`).
|
- **You start at 0** (`economy.json → credits.startingCredits`).
|
||||||
- **Earning credits** = selling goods + mission/quest credit rewards. **Spending** = builds,
|
- **Earning credits** = selling goods + mission/quest credit rewards + the
|
||||||
upgrades, and buying goods.
|
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
|
- **The cargo bays are the first credit sink** — the hold expands 4 → 8 → 12 → 16
|
||||||
credit-costed ship builds (`data/builds.json → cargo-bay-8/12/16`, `targets: ["ship"]`,
|
through three credit-costed ship builds (`data/builds.json → cargo-bay-8/12/16`).
|
||||||
`cost: { credits: 300/1200/3000 }`, `effects.cargo.bays`). Built from any planet's console and
|
- **Trading is LOCKED until the third story quest.** Until then the economy
|
||||||
applied to the ship (MAX wins, never a downgrade). The build console was extended to pay out
|
exists in the world (shops, prices, batches) but the buy/sell UI is gated.
|
||||||
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.)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Interact, don't control (your requirement)
|
## 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
|
What the player **cannot do**: change prices, stock a shop, corner a market,
|
||||||
make a good appear that isn't produced. The economy is a fixed landscape generated from the
|
or make a good appear that isn't made. The only "control" surface that exists
|
||||||
seed. The only "control" surface that exists is *light* and comes from your existing systems:
|
is *light* and comes from the existing systems: reputation slightly moves a
|
||||||
reputation slightly moves a place's prices, and (later) factions may patron/tax specific goods.
|
place's prices, and (later) factions may patron/tax specific goods. That's
|
||||||
That's flavor, not management.
|
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 |
|
| File | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `data/goods.json` | **114 goods** across 9 categories, each with producer/demand/weight/tier/value/flavor. The catalog. |
|
| `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 (shop size, price multipliers, distance curve, cargo, credits start, trading lock, reputation/faction hooks). |
|
| `data/economy.json` | All the tuning knobs (model v2: originDiscount, hubPriceFactor, demandBonus, distanceBands, jitter, the daily batch, cargo, credits). `version: 2`. |
|
||||||
| `data/creditshud.json` | The CREDITS readout styling (upper-right, stacked above the minerals HUD). |
|
| `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/Economy.js` | The pure price/stock engine (`buyPrice`/`sellPrice`/`shopFor`) + the `buildEconomy` generation pass (classBySystem + producerHops). |
|
| `js/economy/Stock.js` | **NEW (v2)** — the time-gated daily batch: `dailyBatch`, `economyDay`, `batchRemaining`, `spend` (pure, reference-swap; state = `{ [systemId]: { day, left } }`). |
|
||||||
| `js/ui/CreditsHud.js` | The CREDITS line, upper-right, above the minerals HUD (count-up on change). |
|
| `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/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/entities/Ship.js` | `ship.credits` + the ledger + `ship.cargo` (the `CargoHold`). |
|
||||||
| `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/save/SaveData.js` | Credits + cargo + **`economyStock`** (the batch remainders) captured/restored; `cleanEconomyStock` normalizes the shape; a new run clears it. |
|
||||||
| `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/scenes/GameScene.js` | Owns `economyStock` (registry-backed), `spendStock()` (the reference swap), the credits HUD, quest payouts. |
|
||||||
| `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/scenes/SurfaceScene.js` | Opens the `ShopView` over the shop clip; the SET DESTINATION offer from a scout exit. |
|
||||||
| `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/builds.json` | The **cargo bays** (`cargo-bay-8/12/16`) — the first credit sink. |
|
||||||
| `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`. |
|
| `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). |
|
||||||
| `js/galaxy/Galaxy.js` | Calls `buildEconomy` at the end of generation (the `galaxy.economy` seam). |
|
| `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). |
|
||||||
| `js/save/SaveData.js` | Credits + cargo captured/restored with the ship record. |
|
| `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. |
|
||||||
| `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. |
|
| `dev/trade-scout.test.mjs` | The scout report's invariants (origin field, exits ranked, determinism). |
|
||||||
| `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. |
|
| `dev/saves.test.mjs` | The save bank + the `economyStock` round-trip (capture/restore/reset/legacy/malformed). |
|
||||||
| `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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Decisions (locked in this pass)
|
## 11. Decisions (locked)
|
||||||
|
|
||||||
1. **Initial credits** — trading is gated until the third story quest; first credits come from
|
### v2 decisions (this pass)
|
||||||
**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**.
|
|
||||||
|
|
||||||
**Model confirmations (the two "before you code" points):**
|
1. **ONE WORLD, ONE GOOD** — every tradeable good has exactly one origin
|
||||||
- Prices are **fixed per (good, location)** — independent of where you got the good or your
|
system; every settled system sells exactly one product (its signature).
|
||||||
hold. Distance is enforced by **regional availability** (goods only cheap in their home
|
Strict seeded 1:1; barren (jump-gate-only) systems host nothing.
|
||||||
region), not a live per-trade hop-multiplier.
|
2. **Distance is the dominant profit factor** — banded hops from the origin
|
||||||
- **Credits are the go-between** — buy and sell are two separate credit transactions; there is
|
(×1.0 / ×1.8 / ×3.2 / ×5.0 / ×7.0), no cap (the tree's diameter is the
|
||||||
no good-for-good barter.
|
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)
|
## 12. The shop screen (player-facing UI)
|
||||||
|
|
||||||
Where the loop is actually played. You open it from the surface deck's **SHOP** button (the
|
Where the loop is actually played. You open it from the surface deck's **SHOP**
|
||||||
existing `data/actionbar.json` slot); the shop clip is already live, and the screen sits **over**
|
button; the screen sits **over** the shop clip. One component —
|
||||||
it. It's one component — `js/ui/shop/ShopView.js` — composing a `TradeGrid` (the SELL
|
`js/ui/shop/ShopView.js` — composing a `TradeGrid` (the SELL side) + a `BuyList`
|
||||||
side) + a `BuyList` (the BUY side).
|
(the BUY side) + the `ScoutPanel` (the goods scout).
|
||||||
|
|
||||||
**Entry state (showcase the clip).** When you first open the shop, **only the tab sub-bar**
|
**Entry state (showcase the clip).** First open shows **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`) above the command
|
||||||
(`[ BUY/SELL | BUYBACK ] │ [ CLEAR | CONFIRM ] │ NET 0 cr` — the tabs, the trade
|
deck. Clicking **Buy/Sell** reveals the trade panel: the **SELL grid** (your
|
||||||
actions, and the live **NET** readout, past hairline dividers; Buyback grayed, the
|
cargo bays, tap to check off) and the **BUY list** side by side, spanning the
|
||||||
actions hidden until you start picking, the readout dim `0 cr`), the same style +
|
screen.
|
||||||
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.
|
|
||||||
|
|
||||||
```
|
**BUY list (v2).** A world's list is ONE wide row — its signature good: icon,
|
||||||
# first click — shop video + the tab sub-bar (rests above the deck):
|
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),
|
||||||
│ (clip plays) │
|
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
|
||||||
│ │ BUY/SELL │ BUYBACK │ CLEAR CONFIRM │ NET 0 cr │ │
|
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
|
||||||
│ SHOP │ BUILD │ SHIP │ QUESTS │ TAKE OFF │ MENU ← command deck│
|
anywhere else closes the scout.
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
# after clicking Buy/Sell — the trade panel reveals (middle of the screen):
|
**NET readout** — the live credit delta on the sub-bar (green up, red spend,
|
||||||
│ SELL and BUY sit NEXT TO each other (a small gap between);
|
dim at zero), the same `preview()` the Confirm button is gated on. **SELL → BUY
|
||||||
│ the trade actions + NET readout ride on the sub-bar below.
|
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
|
||||||
│ │ SELL (4×4 bays) │ │[i] Fresh Produce 24 cr − 0 +│ │
|
the batch).
|
||||||
│ │ 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│
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Tab sub-bar** — the **Buy/Sell** + **Buyback** buttons, the **Confirm/Clear**
|
**Determinism + the one stateful seam.** Prices and floors are **fixed** per
|
||||||
trade actions, and the live **NET** readout — all `MenuButton`s (the deck's cyberpunk
|
(good, location) — pure functions of the seed; reopening a shop re-derives the
|
||||||
style: cut-corner panel, neon edge, hover sweep, press punch) in one console-blue panel
|
same floor and prices. The **only** state is the origin's remaining daily batch
|
||||||
that rests above the command deck, anchored to the Shop button — the exact recipe of
|
(`economyStock` — save/restore in `SaveData.js`), and it only ever *regenerates*
|
||||||
the Menu sub-bar. Buyback is **disabled** (grayed) until you've sold something this
|
while you're away. So the economy stays "interact, don't control": you trade
|
||||||
visit. The bar is **extended** past hairline dividers to carry the actions *and* the
|
against a fixed landscape, and the only clock in it runs in your favor.
|
||||||
**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.
|
|
||||||
|
|
||||||
- **SELL (left)** — your **cargo bays**. 16 cells (the fixed grid); the first `cargoHold`
|
**Tested headlessly** (`dev/shop-ui.test.mjs`, `dev/economy.test.mjs`,
|
||||||
are live bays (filled with a good, or empty), the rest are **locked** (the hold's upgrade
|
`dev/cargo.test.mjs`): the full logic — sell, buy, the daily batch (clamp →
|
||||||
room). Tap a filled bay to **check it off to sell** (a bay = 1 unit). No stepper — the
|
SOLD OUT → engine backstop), the two caps, the buyback neutral-undo, the scout
|
||||||
quantity is simply the number of its bays that are checked.
|
+ set-destination, the anti-loop sweep, and the save round-trip of the batch.
|
||||||
- **BUY (right, next to the SELL grid)** — the shop's **stock** (12–16 distinct goods from
|
Pixel layout and the slide-in animation are verified in the browser.
|
||||||
`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).
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
* 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
|
* at generation and NEVER dependent on where the player got the good or
|
||||||
* is in their hold:
|
* what is in their hold:
|
||||||
*
|
*
|
||||||
* BUY price (you pay the shop) — cheap where the place's class PRODUCES
|
* BUY price (you pay the shop) — the ORIGIN world's batch price
|
||||||
* the good (its home region), standard
|
* (baseValue × (1 − originDiscount)), or
|
||||||
* where a hub resells it.
|
* the hub's standard resale price
|
||||||
* SELL price (the shop pays you) — a premium where the class WANTS the
|
* (baseValue × hubPriceFactor) for
|
||||||
* good, growing with the hops between
|
* hub-pool goods at stations/capitals.
|
||||||
* this system and the good's home region.
|
* 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
|
* The BANDED DISTANCE term is the dominant factor in profit (it has no
|
||||||
* home region), not a live per-trade multiplier. Credits are the go-between:
|
* cap — the jump tree's ~17-hop diameter is the ceiling); planet type
|
||||||
* buying spends them, selling earns them, no good-for-good barter.
|
* (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
|
* 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
|
* 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:
|
* The two layers:
|
||||||
* 1. buildEconomy(galaxy) — the generation pass (called once at roster build,
|
* 1. buildEconomy(galaxy) — the generation pass (called once at roster build,
|
||||||
* after the jump network + faction pass). Produces `galaxy.economy`:
|
* after the jump network + faction pass). Produces `galaxy.economy`:
|
||||||
* classBySystem — Map systemId → Set of economic classes present
|
* classBySystem — Map systemId → Set of economic classes present
|
||||||
* producerHops — Map goodId → { systemId → hops-to-nearest-producer }
|
* signatures — Map systemId → goodId (the system's ONE signature
|
||||||
* Both are pure functions of the seed, so they're lazy-safe and save-free.
|
* 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
|
* 2. buyPrice / sellPrice / shopFor — the per-shop queries the UI and tests
|
||||||
* call with (goodId, class, systemId, galaxy).
|
* call with (goodId, class, systemId, galaxy).
|
||||||
*/
|
*/
|
||||||
|
|
@ -94,7 +119,12 @@ export function economicClass(planet = null, settlement = null) {
|
||||||
return 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) {
|
export function produces(goodId, cls) {
|
||||||
const g = goodById(goodId);
|
const g = goodById(goodId);
|
||||||
return !!(g && cls && Array.isArray(g.producer) && g.producer.includes(cls));
|
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));
|
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
|
* Is this good TRADEABLE — part of the shop economy at all? The catalog's
|
||||||
* quest cargo (data/goods.json → tradeable: false — 'Special Delivery''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;
|
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) {
|
function hopsToProducer(goodId, systemId, galaxy) {
|
||||||
const m = galaxy?.economy?.producerHops?.[goodId]?.[systemId];
|
const m = galaxy?.economy?.producerHops?.[goodId]?.[systemId];
|
||||||
return Number.isFinite(m) ? m : 0;
|
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)
|
// 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 BUY price — what the player pays for one unit at this shop (0 = not on
|
||||||
* the class produces the good (producerDiscount), standard where a hub resells
|
* sale here). Model v2: the good's ORIGIN world sells it at the batch price
|
||||||
* it. Fixed per (good, class, system); independent of the player's inventory.
|
* (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) {
|
export function buyPrice(goodId, cls, systemId, galaxy) {
|
||||||
const g = goodById(goodId);
|
const g = goodById(goodId);
|
||||||
if (!g || !cls) return 0;
|
if (!g || !cls) return 0;
|
||||||
const p = econ().pricing ?? {};
|
const p = econ().pricing ?? {};
|
||||||
const prod = produces(goodId, cls) ? (1 - (p.producerDiscount ?? 0)) : 1;
|
const base = Number(g.baseValue) || 0;
|
||||||
return Math.max(1, Math.round((Number(g.baseValue) || 0) * prod * jitter(goodId, cls, systemId, galaxy?.seed)));
|
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 SELL price — what the shop pays the player for one unit. Model v2:
|
||||||
* the class wants the good (demandBonusPerClass), growing with the hops
|
* baseValue × distanceBand(hops to origin) × (1 + demandBonus if the class
|
||||||
* between this system and the good's home region (distancePerHop, capped).
|
* 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.
|
* Fixed per (good, class, system); independent of where the player bought it.
|
||||||
*/
|
*/
|
||||||
export function sellPrice(goodId, cls, systemId, galaxy) {
|
export function sellPrice(goodId, cls, systemId, galaxy) {
|
||||||
const g = goodById(goodId);
|
const g = goodById(goodId);
|
||||||
if (!g || !cls) return 0;
|
if (!g || !cls) return 0;
|
||||||
const p = econ().pricing ?? {};
|
const p = econ().pricing ?? {};
|
||||||
const demand = wants(goodId, cls) ? 1 + (p.demandBonusPerClass ?? 0) : 1;
|
const demand = wants(goodId, cls) ? 1 + (p.demandBonus ?? 0.1) : 1;
|
||||||
const hops = Math.min(hopsToProducer(goodId, systemId, galaxy), p.distanceCapHops ?? 6);
|
const hops = hopsToProducer(goodId, systemId, galaxy);
|
||||||
const dist = 1 + (p.distancePerHop ?? 0) * hops;
|
const dist = distanceFactor(hops);
|
||||||
return Math.max(1, Math.round((Number(g.baseValue) || 0) * demand * dist * jitter(goodId, cls, systemId, galaxy?.seed)));
|
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:
|
* The full shop for a settlement class at a system (model v2 — ONE WORLD,
|
||||||
* buy — goods on SALE (what you can buy), 8–16 entries, each { id, price, produced }
|
* ONE GOOD):
|
||||||
* · plain world: its produced goods (abundant, cheap)
|
* buy — what the shop SELLS:
|
||||||
* · station: produced (≤ half) + a seeded selection of resold goods (variety)
|
* · world: EXACTLY ONE product — the system's signature good
|
||||||
* · capital: produced (≤ half) + a seeded selection of premium goods (tier 3–4)
|
* (its origin's batch price; the batch's remaining units
|
||||||
* · the class's SIGNATURE GEM is ALWAYS on the floor (the world's
|
* are time-gated — js/economy/Stock.js, the shop UI shows
|
||||||
* "jewel" — a guaranteed premium good + a guaranteed local buy)
|
* the count / sold-out state)
|
||||||
* sell — goods the shop BUYS BACK (what you can sell), each { id, price }
|
* · station / capital: a seeded selection from the HUB POOL at
|
||||||
* · plain world / station: the goods the class wants
|
* standard price (variety for stations, the wider floor
|
||||||
* · capital: EVERYTHING (the universal sink)
|
* 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.
|
* Pure in (cls, systemId, galaxy) — deterministic and re-derivable.
|
||||||
*/
|
*/
|
||||||
export function shopFor(cls, systemId, galaxy) {
|
export function shopFor(cls, systemId, galaxy) {
|
||||||
if (!cls) return { buy: [], sell: [] };
|
if (!cls) return { buy: [], sell: [] };
|
||||||
const shop = econ().shop ?? {};
|
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
|
// THE SHOP FLOOR's catalog — tradeable goods only. The quest cargo
|
||||||
// (tradeable: false, data/goods.json → special-package) is the one good
|
// (tradeable: false, data/goods.json → special-package) is the one good
|
||||||
// the economy does not price: it never stocks, never resells, never
|
// 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 goods = goodsList().filter((g) => isTradeable(g.id));
|
||||||
const seed = galaxy?.seed;
|
const seed = galaxy?.seed;
|
||||||
|
|
||||||
const core = goods.filter((g) => produces(g.id, cls));
|
|
||||||
const isHub = cls === 'station' || cls === 'capital';
|
|
||||||
|
|
||||||
let buyIds;
|
let buyIds;
|
||||||
if (isHub) {
|
if (isHubClass(cls)) {
|
||||||
// Hubs: at most half the floor is their own produce (the rest is their
|
// HUBS: a seeded selection from the HUB POOL (the catalog's remainder —
|
||||||
// reseller role — variety for stations, premium for capitals).
|
// resellers, standard price), sized by kind (capital widest).
|
||||||
const coreCap = Math.max(1, Math.ceil(maxStock / 2));
|
const kind = cls === 'capital' ? 'capital' : 'deepSpaceStation';
|
||||||
const coreIds = seededSelect(core, coreCap, seed, cls, systemId);
|
const sk = shop.stockByKind?.[kind] ?? {};
|
||||||
const other = goods.filter((g) => !produces(g.id, cls));
|
const maxStock = sk.max ?? shop.maxStock ?? 16;
|
||||||
const premium = cls === 'capital'
|
const minStock = sk.min ?? shop.minStock ?? 8;
|
||||||
? other.filter((g) => (Number(g.tier) || 1) >= 3)
|
const pool = (galaxy?.economy?.hubPool ?? []).map((id) => goodById(id)).filter(Boolean);
|
||||||
: other.filter((g) => (Number(g.tier) || 1) <= 3);
|
const fill = seededSelect(pool, Math.min(maxStock, pool.length), seed, cls, systemId);
|
||||||
const fill = seededSelect(premium.length ? premium : other, maxStock - coreIds.length, seed, cls + '-resold', systemId);
|
buyIds = [...fill];
|
||||||
buyIds = [...coreIds, ...fill];
|
// Guarantee the floor reaches minStock when the catalog allows it.
|
||||||
} else {
|
if (buyIds.length < minStock) {
|
||||||
buyIds = core.slice(0, maxStock).map((g) => g.id);
|
const have = new Set(buyIds);
|
||||||
}
|
for (const g of goods) {
|
||||||
|
if (buyIds.length >= minStock) break;
|
||||||
// Guarantee the floor reaches minStock when the catalog allows it.
|
if (!have.has(g.id)) { buyIds.push(g.id); have.add(g.id); }
|
||||||
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
|
// The system's OWN signature good rides EVERY shop's floor at the origin
|
||||||
// gem is ALWAYS on the shelf — a guaranteed premium good (tier 3–4) and a
|
// price (a world sells it as its one product; a capital/station in the
|
||||||
// guaranteed local buy in every shop, whatever the seeded floor picked.
|
// origin system resells its home product too — batch-limited like the
|
||||||
// It's a produced good of `cls`, so it prices at the producer discount;
|
// world's, since it's the same day's batch at the same source).
|
||||||
// swap it into the least-important slot (a hub's resold filler, or an
|
const sig = signatureOf(systemId, galaxy);
|
||||||
// extra local good on a plain world) keeping the 8–16 floor size + no dups.
|
if (sig && !buyIds.includes(sig)) buyIds.push(sig);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buy = buyIds.map((id) => ({
|
const buy = buyIds.map((id) => ({
|
||||||
id,
|
id,
|
||||||
name: goodById(id)?.name ?? id,
|
name: goodById(id)?.name ?? id,
|
||||||
price: buyPrice(id, cls, systemId, galaxy),
|
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)))
|
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)
|
* buyMap = { goodId: qty } — goods to buy (into the hold)
|
||||||
*
|
*
|
||||||
* Pricing is the FIXED per-(good, shop) price from buyPrice/sellPrice above —
|
* 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);
|
* · credits — creditsAfter must be ≥ 0 (you can't spend into the red);
|
||||||
* · cargo — the END hold (aboard − sold + bought) must fit the unlocked
|
* · cargo — the END hold (aboard − sold + bought) must fit the unlocked
|
||||||
* bays (capacity). Selling to free room for a buy is allowed
|
* 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
|
* Pure in its inputs — the UI calls it on every selection change; a test can
|
||||||
* call it with no scene at all.
|
* 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
|
// 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).
|
// so sell→buyback can't net-profit into an infinite-credits loop).
|
||||||
const buyOverrides = o.buyOverrides ?? {};
|
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 credits = Math.max(0, Number(o.credits) || 0);
|
||||||
const capacity = Math.max(0, Math.floor(Number(o.capacity) || 0));
|
const capacity = Math.max(0, Math.floor(Number(o.capacity) || 0));
|
||||||
|
|
||||||
|
|
@ -419,6 +518,13 @@ export function tradePreview(o) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
if (creditsAfter < 0) errors.push('insufficient-credits');
|
if (creditsAfter < 0) errors.push('insufficient-credits');
|
||||||
if (cargoAfter > capacity) errors.push('cargo-full');
|
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;
|
const valid = errors.length === 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -462,16 +568,104 @@ function multiSourceBFS(sources, adj) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ECONOMY PASS — the galaxy-level trade map, computed once at roster build
|
* THE ORIGIN ASSIGNMENT (model v2 — the 'origin-assign' pass, the model's
|
||||||
* (after the jump network + faction pass, before any content generation). A
|
* core): the strict 1:1 matching behind ONE WORLD, ONE GOOD.
|
||||||
* pure function of the seed:
|
*
|
||||||
|
* · 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
|
* classBySystem — Map systemId → Set of economic classes (the planet classes
|
||||||
* from the SAME roll the content generator uses, + the home
|
* from the SAME roll the content generator uses, + the home
|
||||||
* world's class at home, + 'station' if it has a deep-space
|
* world's class at home, + 'station' if it has a deep-space
|
||||||
* station, + 'capital' if it's a designated capital).
|
* station, + 'capital' if it's a designated capital).
|
||||||
* producerHops — Map goodId → { systemId → hops to the nearest producer }.
|
* signatures — Map systemId → goodId (the system's ONE signature good).
|
||||||
* The distance term for sell prices: the further a system
|
* origins — Map goodId → systemId (the good's ONE origin system).
|
||||||
* is from a good's home region, the higher the good sells.
|
* 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.
|
* Stashed on `galaxy.economy` and read by buyPrice/sellPrice/shopFor.
|
||||||
*/
|
*/
|
||||||
export function buildEconomy(galaxy) {
|
export function buildEconomy(galaxy) {
|
||||||
|
|
@ -479,6 +673,7 @@ export function buildEconomy(galaxy) {
|
||||||
const homeCls = config.get('planets.homePlanet', 'terran');
|
const homeCls = config.get('planets.homePlanet', 'terran');
|
||||||
|
|
||||||
const classBySystem = new Map();
|
const classBySystem = new Map();
|
||||||
|
const hubSystems = [];
|
||||||
for (const [id, record] of galaxy.byId) {
|
for (const [id, record] of galaxy.byId) {
|
||||||
const isHome = id === galaxy.homeSystemId;
|
const isHome = id === galaxy.homeSystemId;
|
||||||
const type = typeDefs[record.type] ?? {};
|
const type = typeDefs[record.type] ?? {};
|
||||||
|
|
@ -496,19 +691,24 @@ export function buildEconomy(galaxy) {
|
||||||
if ((comp.stations ?? 0) > 0) set.add('station');
|
if ((comp.stations ?? 0) > 0) set.add('station');
|
||||||
if (record.capital) set.add('capital');
|
if (record.capital) set.add('capital');
|
||||||
classBySystem.set(id, set);
|
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 adj = adjacency(galaxy);
|
||||||
|
|
||||||
const producerHops = {};
|
const producerHops = {};
|
||||||
for (const g of goodsList()) {
|
for (const g of goodsList()) {
|
||||||
const producers = [];
|
if (!isTradeable(g.id)) { producerHops[g.id] = {}; continue; } // quest cargo: no price, no hops
|
||||||
for (const [id, set] of classBySystem) {
|
// Signatures: hops to THEIR origin system (the single source); hub-pool
|
||||||
if (Array.isArray(g.producer) && g.producer.some((c) => set.has(c))) producers.push(id);
|
// 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).
|
||||||
producerHops[g.id] = multiSourceBFS(producers, adj);
|
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;
|
return galaxy.economy;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,15 @@
|
||||||
* trader's questions:
|
* trader's questions:
|
||||||
*
|
*
|
||||||
* · WHAT is this good (name, category, tier, flavor)?
|
* · WHAT is this good (name, category, tier, flavor)?
|
||||||
* · WHERE is it GROWN (the producer classes — cheap to buy there)?
|
* · WHERE is it MADE (model v2 — the ONE ORIGIN system: the single world
|
||||||
* · WHO WANTS it (the demand classes — the premium sell exits)?
|
* 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
|
* · 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
|
* by the SELL price they'd pay, each with the per-unit PROFIT vs.
|
||||||
* player's local buy price here.
|
* 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)
|
* 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
|
* — 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 {{
|
* @returns {{
|
||||||
* id:string, name:string, flavor:string, category:string|null, tier:number|null,
|
* id:string, name:string, flavor:string, category:string|null, tier:number|null,
|
||||||
* localBuy:number, localSell: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}>,
|
* producer:Array<{cls,label,economy}>, demand:Array<{cls,label,economy}>,
|
||||||
* exits:Array<{systemId,systemName,cls,clsLabel,price,profit,hops,objectId,objectName}>,
|
* exits:Array<{systemId,systemName,cls,clsLabel,price,profit,hops,objectId,objectName}>,
|
||||||
* bestExit:object|null, maxProfit:number, charted:number, topN:number
|
* 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 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;
|
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 producer = (Array.isArray(g.producer) ? g.producer : []).map(classInfo);
|
||||||
const demand = (Array.isArray(g.demand) ? g.demand : []).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,
|
baseValue: Number.isFinite(g.baseValue) ? g.baseValue : null,
|
||||||
localBuy,
|
localBuy,
|
||||||
localSell,
|
localSell,
|
||||||
|
origin,
|
||||||
producer,
|
producer,
|
||||||
demand,
|
demand,
|
||||||
exits: top,
|
exits: top,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@
|
||||||
* // steps (monotonic witness)
|
* // steps (monotonic witness)
|
||||||
* totalMined: number, // lifetime asteroid mining
|
* totalMined: number, // lifetime asteroid mining
|
||||||
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
* 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 },
|
* story: { capitalLanded, launchedFromCapital, lastLandedWorld, delivered },
|
||||||
* // the 'Special Delivery' witnesses + the surface TRADE gate
|
* // the 'Special Delivery' witnesses + the surface TRADE gate
|
||||||
* // (GameScene._recordLanding / onSurfaceDeparture /
|
* // (GameScene._recordLanding / onSurfaceDeparture /
|
||||||
|
|
@ -61,6 +66,27 @@ import { SAVE_FORMAT, SaveManager } from './SaveManager.js';
|
||||||
|
|
||||||
export const PENDING_RESTORE_KEY = 'orbit.pendingRestore';
|
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.
|
* 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
|
// these out (world + map). A save predating it has no field; the
|
||||||
// restore stages an empty set (old saves load).
|
// restore stages an empty set (old saves load).
|
||||||
depletedClusters: Array.from(scene.depletedClusters ?? []),
|
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 —
|
// The SYSTEM research category's activation keys (which jump gates —
|
||||||
// incl. other systems' return gates — the run has activated; the
|
// incl. other systems' return gates — the run has activated; the
|
||||||
// per-run world state GameScene keeps in its registry-backed set).
|
// per-run world state GameScene keeps in its registry-backed set).
|
||||||
|
|
@ -309,6 +342,11 @@ export function prepareLoad(registry, record) {
|
||||||
'depletedClusters',
|
'depletedClusters',
|
||||||
new Set(Array.isArray(record.depletedClusters) ? 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 →
|
// 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
|
// an empty set — old saves load; a fresh run's welcome arms on its own
|
||||||
// 'newGame' trigger in GameScene.create()).
|
// 'newGame' trigger in GameScene.create()).
|
||||||
|
|
@ -367,6 +405,7 @@ export function resetRunState(registry) {
|
||||||
registry.set('quests', null);
|
registry.set('quests', null);
|
||||||
registry.set('totalMined', null);
|
registry.set('totalMined', null);
|
||||||
registry.set('depletedClusters', 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('story', null); // the bar, the handoff, the leg — all fresh
|
||||||
registry.set('commsPlayed', null); // the welcome arms again on the new run
|
registry.set('commsPlayed', null); // the welcome arms again on the new run
|
||||||
registry.set('vignettesPlayed', null); // the scenes re-arm on the new run
|
registry.set('vignettesPlayed', null); // the scenes re-arm on the new run
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { ActionBar } from '../ui/ActionBar.js';
|
||||||
import { MineralHud } from '../ui/MineralHud.js';
|
import { MineralHud } from '../ui/MineralHud.js';
|
||||||
import { CreditsHud } from '../ui/CreditsHud.js';
|
import { CreditsHud } from '../ui/CreditsHud.js';
|
||||||
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
|
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
|
||||||
|
import * as Stock from '../economy/Stock.js';
|
||||||
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||||
import { SavePanel } from '../ui/SavePanel.js';
|
import { SavePanel } from '../ui/SavePanel.js';
|
||||||
import { SaveManager } from '../save/SaveManager.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
|
// registry), the current system record, and its lazy contents — the
|
||||||
// dossier below and the world's other planets both read them.
|
// dossier below and the world's other planets both read them.
|
||||||
this.ensureGalaxy();
|
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 EFFECTS DEMO (?fx=<type> — js/visuals/SystemEffects.js): drop
|
||||||
// the run into the richest system of that archetype (ranked in
|
// the run into the richest system of that archetype (ranked in
|
||||||
// js/galaxy/FxSystems.js), fully charted, all gates live — a clean
|
// 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);
|
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):
|
* 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
|
* a loop that starts when the beam goes live (phase 'mining') and
|
||||||
|
|
|
||||||
|
|
@ -415,7 +415,13 @@ export class BuyList extends Phaser.GameObjects.Container {
|
||||||
*/
|
*/
|
||||||
refresh(o = {}) {
|
refresh(o = {}) {
|
||||||
const stock = Array.isArray(o.stock) ? o.stock : [];
|
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.buyQty = new Map();
|
||||||
this._destroyRows();
|
this._destroyRows();
|
||||||
for (const s of this.stock) this._addRow(s);
|
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 price = Math.round(Number(s.price) || 0);
|
||||||
const flavor = this._flavorOf(id);
|
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
|
// row plate (bg + border) — the whole row's width, in the viewport
|
||||||
const bg = this.scene.add?.graphics?.();
|
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); }
|
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(
|
const descEl = this._makeText(
|
||||||
textX, 0, row.desc,
|
textX, 0, row.desc,
|
||||||
{ fontSize: `${descFont}px`, color: bl.desc?.color ?? '#7d92c4', align: 'left' },
|
{ 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); }
|
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).
|
// stepper: − qty + (the qty tap = +1, the old grid's behavior).
|
||||||
// Centered origin: _btnXs() positions each stepper at its intended
|
// Centered origin: _btnXs() positions each stepper at its intended
|
||||||
// CENTER, so render the glyph centered on that point (name/desc/price
|
// 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
|
// Max description width (text block → price), in px. The price reserves
|
||||||
// its full width ("1,240 cr") so the description never collides with it.
|
// its full width ("1,240 cr") so the description never collides with it;
|
||||||
_descMaxW() {
|
// batch-limited rows reserve the "N left" / "SOLD OUT" strip between the
|
||||||
return this._btnXs().price - this.priceW - 14 - this._textX();
|
// 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
|
// Truncate `str` to fit ~maxW px over `maxLines` line(s) at fontSize
|
||||||
// (deterministic char estimate — no text metrics needed; the headless
|
// (deterministic char estimate — no text metrics needed; the headless
|
||||||
// stubs have none). Real Phaser word-wraps at _descMaxW() per line;
|
// stubs have none). Real Phaser word-wraps at _descMaxW() per line;
|
||||||
// this cap is the fail-safe that keeps the total inside maxLines lines.
|
// 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 ?? '');
|
const s = String(str ?? '');
|
||||||
if (!s) return '';
|
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));
|
const maxChars = perLine * Math.max(1, Math.floor(Number(maxLines) || 1));
|
||||||
if (s.length <= maxChars) return s;
|
if (s.length <= maxChars) return s;
|
||||||
return `${s.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
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.name?.setPosition?.(tx, y - this.rowH * 0.18);
|
||||||
o.desc?.setPosition?.(tx, y + this.rowH * 0.27);
|
o.desc?.setPosition?.(tx, y + this.rowH * 0.27);
|
||||||
o.price?.setPosition?.(cx + bx.price, y);
|
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.minus?.setPosition?.(cx + bx.minus, y);
|
||||||
o.qty?.setPosition?.(cx + bx.qty, y);
|
o.qty?.setPosition?.(cx + bx.qty, y);
|
||||||
o.plus?.setPosition?.(cx + bx.plus, y);
|
o.plus?.setPosition?.(cx + bx.plus, y);
|
||||||
if (row.shown) {
|
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);
|
el?.setAlpha?.(this._elInVp(el, vpTop, vpBot) ? 1 : 0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -687,9 +712,14 @@ export class BuyList extends Phaser.GameObjects.Container {
|
||||||
/** Adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
|
/** Adjust a stock good's quantity by `d` (−/+ stepper). Clamped ≥ 0. */
|
||||||
bumpBuy(id, d = 1) {
|
bumpBuy(id, d = 1) {
|
||||||
if (!id) return this;
|
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 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);
|
if (next === 0) this.buyQty.delete(id);
|
||||||
else this.buyQty.set(id, next);
|
else this.buyQty.set(id, next);
|
||||||
this._renderRow(id);
|
this._renderRow(id);
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
* │ └────┘ 9 CR │
|
* │ └────┘ 9 CR │
|
||||||
* │ The lifeblood… │
|
* │ The lifeblood… │
|
||||||
* │ ────────────────────────── │
|
* │ ────────────────────────── │
|
||||||
* │ GROWN ON Gas Giant │
|
* │ MADE IN New Concord … │ ← model v2: the ONE ORIGIN system
|
||||||
* │ SOUGHT ON Rocky · Ice … │
|
* │ SOUGHT ON Rocky · Ice … │ (the world that makes it, daily batch)
|
||||||
* │ ────────────────────────── │
|
* │ ────────────────────────── │
|
||||||
* │ BEST EXITS │
|
* │ BEST EXITS │
|
||||||
* │ ① New Concord 41 cr │ ← TAP A ROW → "SET DESTINATION" popup
|
* │ ① New Concord 41 cr │ ← TAP A ROW → "SET DESTINATION" popup
|
||||||
|
|
@ -196,8 +196,17 @@ export class ScoutPanel extends Phaser.GameObjects.Container {
|
||||||
return t;
|
return t;
|
||||||
};
|
};
|
||||||
let y = y0 + 22;
|
let y = y0 + 22;
|
||||||
label(y, 'Grown on');
|
// THE ORIGIN (model v2) — the ONE system that makes this good (its
|
||||||
const pro = chips(y, d.producer);
|
// 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;
|
y += (pro?.height ?? 14) + 10;
|
||||||
label(y, 'Sought on');
|
label(y, 'Sought on');
|
||||||
const dem = chips(y, d.demand);
|
const dem = chips(y, d.demand);
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
import Phaser from '../../vendor/phaser.js';
|
import Phaser from '../../vendor/phaser.js';
|
||||||
import { config } from '../../config/Config.js';
|
import { config } from '../../config/Config.js';
|
||||||
import * as E from '../../economy/Economy.js';
|
import * as E from '../../economy/Economy.js';
|
||||||
|
import * as Stock from '../../economy/Stock.js';
|
||||||
import { scoutGood } from '../../economy/TradeScout.js';
|
import { scoutGood } from '../../economy/TradeScout.js';
|
||||||
import { MenuButton } from '../MenuButton.js';
|
import { MenuButton } from '../MenuButton.js';
|
||||||
import { toColor, toCss } from '../../utils/Color.js';
|
import { toColor, toCss } from '../../utils/Color.js';
|
||||||
|
|
@ -529,7 +530,7 @@ export class ShopView extends Phaser.GameObjects.Container {
|
||||||
|
|
||||||
// Data + live preview wiring.
|
// Data + live preview wiring.
|
||||||
sellGrid.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
|
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();
|
sellGrid.onSelectionChange = () => this._syncPreview();
|
||||||
buyList.onSelectionChange = () => this._syncPreview();
|
buyList.onSelectionChange = () => this._syncPreview();
|
||||||
// The BUY/SELL toggle closed the panel WITH a pending trade? Re-apply
|
// 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;
|
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)
|
// 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,
|
cls: this.cls, systemId: this.systemId, galaxy: this.galaxy,
|
||||||
cargo, capacity, credits,
|
cargo, capacity, credits,
|
||||||
sellMap, buyMap,
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
this._applyTrade(pv);
|
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
|
// The 'Special Delivery' step 3 — the parcel's HOME-WORLD SALE (zero
|
||||||
// credits) is the delivery itself. Only quest cargo (tradeable:false
|
// credits) is the delivery itself. Only quest cargo (tradeable:false
|
||||||
// — the package) can ride a sell here (everywhere else the grid
|
// — 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._pendingBuy = null;
|
||||||
}
|
}
|
||||||
this.sellGrid?.refresh({ cargo: this.ship.cargo ? this.ship.cargo.snapshot() : [] });
|
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.refreshHud();
|
||||||
this._syncPreview();
|
this._syncPreview();
|
||||||
this._refreshTabs();
|
this._refreshTabs();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue