orbit/docs/ECONOMY.md

23 KiB
Raw Permalink Blame History

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)

  1. Distance is the dominant factor in profit. A good's sell price grows with its hop distance from its ORIGIN (the one world that makes it). Longer journey ⇒ meaningfully better reward. This is the business model.
  2. Planet type is a secondary modifier. A world's class contributes a small demand bonus (the class wants the good) and drives the THEMATIC FIT of the origin assignment (a gas world is where fuel is made) — but it never overrides distance.
  3. Goods are regionally produced — one world, one good. Every tradeable good has exactly ONE origin system (a seeded strict 1:1 assignment); every settled system sells exactly ONE product — its signature good, cheap, in a daily batch. The catalog's remainder is the hub pool, resold by stations/capitals at standard price.
  4. Same-system trading earns money, but it is not the business model. A buy-local/sell-local loop in one system is bounded by the daily batch (a few units a day) — pocket change. Long-haul SELLs are unlimited: the market is a sink.
  5. Interact, don't control. No market simulation, no stock you can manipulate, no persistent shop state beyond the day's remaining batch. The economy is a landscape you travel through and exploit.
  6. Local money is possible without crossing the galaxy. You never have to cross the map to make a living; you choose to, for much more.

2. The core loop

   LAND on 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 producer list is the fit signal; where the catalog leaves no fitting system, the assignment falls back (a few goods are "anywhere" — the deterministic seed still fixes exactly where).
  • Demand — a class in the good's demand list pays a +10% bonus (economy.json → pricing.demandBonus). The capital class wants everything: the great sink, the universal buyer, the long-haul ceiling.

The 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-assign pass in Economy.buildEconomy, greedy most-constrained-first with zone→tier fit and a seeded tie-break). 78 settled systems ↔ 78 signature goods out of the 114-goods catalog; the 12 jump-gate-only (barren) systems host nothing — there is no world to sell from.
  • The catalog's remainder (36 goods) is the HUB POOL — no origin world; stations and capitals resell a seeded selection of it (standard price, wide floors for variety).
  • Thematic fit is near-total — the origin assignment prefers systems whose classes produce the good (in the test seed, 78/78 fit; the fallback only fires where the catalog leaves a good with no fitting system).
  • Gems are no longer special. The eight gemstones are still in the catalog (and can be a world's signature, earning half-price origin + the demand bonus), but there is no more "one per class, always on every shelf" rule.

Why: one product per world makes each place legible — you can name a system by what it makes ("the fuel world"), the buy menu is trivially small, and the only economic question a player has to answer is where does it go?


5. Pricing — fixed per location, distance 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 ≤ 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.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)

  1. ONE WORLD, ONE GOOD — every tradeable good has exactly one origin system; every settled system sells exactly one product (its signature). Strict seeded 1:1; barren (jump-gate-only) systems host nothing.
  2. Distance is the dominant profit factor — banded hops from the origin (×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).
  3. The daily batch bounds the local loop — 6 units per origin per wall-clock day (1h); sell side unlimited. Same-system trading is bounded pocket change, never infinite money.
  4. The anti-loop cap — sell price ≤ own buy price at the same shop; no shop may ever be a positive same-shop spread (the v1 exploit, dead).
  5. The catalog is reused as-is (114 goods) — the 36 remainder form the hub pool; no new goods, no per-class gems.

v1 decisions (still in force)

  1. Initial credits — trading gated until the third story quest; first credits from quest rewards + (later) asteroid gemstone mining.
  2. Cargo — fixed bays, one good each; 4 unlocked → 16-slot grid.
  3. Reputation & faction prices — deferred (hooks in economy.json).
  4. Zones — hop-distance only (no per-zone price swing).
  5. Station vs. capital — stations = wide variety (resellers); capitals = the universal buyer.
  6. Credits are the go-between — no barter.

12. The shop screen (player-facing UI)

Where the loop is actually played. You open it from the surface deck's SHOP button; the 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.