23 KiB
Orbit — Trade & Economy (design)
This is the design for the game's trade/economy. It hangs off the deterministic
galaxy Orbit already has — the jump network, factions, planet classes, settlements,
the SHOP button on the surface deck, and the ship.stats.cargoHold slot — so nothing
here requires new world-structure.
Status: MODEL v2 — IMPLEMENTED. The goods catalog (data/goods.json) and the
tuning knobs (data/economy.json) are the source of truth; the engine
(js/economy/Economy.js), the batch clock (js/economy/Stock.js), and the shop
UI (js/ui/shop/*) are built. Model v2 — ONE WORLD, ONE GOOD — was locked in
after v1's price model (producer/demand class discounts) turned out to make
distance a weak signal and, worse, left a buy→sell→buyback infinite-credit
loop at any shop. v2's invariants are enforced by dev/economy.test.mjs.
1. Design goals (v2)
- Distance is the dominant factor in profit. A good's sell price grows with its hop distance from its ORIGIN (the one world that makes it). Longer journey ⇒ meaningfully better reward. This is the business model.
- Planet type is a secondary modifier. A world's class contributes a small demand bonus (the class wants the good) and drives the THEMATIC FIT of the origin assignment (a gas world is where fuel is made) — but it never overrides distance.
- Goods are regionally produced — one world, one good. Every tradeable good has exactly ONE origin system (a seeded strict 1:1 assignment); every settled system sells exactly ONE product — its signature good, cheap, in a daily batch. The catalog's remainder is the hub pool, resold by stations/capitals at standard price.
- Same-system trading earns money, but it is not the business model. A buy-local/sell-local loop in one system is bounded by the daily batch (a few units a day) — pocket change. Long-haul SELLs are unlimited: the market is a sink.
- 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.
- Local money is possible without crossing the galaxy. You never have to cross the map to make a living; you choose to, for much more.
2. The core loop
LAND on the ORIGIN world SHIP a good LAND far away
┌──────────────┐ (jump) ┌──────────────┐
│ open SHOP │ ◄──────────────────────────────── │ SELL for │
│ BUY the │ │ CREDITS │
│ signature │ ─────────────────────────────────► │ (dear — it │
│ (half price,│ carry in cargoHold │ is far from │
│ daily batch│ │ its home) │
└──────────────┘ └──────────────┘
▲ │
└──────────────── spend credits on builds/quests ◄──────┘
- BUY a world's signature good at its ORIGIN — the only place it is cheap (half of base value), limited to the day's batch.
- CARRY it in your cargo hold (
ship.stats.cargoHold). - SELL it wherever a class wants it — the price rises with hop distance from the origin (the banded term). The further you carry it, the more it's worth.
- PROFIT = sell price − buy price. The gap is dominated by distance.
- SPEND credits on ship builds, quest rewards, upgrades.
You are never balancing a market. You are finding an arbitrage the galaxy already set up and running it.
3. The eight economic classes
data/goods.json defines them; they map straight onto existing structures:
| Class | Where it exists | Thematic producers (origin fit) | Strongly WANTS (+10% demand bonus) |
|---|---|---|---|
| terran | terran planets |
food, water, timber, pharma, biology | rare metals, fuel, heat goods, high-end tech |
| arid | arid planets |
salt, silica, solar, spice | water, humidity, coolants, food |
| rocky | rocky planets |
ores, metal, stone | fuel, food, water, processed tech |
| gas | gas planets |
fuel, gases, isotopes | solid metal, food, water, hull parts |
| ice | ice planets |
water, ice, cryo, volatiles | heat, energy, food, fuel |
| lava | lava planets |
heat, rare metals, volcanic glass | coolants, water, food, fuel |
| station | deep-space stations | (hubs — resellers, not producers) | raw ore, food, water, bulk fuel |
| capital | a faction's seat | (hubs — resellers, not producers) | everything, at a premium (the universal buyer) |
In v2 the classes play TWO roles, both secondary to distance:
- Origin fit — the seeded origin assignment prefers systems whose classes
produce the good (a gas world gets fuel, an ice world gets cryo). The
producerlist is the fit signal; where the catalog leaves no fitting system, the assignment falls back (a few goods are "anywhere" — the deterministic seed still fixes exactly where). - Demand — a class in the good's
demandlist pays a +10% bonus (economy.json → pricing.demandBonus). Thecapitalclass wants everything: the great sink, the universal buyer, the long-haul ceiling.
The opposition pairs (ice ⇄ lava, terran ⇄ arid, gas ⇄ rocky) still give the catalog its flavor and keep the demand web symmetric — but the price no longer derives from "produces here, wants there"; it derives from how far from the origin you sell it.
4. One world, one good (the v2 identity)
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:
Every settled system has exactly ONE signature good — and every signature good has exactly ONE origin system.
- The assignment is a seeded strict bijection (the
origin-assignpass inEconomy.buildEconomy, greedy most-constrained-first with zone→tier fit and a seeded tie-break). 78 settled systems ↔ 78 signature goods out of the 114-goods catalog; the 12 jump-gate-only (barren) systems host nothing — there is no world to sell from. - The catalog's remainder (36 goods) is the HUB POOL — no origin world; stations and capitals resell a seeded selection of it (standard price, wide floors for variety).
- Thematic fit is near-total — the origin assignment prefers systems whose classes produce the good (in the test seed, 78/78 fit; the fallback only fires where the catalog leaves a good with no fitting system).
- Gems are no longer special. The eight gemstones are still in the catalog (and can be a world's signature, earning half-price origin + the demand bonus), but there is no more "one per class, always on every shelf" rule.
Why: one product per world makes each place legible — you can name a system by what it makes ("the fuel world"), the buy menu is trivially small, and the only economic question a player has to answer is where does it go?
5. Pricing — fixed per location, distance dominant
Prices are FIXED per (good, location) pair — a pure function of the seed.
They do not depend on where you got the good or what's in your hold. The shops
are stateless; the whole economy re-derives from (seed, placeId, economyVersion).
The only state is the day's remaining batch (see §5.3).
For a good g at a system S (class C) there are TWO fixed prices:
5.1 The BUY price (what you pay)
origin sale (S is g's ORIGIN) = baseValue × (1 − originDiscount) × jitter
hub resale (hub-pool good, S is a hub) = baseValue × hubPriceFactor × jitter
everywhere else = NOT ON SALE (buyPrice = 0)
- originDiscount = 50% — the origin sells at ~half base. The origin is the galaxy's only cheap source of its good.
- hubPriceFactor = 1.0 — hubs resell at standard (never below the origin — there is exactly one cheap place).
- A world's floor is only its signature (its one product); a hub's floor is the hub-pool selection plus its own system's signature (same day's batch, same source — a station in the fuel system resells fuel at the origin price).
5.2 The SELL price (what you get paid)
sell(g, C, S) = baseValue(g)
× distanceBand(hops(S → origin(g))) ← THE dominant term
× (1 + demandBonus if C wants g) ← the secondary modifier
× jitter
, CAPPED at the shop's own BUY price for g ← the anti-loop rule
-
distanceBands (banded, not linear — the trip pays in steps):
hops from origin 0 2 4 7 11+ factor ×0.75 ×1.15 ×1.85 ×2.75 ×3.75 profit/unit (≈) 0 → 0.25× 0.65× 1.35× 2.25× 3.25× Profit is in base-value multiples (≈ (factor − 0.5) × base, before the +10% demand bonus and jitter); 0 hops nets ~0 — the anti-loop cap. HALVED-MARGIN curve (f′ = 0.25 + f/2 on the original ×1.0 / ×1.8 / ×3.2 / ×5.0 / ×7.0): every band's per-unit profit is half the original, distance stays the dominant term. The jump tree's diameter (~17 hops) is the natural ceiling. No cap: the band with the largest
minHops ≤ hopswins. -
demandBonus = +10% when the class wants the good (
g.demandcontainsC) — 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.mjssweeps 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.jsis a pure module;spend()returns a NEW map,GameScene.spendStockswaps the reference;SaveData.jscaptures/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.15 × 1.1 ≈ 84 → ≈ 53/unit profit on a 2-hop run. Seven hops
out: 66 × 2.75 × 1.1 ≈ 200 → ≈ 169/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 floors (v2)
| Shop | Floor |
|---|---|
| A world | EXACTLY its signature good — one row, origin price, the "N left" batch strip. |
| A station | a seeded 10–16 from the hub pool (standard price) + its own system's signature (origin price, batch-limited). |
| A capital | a seeded 12–16 from the hub pool (standard price) + its own system's signature. Its SELL list is the entire catalog (the universal sink). |
Every world's BUYBACK list is its class's wanted goods (the demand rule) — a
place that wants fuel buys back fuel; a capital buys back everything.
The scout panel (tap a buy row — js/ui/shop/ScoutPanel.js + TradeScout.js)
reports the good's MADE IN (its origin system, or "HUB RESALE"), its
SOUGHT ON classes, and the top-3 charted exits ranked by sell price — with
tap-to-SET DESTINATION on each exit. That is the player's strategy tool:
the economy is a landscape, and this is the map of it.
7. How it hangs off the existing galaxy
| Existing system | Economy uses it |
|---|---|
Jump network / hops (Galaxy.js, data/galaxy.json) |
the distance bands in pricing — hops ≈ map distance. Longer path ⇒ higher sell price. |
Faction territories (data/factions.json) |
capital class = faction seats (universal premium buyers). Faction tariff/patron hooks (economy.json → faction). |
Planet classes (data/planets.json) |
the six world classes — origin-fit (assignment) + demand bonus (pricing). |
Settlements (data/settlements.json, every planet settled) |
each settled system is a shop; the origin assignment's candidate pool. |
Stations (data/stations.json) |
the station class (hub-pool resellers, wider floors). |
Zones (data/galaxy.json → zones) |
the origin assignment's tie-break (zone → tier fit). |
Reputation (js/reputation/Reputation.js) |
standing with a place's owner faction nudges its prices (hook, economy.json → reputation). |
SHOP button (surface deck, data/actionbar.json) |
the existing surface shop action opens the buy/sell UI. |
cargoHold (data/ship.json) |
your carrying capacity for goods. |
Credits (builds, quest rewards already reference credits) |
the currency. HUD next to minerals. |
Determinism: all of this is a pure function of the seed (lazy and eager generation agree; saves stay tiny — we store only your credits, your carried goods, and the batch remainders, never the shops).
8. The credits currency (decided)
- Displayed next to the minerals HUD (upper-right). Minerals are bulk matter; credits are trade value.
- You start at 0 (
economy.json → credits.startingCredits). - Earning credits = selling goods + mission/quest credit rewards + the bounded local batch loop. Spending = builds, upgrades, and buying goods.
- The cargo bays are the first credit sink — the hold expands 4 → 8 → 12 → 16
through three ship builds paid in credits + minerals
(
data/builds.json → cargo-bay-8/12/16: 500 cr + 200 min / 800 cr + 350 min / 1200 cr + 500 min). - Trading is LOCKED until the third story quest. Until then the economy exists in the world (shops, prices, batches) but the buy/sell UI is gated.
9. Interact, don't control (your requirement)
What the player does: pick goods, buy at the origin, travel, sell far, spend. Nothing else.
What the player cannot do: change prices, stock a shop, corner a market, or make a good appear that isn't made. The only "control" surface that exists is light and comes from the existing systems: reputation slightly moves a place's prices, and (later) factions may patron/tax specific goods. That's flavor, not management. The daily batch is the one place the economy has a memory — and it only ever refills in the player's favor.
10. Files (v2)
| File | What it is |
|---|---|
data/goods.json |
114 goods across 9 categories, each with producer/demand/weight/tier/value/flavor. The catalog (unchanged by v2 — the assignment works on it). |
data/economy.json |
All the tuning knobs (model v2: originDiscount, hubPriceFactor, demandBonus, distanceBands, jitter, the daily batch, cargo, credits). version: 2. |
js/economy/Economy.js |
The pure price/stock engine: originOf/signatureOf/isHubPool, buyPrice/sellPrice/shopFor, tradePreview (with the stock-cap backstop), and the buildEconomy generation pass (classBySystem + the strict signatures/origins/hubPool assignment + producerHops). |
js/economy/Stock.js |
NEW (v2) — the time-gated daily batch: dailyBatch, economyDay, batchRemaining, spend (pure, reference-swap; state = { [systemId]: { day, left } }). |
js/economy/TradeScout.js |
The strategy layer (pure): scoutGood() — the good's origin (MADE IN), demand, and the top-N charted exits ranked by sell price. |
js/economy/CargoHold.js |
The trade-goods hold — a pure, framework-free class. One good per bay. |
js/ui/shop/TradeGrid.js |
The shop's SELL side: a 4×4 grid of the player's cargo bays. |
js/ui/shop/BuyList.js |
The shop's BUY side: the floor as wide rows — icon + name + flavor + price + stepper, plus the "N left" / "SOLD OUT" batch strip (v2) and the stepper clamp at the remaining batch. |
js/ui/shop/ScoutPanel.js |
The goods scout (v2): the panel that replaces the cargo grid when a buy row is tapped — MADE IN / SOUGHT ON / BEST EXITS, with tap-to-set-destination rows. |
js/ui/shop/ShopView.js |
The buy/sell (and buyback) screen: the two grids + the scout + a live NET preview + Confirm/Clear. Owns the batch wiring (stockRemaining/stockCapsFor, confirm() spends the batch, _afterTrade re-renders the "N left" strip). |
js/galaxy/Galaxy.js |
Calls buildEconomy at the end of generation (the galaxy.economy seam). |
js/entities/Ship.js |
ship.credits + the ledger + ship.cargo (the CargoHold). |
js/save/SaveData.js |
Credits + cargo + economyStock (the batch remainders) captured/restored; cleanEconomyStock normalizes the shape; a new run clears it. |
js/scenes/GameScene.js |
Owns economyStock (registry-backed), spendStock() (the reference swap), the credits HUD, quest payouts. |
js/scenes/SurfaceScene.js |
Opens the ShopView over the shop clip; the SET DESTINATION offer from a scout exit. |
data/builds.json |
The cargo bays (cargo-bay-8/12/16) — the first credit sink (credits + minerals). |
dev/economy.test.mjs |
The engine's invariants: the strict 1:1 assignment, hub-pool completeness, determinism, origin-only sales, the loop, the distance bands, the shop floors, and the anti-loop sweep (zero positive same-shop spreads, every system × class). |
dev/cargo.test.mjs |
The hold's rules + the tradePreview caps (credits/cargo/stock, sell→buy in one confirm, the buyback exemption from the batch). |
dev/shop-ui.test.mjs |
The shop screen's LOGIC headlessly: sell, buy, the daily batch ("N left" → clamp → SOLD OUT → engine backstop), scroll/clip, the two caps, the buyback, the scout panel + set-destination. |
dev/trade-scout.test.mjs |
The scout report's invariants (origin field, exits ranked, determinism). |
dev/saves.test.mjs |
The save bank + the economyStock round-trip (capture/restore/reset/legacy/malformed). |
11. Decisions (locked)
v2 decisions (this pass)
- ONE WORLD, ONE GOOD — every tradeable good has exactly one origin system; every settled system sells exactly one product (its signature). Strict seeded 1:1; barren (jump-gate-only) systems host nothing.
- Distance is the dominant profit factor — banded hops from the origin (×0.75 / ×1.15 / ×1.85 / ×2.75 / ×3.75 — the halved-margin curve, f′ = 0.25 + f/2), no cap (the tree's diameter is the ceiling). Planet type contributes at most +10% (demand bonus).
- 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.
- 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).
- 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)
- Initial credits — trading gated until the third story quest; first credits from quest rewards + (later) asteroid gemstone mining.
- Cargo — fixed bays, one good each; 4 unlocked → 16-slot grid.
- Reputation & faction prices — deferred (hooks in
economy.json). - Zones — hop-distance only (no per-zone price swing).
- Station vs. capital — stations = wide variety (resellers); capitals = the universal buyer.
- Credits are the go-between — no barter.
12. The shop screen (player-facing UI)
Where the loop is actually played. You open it from the surface deck's SHOP
button; the screen sits over the shop clip. One component —
js/ui/shop/ShopView.js — composing a TradeGrid (the SELL side) + a BuyList
(the BUY side) + the ScoutPanel (the goods scout).
Entry state (showcase the clip). First open shows only the tab sub-bar
([ BUY/SELL | BUYBACK ] │ [ CLEAR | CONFIRM ] │ NET 0 cr) above the command
deck. Clicking Buy/Sell reveals the trade panel: the SELL grid (your
cargo bays, tap to check off) and the BUY list side by side, spanning the
screen.
BUY list (v2). A world's list is ONE wide row — its signature good: icon, name, one-line flavor, the origin price, a "N left" strip (the day's remaining batch — the stepper can't exceed it; 0 reads SOLD OUT in magenta), and the −/qty/+ stepper. A hub's list is its 10–16 hub-pool rows (unlimited — no strip) + its own signature (with the strip). Tapping a row's body (not the stepper) opens the TRADE SCOUT: MADE IN (the origin system, or HUB RESALE), SOUGHT ON (the demand classes), and the BEST EXITS ranked by sell price — tapping an exit row offers to SET DESTINATION to that system. Tapping anywhere else closes the scout.
NET readout — the live credit delta on the sub-bar (green up, red spend,
dim at zero), the same preview() the Confirm button is gated on. SELL → BUY
is atomic — one Confirm executes credits + sale − buy, cargo − sold + bought. BUYBACK (second tab) lists what you sold this visit, buyable back
at the sale price — a neutral undo (sell→buyback nets zero; it never draws on
the batch).
Determinism + the one stateful seam. Prices and floors are fixed per
(good, location) — pure functions of the seed; reopening a shop re-derives the
same floor and prices. The only state is the origin's remaining daily batch
(economyStock — save/restore in SaveData.js), and it only ever regenerates
while you're away. So the economy stays "interact, don't control": you trade
against a fixed landscape, and the only clock in it runs in your favor.
Tested headlessly (dev/shop-ui.test.mjs, dev/economy.test.mjs,
dev/cargo.test.mjs): the full logic — sell, buy, the daily batch (clamp →
SOLD OUT → engine backstop), the two caps, the buyback neutral-undo, the scout
- set-destination, the anti-loop sweep, and the save round-trip of the batch. Pixel layout and the slide-in animation are verified in the browser.