Add the src/lib/economy service (GM baselines, deterministic per-period demand drift, price modifier computation, event emission) and the economy-tick bin. Gated by GameRules economy.enabled; period-idempotent with a cold-start observation gate. Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
98 lines
7 KiB
Markdown
98 lines
7 KiB
Markdown
# Supply & Demand Economy — Phase 1 Design
|
||
|
||
Status: **Approved direction** (decisions confirmed with product owner, 2026-09-06). Not yet implemented.
|
||
|
||
## Confirmed decisions
|
||
|
||
| Decision | Choice |
|
||
|---|---|
|
||
| Supply model | **Abstract per-item ledger** (`market-state` collection), GM baseline + transaction-driven adjustments. Never derived by scanning physical stockpiles. |
|
||
| Item scope | **Resources + tradeable assets only.** Vehicles deferred to a later phase. |
|
||
| Pricing reach | **New NPC listings only** + displayed reference price. Existing listings, player listings, and open negotiations are never repriced. |
|
||
| Simulation cadence | **New `economy-tick` bin** (external cron, idempotent, SSE notify). `market-tick` consumes the resulting state. |
|
||
| GM surface | **Minimal per-item** (baseline supply, baseline demand, price band, enable toggle) on Resources/Assets; global knobs on GameRules. |
|
||
|
||
## Current-state facts this builds on
|
||
|
||
- Price anchors: `Assets.marketTrading.perceivedValue.baseBuyPrice/baseSellPrice` (`src/lib/market/index.ts` — `getBaseBuyPrice`/`getBaseSellPrice`), `Resources.baseValue`, `Structures.sellPrice`.
|
||
- NPC listing generation: `src/scripts/marketTick.ts` — `autoPrice` (±15%) × `vendor.priceModifier`, `minPrice = price × 0.6`.
|
||
- Real-demand signal sources that already exist: `bank-transactions` (type `payment`, memo carries asset + qty), `market-listings` (sold/buyer/soldAt), `market:sale` event logs with structured data.
|
||
- Unwired production engine: `Structures.autoGeneratedResources[]` (min/max amount, frequency, produce chance) — schema exists, no tick consumes it.
|
||
- Tick bin pattern: `bin[]` registration in `src/payload.config.ts`, `src/scripts/<key>.ts` exporting `script`, `createBinLogger` (`src/scripts/lib/binFileLogger.ts`), SSE notify via `/api/game-tick/notify` with `x-game-tick-secret`.
|
||
- Negotiation system invariants to protect: NPC stance anchored to asking price, `minPrice` floor, patience meter, strictly-increasing buyer offers (`src/lib/market/negotiations.ts`).
|
||
|
||
## Architecture
|
||
|
||
### 1. `market-state` collection (new) — the supply/demand ledger
|
||
|
||
One doc per item (`itemType: "resource" | "asset"`, `itemId`). Fields:
|
||
|
||
- `gmBaselineSupply`, `gmBaselineDemand` — numbers, GM-set (copied from per-item fields on first creation).
|
||
- `currentSupply` — adjusted by: unit purchases from NPC listings (decrement), unit sales to NPC buyers (increment, later phase), production events (increment).
|
||
- `artificialDemand` — slow-drifting NPC pressure, updated per tick (random walk bounded around `gmBaselineDemand`).
|
||
- `realDemand` — decaying rolling aggregate of completed unit purchases (quantity-weighted), decayed each tick by a GameRules decay factor.
|
||
- `priceModifier` — computed, bounded: derived from demand-vs-supply ratio, clamped to the item's band (`±maxModifier`).
|
||
- `lastComputedAt`.
|
||
|
||
### 2. Per-item GM fields (Resources + Assets)
|
||
|
||
- `economy.enabled` (default true for tradeable items)
|
||
- `economy.baselineSupply`, `economy.baselineDemand` (numbers; defaults derived from rarity)
|
||
- `economy.maxModifier` (price band, e.g. 0.5 → price can move ±50% of base)
|
||
|
||
### 3. GameRules global knobs (new group `economy`)
|
||
|
||
- `economyTickIntervalHint` (docs for cron), `realDemandWindowTicks`, `realDemandDecay`, `artificialDemandDrift`, `coldStartBehavior` (use baseline until N observations), global price band default, `economyEnabled` master gate (pattern: `staffHiringEnabled`).
|
||
|
||
### 4. `economy-tick` bin (new)
|
||
|
||
`src/scripts/economyTick.ts` + logic in `src/lib/economy/`:
|
||
|
||
1. For each enabled market-state doc (creating missing ones from GM baselines): decay `realDemand`, drift `artificialDemand`, recompute `priceModifier` from `demand / supply` ratio, clamp to band.
|
||
2. Cold start: items with no purchase observations keep `priceModifier = 1` (neutral).
|
||
3. Emits a batch summary `economy:tick` event + per-item `economy:price-change` events only when the modifier crosses a visible threshold (avoid event spam).
|
||
4. SSE notify (same path as other bins). Idempotent: a re-run recomputes the same values from stored state; drift steps are seeded deterministically per period.
|
||
|
||
### 5. Consumption by market-tick
|
||
|
||
When generating a new NPC auto listing, price becomes:
|
||
|
||
```
|
||
price = round(baseBuyPrice × priceModifier × vendor.priceModifier)
|
||
```
|
||
|
||
`autoPrice` ±15% randomness is dropped (the modifier replaces it) — `minPrice = price × 0.6`, `desiredPrice = price` unchanged, so negotiation invariants are untouched. A "reference price" (modifier-applied base) is displayed on item views for player pricing guidance.
|
||
|
||
Shortage behavior (phase 1): modifier raises prices and NPC restock quantity scales down when `supply < demand` (bounded, never zero without a GM flag).
|
||
|
||
### 6. Real-demand ingestion
|
||
|
||
Hook points (no new collection needed in phase 1 — read from existing records):
|
||
|
||
- `buyListing` (`src/lib/market/index.ts`) — on successful purchase of an NPC/vendor listing, increment `realDemand` on that item's market-state (quantity-weighted).
|
||
- Player-to-player sales do **not** move prices in phase 1 (no double counting).
|
||
|
||
## Out of scope (explicit deferrals)
|
||
|
||
NPC faction economies, autonomous NPC buyers with wallets, production/crafting chains, vehicle market, physical-stock reconciliation, multi-currency, repricing existing listings/negotiations, real-time price streaming (refresh-on-tick only — SSE bus is single-instance).
|
||
|
||
## Migration & rollout
|
||
|
||
- New collection + fields = **named migration** (`bun run payload migrate:create add_market_economy_state`), registered in `migrations/index.ts`; dev applies via `bun run payload migrate` (`push: false`). Never `db push`.
|
||
- Existing listings stay at their prices; modifier applies only to newly generated listings. `economyEnabled` master flag allows instant disable.
|
||
- Regenerate `src/payload-types.ts` after schema changes.
|
||
|
||
## Top risks (from design review)
|
||
|
||
1. **Non-atomic mutations** — `buyListing` already credits locker before clearing payment; new flows must not compound this. Define failure/retry behavior before adding ledger writes.
|
||
2. **Negotiation breakage** — never reprice under open negotiations (phase 1 avoids entirely by only touching new listings).
|
||
3. **Ledger/physical divergence** — abstract supply will not track destroyed/admin-edited stock; GM can reset per-item state from the admin panel (rebuild from baselines).
|
||
4. **Tick duplication** — external cron may retry; idempotency + deterministic period boundaries required.
|
||
5. **Scope explosion** — every extension (factions, vehicles, production) is a separate approved phase.
|
||
|
||
## Implementation phases
|
||
|
||
1. **P1a — schema**: `market-state` collection, per-item economy fields, GameRules knobs, named migration, generated types.
|
||
2. **P1b — engine**: `src/lib/economy/` (modifier math, decay, drift, cold start) + unit-testable pure functions.
|
||
3. **P1c — tick**: `economy-tick` bin + event types + SSE wiring.
|
||
4. **P1d — consumption**: market-tick uses modifier for new NPC listings; restock scaling; reference price display; `buyListing` real-demand ingestion; GM reset action.
|