orbit/docs/ECONOMY.md

400 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.