1
0
Fork 0

feat(economy): add economy tick with demand simulation and price modifiers

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>
This commit is contained in:
Jason Fraley 2026-09-06 17:22:30 -04:00
parent ded5d4b341
commit 447df220f5
17 changed files with 24329 additions and 1 deletions

View file

@ -0,0 +1,98 @@
# 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.

115
src/lib/economy/config.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* Supply & demand economy — configuration accessors.
*
* Resolves global knobs from the GameRules `economy` group and per-item
* configuration from the two item shapes (Resources use a top-level
* `economy` group, Assets nest theirs under `marketTrading`).
*/
import type { Payload } from "payload";
import type { EconomyKnobs, EconomyItemType, ItemEconomyConfig } from "./types";
import { ECONOMY_DEFAULTS, ITEM_ECONOMY_DEFAULTS } from "./types";
type GameRulesDoc = {
economy?: {
enabled?: boolean | null;
defaultPriceBand?: number | null;
realDemandWindowTicks?: number | null;
realDemandDecay?: number | null;
artificialDemandDrift?: number | null;
coldStartObservations?: number | null;
tickIntervalHintMinutes?: number | null;
} | null;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function toNumber(value: number | null | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
/** Read the global economy knobs from GameRules, falling back to defaults. */
export function knobsFromGameRules(rules: GameRulesDoc | null | undefined): EconomyKnobs {
const economy = rules?.economy;
return {
defaultPriceBand: clamp(
toNumber(economy?.defaultPriceBand, ECONOMY_DEFAULTS.defaultPriceBand),
0,
1,
),
realDemandWindowTicks: Math.max(
1,
toNumber(economy?.realDemandWindowTicks, ECONOMY_DEFAULTS.realDemandWindowTicks),
),
realDemandDecay: clamp(
toNumber(economy?.realDemandDecay, ECONOMY_DEFAULTS.realDemandDecay),
0,
1,
),
artificialDemandDrift: clamp(
toNumber(economy?.artificialDemandDrift, ECONOMY_DEFAULTS.artificialDemandDrift),
0,
1,
),
coldStartObservations: Math.max(
0,
toNumber(economy?.coldStartObservations, ECONOMY_DEFAULTS.coldStartObservations),
),
tickIntervalHintMinutes: Math.max(
1,
toNumber(economy?.tickIntervalHintMinutes, ECONOMY_DEFAULTS.tickIntervalHintMinutes),
),
};
}
export async function getEconomySettings(
payload: Payload,
): Promise<{ enabled: boolean; knobs: EconomyKnobs }> {
const rules = (await payload.findGlobal({ slug: "game-rules", depth: 0 })) as GameRulesDoc;
return { enabled: rules?.economy?.enabled === true, knobs: knobsFromGameRules(rules) };
}
type ResourceLike = {
economy?: {
enabled?: boolean | null;
baselineSupply?: number | null;
baselineDemand?: number | null;
maxModifier?: number | null;
} | null;
};
/**
* Resolve per-item economy configuration. Both item shapes store the group
* at the top level (`economy.*`); returns null when the item's economy is
* disabled (no ledger, no pricing).
*/
export function resolveEconomyConfig(
_itemType: EconomyItemType,
doc: ResourceLike | null | undefined,
knobs: EconomyKnobs,
): ItemEconomyConfig | null {
const economy = doc?.economy;
if (!economy || economy.enabled !== true) {
return null;
}
const priceBand = clamp(
toNumber(economy.maxModifier, knobs.defaultPriceBand),
0,
1,
);
return {
enabled: true,
baselineSupply: Math.max(
0,
toNumber(economy.baselineSupply, ITEM_ECONOMY_DEFAULTS.baselineSupply),
),
baselineDemand: Math.max(
0,
toNumber(economy.baselineDemand, ITEM_ECONOMY_DEFAULTS.baselineDemand),
),
priceBand,
};
}

View file

@ -0,0 +1,120 @@
import type { Payload, Where } from "payload";
import type { EconomyItemType } from "./types";
import type { ItemRef } from "./persistence";
/**
* Consumption-side helpers: the market system reads ledger state to price new
* NPC listings, ingests real demand from completed purchases, and GMs can
* rebuild a doc from its baselines when the ledger drifts from reality.
*/
export interface MarketPriceContext {
priceModifier: number;
currentSupply: number;
artificialDemand: number;
realDemand: number;
}
type PayloadLike = Payload;
export async function getMarketPriceContext(
payload: PayloadLike,
itemType: EconomyItemType,
itemId: number,
): Promise<MarketPriceContext | null> {
const docs = await payload.find({
collection: "market-state",
where: { and: [{ itemType: { equals: itemType } }, { itemId: { equals: itemId } }] },
limit: 1,
depth: 0,
overrideAccess: true,
});
const doc = docs.docs[0];
if (!doc) return null;
return {
priceModifier: doc.priceModifier,
currentSupply: doc.currentSupply,
artificialDemand: doc.artificialDemand,
realDemand: doc.realDemand,
};
}
/**
* Fire-and-forget real-demand ingestion for completed purchases of NPC stock.
* The purchase has already succeeded, so failures are logged, never thrown.
*/
export async function recordPurchase(
payload: PayloadLike,
{ itemId, quantity }: { itemId: number; quantity: number },
): Promise<void> {
try {
const docs = await payload.find({
collection: "market-state",
where: { and: [{ itemType: { equals: "asset" } }, { itemId: { equals: itemId } }] },
limit: 1,
depth: 0,
overrideAccess: true,
});
const doc = docs.docs[0];
if (!doc) return;
await payload.update({
collection: "market-state",
id: doc.id,
data: { realDemand: doc.realDemand + quantity },
overrideAccess: true,
depth: 0,
});
} catch (error: unknown) {
payload.logger.error({ err: error }, "economy purchase ingestion failed");
}
}
/** Rebuilds market-state docs from their GM baselines (admin repair action). */
export async function resetMarketStateDocs(
payload: PayloadLike,
{ items }: { items?: ItemRef[] },
): Promise<{ reset: number; scanned: number }> {
const where: Where | undefined = items
? {
or: items.map((item): Where => ({
and: [
{ itemType: { equals: item.itemType } },
{ itemId: { equals: item.itemId } },
],
})),
}
: undefined;
const found = await payload.find({
collection: "market-state",
...(where ? { where } : {}),
limit: 500,
depth: 0,
overrideAccess: true,
});
let reset = 0;
for (const doc of found.docs) {
try {
await payload.update({
collection: "market-state",
id: doc.id,
data: {
currentSupply: doc.gmBaselineSupply,
artificialDemand: doc.gmBaselineDemand,
realDemand: 0,
priceModifier: 1,
lastComputedAt: null,
},
overrideAccess: true,
depth: 0,
});
reset += 1;
} catch (error: unknown) {
payload.logger.error({ err: error }, `failed to reset market-state doc ${doc.id}`);
}
}
return { reset, scanned: found.docs.length };
}

43
src/lib/economy/events.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Supply & demand economy — event emission.
*
* Payload-dependent; called by the `economy-tick` bin after a successful
* tick. Emits one batch summary plus one event per recorded price change.
*/
import { EventTypes } from "@/utils/event-log/eventTypes";
import { emitGameEvent } from "@/utils/event-log/emit";
import type { TickSummary } from "./tick";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export async function emitEconomyEvents(
payload: PayloadType,
result: TickSummary,
): Promise<void> {
await emitGameEvent(payload, {
type: EventTypes.EconomyTick,
message: `Economy tick processed ${result.processed} item(s) with ${result.priceChanges.length} price change(s)${result.errors > 0 ? ` and ${result.errors} error(s)` : ""}.`,
data: {
processed: result.processed,
priceChangeCount: result.priceChanges.length,
errors: result.errors,
},
});
for (const change of result.priceChanges) {
await emitGameEvent(payload, {
type: EventTypes.EconomyPriceChange,
message: `Price modifier for ${change.itemType} #${change.itemId} moved from ${change.from} to ${change.to}.`,
targetCollection: "market-state",
targetId: change.stateId,
data: {
itemType: change.itemType,
itemId: change.itemId,
from: change.from,
to: change.to,
},
});
}
}

164
src/lib/economy/math.ts Normal file
View file

@ -0,0 +1,164 @@
/**
* Supply & demand economy — pure modifier math.
*
* No Payload imports; every function is deterministic except where an `rng`
* is injected (default `Math.random`). See docs/economy/supply-demand-design.md.
*/
import type {
EconomyKnobs,
EconomyTickResult,
MarketStateSnapshot,
} from "./types";
/** Round to 4 decimal places to keep stored floats stable across ticks. */
export function round4(value: number): number {
return Math.round(value * 10000) / 10000;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** Decay real demand by a factor in [0, 1]: 0 = no decay, 1 = wipe. */
export function decayRealDemand(realDemand: number, decay: number): number {
return round4(realDemand * (1 - clamp(decay, 0, 1)));
}
/**
* Random-walk the artificial NPC demand around its baseline, bounded to
* [0, 2 × baseline] so demand never goes negative or runs away.
* A full step is ±drift × baseline.
*/
export function driftArtificialDemand(
current: number,
baseline: number,
drift: number,
rng: () => number = Math.random,
): number {
const step = (rng() * 2 - 1) * drift * baseline;
return clamp(round4(current + step), 0, baseline * 2);
}
/**
* Price modifier from the demand/supply ratio, clamped to ±band around 1.
* Zero supply is treated as maximum scarcity (1 + band). The band itself is
* clamped to [0, 1].
*/
export function priceModifierFromRatio(
supply: number,
demand: number,
band: number,
): number {
const safeBand = clamp(band, 0, 1);
if (supply <= 0) {
return round4(1 + safeBand);
}
const ratio = demand / supply;
return round4(clamp(ratio, 1 - safeBand, 1 + safeBand));
}
/**
* Cold-start gate: an item needs at least `knobs.coldStartObservations`
* completed purchases (realDemand) before its price starts moving; until
* then the modifier stays neutral at 1.
*/
export function computePriceModifier(
supply: number,
demand: number,
realDemand: number,
band: number,
knobs: Pick<EconomyKnobs, "coldStartObservations">,
): number {
if (realDemand < knobs.coldStartObservations) {
return 1;
}
return priceModifierFromRatio(supply, demand, band);
}
/**
* Apply one tick of economy math to a snapshot.
*
* - realDemand decays by the knob factor
* - artificialDemand random-walks around its baseline
* - priceModifier is recomputed from the demand/supply ratio (cold-start gated)
* - currentSupply is unchanged in phase 1 (production arrives in a later phase)
*/
export function applyEconomyTick(
state: MarketStateSnapshot,
config: { baselineDemand: number; priceBand: number },
knobs: EconomyKnobs,
rng: () => number = Math.random,
): EconomyTickResult {
const realDemand = decayRealDemand(state.realDemand, knobs.realDemandDecay);
const artificialDemand = driftArtificialDemand(
state.artificialDemand,
config.baselineDemand,
knobs.artificialDemandDrift,
rng,
);
const totalDemand = artificialDemand + realDemand;
const priceModifier = computePriceModifier(
state.currentSupply,
totalDemand,
realDemand,
config.priceBand,
knobs,
);
return {
currentSupply: state.currentSupply,
artificialDemand,
realDemand,
priceModifier,
};
}
/**
* Mulberry32 — small deterministic PRNG. Same seed always yields the same
* sequence, so a tick re-run within the same period reproduces its drift.
*/
export function createSeededRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Deterministic seed for a cron period: stable for every run within the
* same interval window, different across windows (idempotent re-runs).
*/
export function periodSeed(now: Date, intervalMinutes: number): number {
const periodMs = Math.max(1, intervalMinutes) * 60_000;
return Math.floor(now.getTime() / periodMs);
}
/**
* Minimum modifier delta for a change to be recorded (and later emitted as
* an economy:price-change event) — keeps event spam down for micro-moves.
*/
export const PRICE_CHANGE_THRESHOLD = 0.05;
export function shouldEmitPriceChange(from: number, to: number): boolean {
return Math.abs(to - from) >= PRICE_CHANGE_THRESHOLD;
}
/**
* Scales an NPC restock quantity down when supply runs short of demand.
* Bounded: the scale factor is clamped to [0.25, 1], so vendors still stock
* something under extreme shortage (never zero without a GM flag) and the
* quantity is never reduced below 1.
*/
export function scaleQuantityForShortage(
quantity: number,
supply: number,
demand: number,
): number {
if (demand <= 0 || supply >= demand) return quantity;
const factor = clamp(supply / demand, 0.25, 1);
return Math.max(1, Math.floor(quantity * factor));
}

View file

@ -0,0 +1,62 @@
/**
* Supply & demand economy — find/load helpers shared by the tick
* orchestrator. Kept out of `tick.ts` to respect the 250-line ceiling.
*/
import type { Payload } from "payload";
import type { Where } from "payload";
import type { EconomyItemType } from "./types";
export type ItemRef = { itemType: EconomyItemType; itemId: number };
export const FIND_LIMIT = 500;
export async function findItemIds(
payload: Payload,
itemType: EconomyItemType,
items: ItemRef[] | undefined,
): Promise<number[]> {
if (items) {
return items.filter((i) => i.itemType === itemType).map((i) => i.itemId);
}
const { docs } = await payload.find({
collection: itemType === "resource" ? "resources" : "assets",
where: { tradeable: { equals: true } },
limit: FIND_LIMIT,
depth: 0,
});
return docs.map((d) => d.id);
}
export async function loadItemDocs(
payload: Payload,
itemType: EconomyItemType,
itemIds: number[],
): Promise<Map<number, Record<string, unknown>>> {
const byId = new Map<number, Record<string, unknown>>();
const collection = itemType === "resource" ? "resources" : "assets";
for (let i = 0; i < itemIds.length; i += FIND_LIMIT) {
const chunk = itemIds.slice(i, i + FIND_LIMIT);
const { docs } = await payload.find({
collection,
where: { id: { in: chunk } },
limit: FIND_LIMIT,
depth: 0,
});
for (const doc of docs) {
byId.set(doc.id, doc as unknown as Record<string, unknown>);
}
}
return byId;
}
export function marketStateWhereFor(items: ItemRef[]): Where {
return {
or: items.map(
(i): Where => ({
and: [{ itemType: { equals: i.itemType } }, { itemId: { equals: i.itemId } }],
}),
),
};
}

226
src/lib/economy/tick.ts Normal file
View file

@ -0,0 +1,226 @@
/**
* Supply & demand economy — tick orchestrator.
*
* Persists the ledger: ensures one market-state doc per enabled tradeable
* item (copied from GM baselines), then advances every doc one tick via the
* pure math in `math.ts`. Called by the `economy-tick` bin (P1c).
*/
import type { Where } from "payload";
import { getEconomySettings, resolveEconomyConfig } from "./config";
import { applyEconomyTick, shouldEmitPriceChange } from "./math";
import {
FIND_LIMIT,
findItemIds,
loadItemDocs,
marketStateWhereFor,
} from "./persistence";
import type { ItemRef } from "./persistence";
import type { EconomyKnobs } from "./types";
import { ECONOMY_DEFAULTS } from "./types";
export type { ItemRef };
export type EnsureSummary = {
created: number;
scanned: number;
};
export type PriceChange = {
itemType: ItemRef["itemType"];
itemId: number;
/** market-state doc id — used as the event-log targetId. */
stateId: number;
from: number;
to: number;
};
export type TickSummary = {
processed: number;
/** Docs skipped because they were already computed in this period. */
skipped: number;
priceChanges: PriceChange[];
errors: number;
disabled: boolean;
};
export type ProcessOptions = {
now?: Date;
rng?: () => number;
items?: ItemRef[];
/**
* Only docs whose lastComputedAt is before this boundary are processed —
* makes cron re-runs within one period idempotent no-ops.
*/
periodStart?: Date;
/** Override the GameRules master gate (hermetic tests / manual runs). */
masterGate?: boolean;
/** Override the GameRules knobs (hermetic tests / manual runs). */
knobs?: EconomyKnobs;
};
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/**
* Create missing market-state docs for enabled tradeable items, copying the
* GM baselines from the item doc. Idempotent: existing docs are left alone.
*/
export async function ensureMarketStateDocs(
payload: PayloadType,
options: { items?: ItemRef[]; knobs?: EconomyKnobs } = {},
): Promise<EnsureSummary> {
const { knobs = ECONOMY_DEFAULTS } = options;
let created = 0;
let scanned = 0;
for (const itemType of ["resource", "asset"] as const) {
const itemIds = await findItemIds(payload, itemType, options.items);
scanned += itemIds.length;
if (itemIds.length === 0) continue;
const { docs: existing } = await payload.find({
collection: "market-state",
where: { and: [{ itemType: { equals: itemType } }, { itemId: { in: itemIds } }] },
limit: FIND_LIMIT,
depth: 0,
select: { itemId: true },
});
const have = new Set(existing.map((d) => d.itemId));
const missing = itemIds.filter((id) => !have.has(id));
if (missing.length === 0) continue;
const itemDocs = await loadItemDocs(payload, itemType, missing);
for (const itemId of missing) {
try {
const config = resolveEconomyConfig(itemType, itemDocs.get(itemId), knobs);
if (!config) continue;
await payload.create({
collection: "market-state",
data: {
itemType,
itemId,
gmBaselineSupply: config.baselineSupply,
gmBaselineDemand: config.baselineDemand,
currentSupply: config.baselineSupply,
artificialDemand: config.baselineDemand,
realDemand: 0,
priceModifier: 1,
},
overrideAccess: true,
});
created += 1;
} catch (error) {
payload.logger.error({
msg: "economy: failed to ensure market-state doc",
itemType,
itemId,
err: error,
});
}
}
}
return { created, scanned };
}
/**
* Advance every market-state doc one tick. Disabled economy (master gate)
* is a no-op. Per-item failures are logged and skipped.
*/
export async function processEconomyTick(
payload: PayloadType,
options: ProcessOptions = {},
): Promise<TickSummary> {
const { now = new Date(), rng = Math.random, items, periodStart } = options;
const settings = await getEconomySettings(payload);
const enabled = options.masterGate ?? settings.enabled;
const knobs = options.knobs ?? settings.knobs;
if (!enabled) {
return { processed: 0, skipped: 0, priceChanges: [], errors: 0, disabled: true };
}
await ensureMarketStateDocs(payload, { items, knobs });
const marketStateWhere: Where | undefined = items ? marketStateWhereFor(items) : undefined;
const { docs } = await payload.find({
collection: "market-state",
...(marketStateWhere ? { where: marketStateWhere } : {}),
limit: FIND_LIMIT,
depth: 0,
});
const resourceDocs = await loadItemDocs(
payload,
"resource",
docs.filter((d) => d.itemType === "resource").map((d) => d.itemId),
);
const assetDocs = await loadItemDocs(
payload,
"asset",
docs.filter((d) => d.itemType === "asset").map((d) => d.itemId),
);
let processed = 0;
let skipped = 0;
let errors = 0;
const priceChanges: PriceChange[] = [];
for (const doc of docs) {
if (
periodStart &&
doc.lastComputedAt &&
new Date(doc.lastComputedAt) >= periodStart
) {
skipped += 1;
continue;
}
try {
const itemDocs = doc.itemType === "resource" ? resourceDocs : assetDocs;
const config = resolveEconomyConfig(doc.itemType, itemDocs.get(doc.itemId), knobs);
if (!config) continue;
const next = applyEconomyTick(
{
currentSupply: doc.currentSupply,
artificialDemand: doc.artificialDemand,
realDemand: doc.realDemand,
priceModifier: doc.priceModifier,
},
{ baselineDemand: config.baselineDemand, priceBand: config.priceBand },
knobs,
rng,
);
if (shouldEmitPriceChange(doc.priceModifier, next.priceModifier)) {
priceChanges.push({
itemType: doc.itemType,
itemId: doc.itemId,
stateId: doc.id,
from: doc.priceModifier,
to: next.priceModifier,
});
}
await payload.update({
collection: "market-state",
id: doc.id,
data: {
currentSupply: next.currentSupply,
artificialDemand: next.artificialDemand,
realDemand: next.realDemand,
priceModifier: next.priceModifier,
lastComputedAt: now.toISOString(),
},
overrideAccess: true,
});
processed += 1;
} catch (error) {
errors += 1;
payload.logger.error({
msg: "economy: failed to process market-state doc",
itemType: doc.itemType,
itemId: doc.itemId,
err: error,
});
}
}
return { processed, skipped, priceChanges, errors, disabled: false };
}

53
src/lib/economy/types.ts Normal file
View file

@ -0,0 +1,53 @@
/**
* Supply & demand economy — shared types and defaults.
*
* Pure module (no Payload imports) — safe to import client-side.
* See docs/economy/supply-demand-design.md for the approved design.
*/
export type EconomyItemType = "resource" | "asset";
/** Per-item economy configuration resolved from Resources `economy.*` or Assets `marketTrading.economy.*`. */
export type ItemEconomyConfig = {
enabled: boolean;
baselineSupply: number;
baselineDemand: number;
/** Price band: the modifier may move up to ±band from 1. Clamped to [0, 1]. */
priceBand: number;
};
/** Global economy knobs (from the GameRules `economy` group, with defaults). */
export type EconomyKnobs = {
defaultPriceBand: number;
realDemandWindowTicks: number;
realDemandDecay: number;
artificialDemandDrift: number;
coldStartObservations: number;
tickIntervalHintMinutes: number;
};
/** The mutable numeric state of a market-state doc, as consumed by the tick math. */
export type MarketStateSnapshot = {
currentSupply: number;
artificialDemand: number;
realDemand: number;
priceModifier: number;
};
/** Result of applying one tick of economy math to a snapshot. */
export type EconomyTickResult = MarketStateSnapshot;
export const ECONOMY_DEFAULTS: EconomyKnobs = {
defaultPriceBand: 0.5,
realDemandWindowTicks: 24,
realDemandDecay: 0.5,
artificialDemandDrift: 0.05,
coldStartObservations: 5,
tickIntervalHintMinutes: 60,
};
/** Baseline defaults used when a GM leaves the per-item fields empty. */
export const ITEM_ECONOMY_DEFAULTS = {
baselineSupply: 100,
baselineDemand: 50,
} as const;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'market-state' BEFORE 'evaluations';
CREATE INDEX "market_state_item_type_idx" ON "market_state" USING btree ("item_type");
CREATE INDEX "market_state_item_id_idx" ON "market_state" USING btree ("item_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
DROP TYPE "public"."enum_game_event_logs_target_collection";
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'npc-staffing', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers');
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
DROP INDEX "market_state_item_type_idx";
DROP INDEX "market_state_item_id_idx";`)
}

View file

@ -33,6 +33,7 @@ import * as migration_20260906_061451_add_wiki_template_css from './20260906_061
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext'; import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html'; import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state'; import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
export const migrations = [ export const migrations = [
{ {
@ -210,4 +211,9 @@ export const migrations = [
down: migration_20260906_100908_add_market_economy_state.down, down: migration_20260906_100908_add_market_economy_state.down,
name: '20260906_100908_add_market_economy_state', name: '20260906_100908_add_market_economy_state',
}, },
{
up: migration_20260906_165837_add_market_state_event_target.up,
down: migration_20260906_165837_add_market_state_event_target.down,
name: '20260906_165837_add_market_state_event_target',
},
]; ];

View file

@ -324,6 +324,10 @@ export default buildConfig({
scriptPath: path.resolve(dirname, "scripts/baseTick.ts"), scriptPath: path.resolve(dirname, "scripts/baseTick.ts"),
key: "base-tick", key: "base-tick",
}, },
{
scriptPath: path.resolve(dirname, "scripts/economyTick.ts"),
key: "economy-tick",
},
{ {
scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"), scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"),
key: "generate-mission", key: "generate-mission",

View file

@ -0,0 +1,66 @@
/**
* Economy tick bin — recomputes the supply/demand ledger (market-state).
*
* Run via `bun run payload economy-tick` (external cron). Uses the GameRules
* `tickIntervalHintMinutes` knob to seed a deterministic per-period RNG so a
* retried run inside the same period produces identical drift steps.
*/
import type { SanitizedConfig } from "payload";
import { getPayload } from "payload";
import { createSeededRng, periodSeed } from "@/lib/economy/math";
import { processEconomyTick } from "@/lib/economy/tick";
import { emitEconomyEvents } from "@/lib/economy/events";
import { getEconomySettings } from "@/lib/economy/config";
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
export const script = async (config: SanitizedConfig) => {
const payload = await getPayload({ config });
const log = createBinLogger("economy-tick", payload.logger);
try {
log.info("[Economy Tick] Starting economy tick...");
const now = new Date();
const { enabled, knobs } = await getEconomySettings(payload);
const result = await processEconomyTick(payload, {
now,
rng: createSeededRng(periodSeed(now, knobs.tickIntervalHintMinutes)),
masterGate: enabled,
knobs,
});
if (result.disabled) {
log.info("[Economy Tick] Master gate is off — nothing processed.");
} else {
await emitEconomyEvents(payload, result);
log.info(
`[Economy Tick] Complete. Processed ${result.processed}, skipped ${result.skipped}, ` +
`${result.priceChanges.length} price change(s), ${result.errors} error(s).`,
);
// Notify connected clients so economy-driven views refresh.
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) {
await fetch(`${notifyUrl}/api/game-tick/notify`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-game-tick-secret": notifySecret,
},
body: JSON.stringify({ processedAt: now.toISOString(), source: "economy-tick" }),
}).catch((error: unknown) => {
log.error(`[Economy Tick] Failed to notify app clients: ${errorMessage(error)}`);
});
}
}
process.exit(0);
} catch (error) {
log.error(
`[Economy Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
);
process.exit(1);
}
};

View file

@ -35,7 +35,8 @@ interface GameEventInput {
| "wiki-revisions" | "wiki-revisions"
| "wiki-templates" | "wiki-templates"
| "game-servers" | "game-servers"
| "assignment-transfers"; | "assignment-transfers"
| "market-state";
targetId?: number; targetId?: number;
data?: Record<string, unknown>; data?: Record<string, unknown>;
} }

View file

@ -90,6 +90,11 @@ export const EventTypes = {
MarketOfferCancel: "market:offer-cancel", MarketOfferCancel: "market:offer-cancel",
MarketOfferClosed: "market:offer-closed", MarketOfferClosed: "market:offer-closed",
// Economy
EconomyTick: "economy:tick",
EconomyPriceChange: "economy:price-change",
EconomyReset: "economy:reset",
// NPCs // NPCs
NpcGenerate: "npc:generate", NpcGenerate: "npc:generate",

View file

@ -0,0 +1,292 @@
/**
* Integration tests for economy-tick determinism (seeded rng, period seeding),
* price-change emission threshold, and the economy event log wiring.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Payload } from "payload";
import { getPayload } from "payload";
import config from "@/payload.config";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { emitEconomyEvents } from "@/lib/economy/events";
import {
createSeededRng,
driftArtificialDemand,
periodSeed,
PRICE_CHANGE_THRESHOLD,
shouldEmitPriceChange,
} from "@/lib/economy/math";
import { ECONOMY_DEFAULTS } from "@/lib/economy/types";
import { ensureMarketStateDocs, processEconomyTick } from "@/lib/economy/tick";
describe("economy seeded rng + period seeding (pure)", () => {
it("createSeededRng produces identical sequences for the same seed", () => {
const a = createSeededRng(42);
const b = createSeededRng(42);
expect([a(), a(), a()]).toEqual([b(), b(), b()]);
});
it("createSeededRng produces different sequences for different seeds", () => {
const a = createSeededRng(1);
const b = createSeededRng(2);
expect([a(), a(), a()]).not.toEqual([b(), b(), b()]);
});
it("periodSeed is stable within a period and changes across periods", () => {
const now = new Date("2026-09-06T12:34:56.000Z");
const withinPeriod = new Date("2026-09-06T12:35:59.000Z");
const nextPeriod = new Date("2026-09-06T13:35:59.000Z");
// 60-minute periods: 12:34 and 12:35 fall in the same period window.
expect(periodSeed(now, 60)).toBe(periodSeed(withinPeriod, 60));
expect(periodSeed(now, 60)).not.toBe(periodSeed(nextPeriod, 60));
});
it("seeded rng drives driftArtificialDemand deterministically", () => {
const seed = periodSeed(new Date("2026-09-06T00:00:00Z"), 60);
const first = driftArtificialDemand(50, 50, 0.05, createSeededRng(seed));
const again = driftArtificialDemand(50, 50, 0.05, createSeededRng(seed));
expect(first).toBe(again);
});
});
describe("price-change emission threshold (pure)", () => {
it("PRICE_CHANGE_THRESHOLD is 0.05", () => {
expect(PRICE_CHANGE_THRESHOLD).toBe(0.05);
});
it("shouldEmitPriceChange emits for changes >= threshold", () => {
expect(shouldEmitPriceChange(1, 1.06)).toBe(true);
expect(shouldEmitPriceChange(1, 0.9)).toBe(true);
});
it("shouldEmitPriceChange is quiet below the threshold", () => {
expect(shouldEmitPriceChange(1, 1.001)).toBe(false);
expect(shouldEmitPriceChange(1, 1)).toBe(false);
});
});
describe("economy events (DB)", () => {
let payload: Payload;
const run = `econ-ev-${Date.now().toString(36)}`;
let resourceId: number | null = null;
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
});
afterAll(async () => {
// Remove fixtures in reverse dependency order; tolerate missing docs.
if (resourceId !== null) {
const states = await payload
.find({
collection: "market-state",
where: {
and: [
{ itemType: { equals: "resource" } },
{ itemId: { equals: resourceId } },
],
},
depth: 0,
limit: 10,
overrideAccess: true,
})
.catch(() => null);
for (const doc of states?.docs ?? []) {
await payload
.delete({ collection: "market-state", id: doc.id })
.catch(() => {});
}
await payload
.delete({ collection: "resources", id: resourceId })
.catch(() => {});
}
const events = await payload
.find({
collection: "game-event-logs",
where: { type: { in: ["economy:tick", "economy:price-change"] } },
depth: 0,
limit: 100,
overrideAccess: true,
})
.catch(() => null);
for (const doc of events?.docs ?? []) {
await payload
.delete({ collection: "game-event-logs", id: doc.id })
.catch(() => {});
}
});
async function createFixtureResource(): Promise<number> {
const doc = await payload.create({
collection: "resources",
data: {
name: `${run} Res`,
codeName: `${run}-res`,
baseValue: 100,
rarity: "common",
unitOfMeasure: "kg",
massPerUnit: 1,
gridWidth: 1,
gridHeight: 1,
tradeable: true,
approvalStatus: "in_progress",
type: "physical",
economy: { enabled: true, maxModifier: 0.5 },
},
});
resourceId = doc.id;
return doc.id;
}
async function findStateDoc(resourceIdValue: number) {
const states = await payload.find({
collection: "market-state",
where: {
and: [
{ itemType: { equals: "resource" } },
{ itemId: { equals: resourceIdValue } },
],
},
depth: 0,
limit: 1,
overrideAccess: true,
});
const doc = states.docs[0];
expect(doc).toBeDefined();
return doc as NonNullable<typeof doc>;
}
/** Give each DB test a clean event slate so counts are exact and independent. */
async function deleteEconomyEvents(): Promise<void> {
const events = await payload.find({
collection: "game-event-logs",
where: { type: { in: ["economy:tick", "economy:price-change"] } },
depth: 0,
limit: 100,
overrideAccess: true,
});
for (const doc of events.docs) {
await payload
.delete({ collection: "game-event-logs", id: doc.id })
.catch(() => {});
}
}
it("processEconomyTick skips already-computed docs within the same period", async () => {
const id = await createFixtureResource();
await ensureMarketStateDocs(payload, {
items: [{ itemType: "resource", itemId: id }],
});
const periodStart = new Date("2026-09-06T12:00:00.000Z");
const first = await processEconomyTick(payload, {
now: periodStart,
rng: createSeededRng(7),
items: [{ itemType: "resource", itemId: id }],
masterGate: true,
periodStart,
});
expect(first.processed).toBeGreaterThan(0);
expect(first.skipped).toBe(0);
const second = await processEconomyTick(payload, {
now: periodStart,
rng: createSeededRng(7),
items: [{ itemType: "resource", itemId: id }],
masterGate: true,
periodStart,
});
expect(second.processed).toBe(0);
expect(second.skipped).toBeGreaterThan(0);
});
it("emits economy:price-change event when the modifier moves beyond the threshold", async () => {
const id = resourceId;
if (id === null) throw new Error("fixture resource missing");
// Force a large ratio so the modifier jumps by >= PRICE_CHANGE_THRESHOLD.
const stateDoc = await findStateDoc(id);
await payload.update({
collection: "market-state",
id: stateDoc.id,
data: { realDemand: 10, currentSupply: 10 },
overrideAccess: true,
});
const result = await processEconomyTick(payload, {
now: new Date("2026-09-06T13:00:00.000Z"),
rng: createSeededRng(11),
items: [{ itemType: "resource", itemId: id }],
masterGate: true,
});
const change = result.priceChanges.find((pc) => pc.itemId === id);
expect(change).toBeDefined();
if (!change) throw new Error("expected a price change");
expect(change.stateId).toBe(stateDoc.id);
expect(shouldEmitPriceChange(change.from, change.to)).toBe(true);
await deleteEconomyEvents();
await emitEconomyEvents(payload, result);
const logged = await payload.find({
collection: "game-event-logs",
where: { type: { equals: EventTypes.EconomyPriceChange } },
depth: 0,
limit: 10,
overrideAccess: true,
});
const mine = logged.docs.find(
(d) => d.targetId === stateDoc.id && d.targetCollection === "market-state",
);
expect(mine).toBeDefined();
expect(mine?.system).toBe(true);
expect(mine?.data).toMatchObject({ itemType: "resource", itemId: id });
});
it("emits only the batch economy:tick summary when prices stay quiet", async () => {
const id = resourceId;
if (id === null) throw new Error("fixture resource missing");
// Reset to balanced numbers, neutral modifier, and zero real demand so
// the cold-start gate keeps the modifier at exactly 1.
const stateDoc = await findStateDoc(id);
await payload.update({
collection: "market-state",
id: stateDoc.id,
data: {
gmBaselineDemand: 100,
artificialDemand: 100,
currentSupply: 100,
realDemand: 0,
priceModifier: 1,
},
overrideAccess: true,
});
const result = await processEconomyTick(payload, {
now: new Date("2026-09-06T14:00:00.000Z"),
rng: createSeededRng(23),
items: [{ itemType: "resource", itemId: id }],
masterGate: true,
knobs: { ...ECONOMY_DEFAULTS, artificialDemandDrift: 0.001 },
});
expect(result.priceChanges.length).toBe(0);
await deleteEconomyEvents();
await emitEconomyEvents(payload, result);
const priceChanges = await payload.find({
collection: "game-event-logs",
where: { type: { equals: EventTypes.EconomyPriceChange } },
depth: 0,
limit: 10,
overrideAccess: true,
});
expect(priceChanges.docs.length).toBe(0);
const summaries = await payload.find({
collection: "game-event-logs",
where: { type: { equals: EventTypes.EconomyTick } },
depth: 0,
limit: 10,
overrideAccess: true,
});
expect(summaries.docs.length).toBe(1);
expect(summaries.docs[0]?.system).toBe(true);
});
});

View file

@ -0,0 +1,267 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getPayload, type CollectionSlug, type Payload } from "payload";
import config from "@/payload.config";
import {
applyEconomyTick,
computePriceModifier,
decayRealDemand,
driftArtificialDemand,
priceModifierFromRatio,
round4,
} from "@/lib/economy/math";
import type { EconomyKnobs, MarketStateSnapshot } from "@/lib/economy/types";
let payload: Payload;
const RUN = `econ-${Date.now().toString(36)}`;
const KNOBS: EconomyKnobs = {
defaultPriceBand: 0.5,
realDemandWindowTicks: 24,
realDemandDecay: 0.5,
artificialDemandDrift: 0.05,
coldStartObservations: 5,
tickIntervalHintMinutes: 60,
};
let resourceId: number;
let assetId: number;
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
});
afterAll(async () => {
const cleanup = async (collection: CollectionSlug, id: number) => {
await payload.delete({ collection, id }).catch(() => {});
};
const cleanupState = async () => {
for (const [itemType, itemId] of [
["resource", resourceId],
["asset", assetId],
] as const) {
if (itemId === undefined) continue;
const { docs } = await payload
.find({
collection: "market-state",
where: { and: [{ itemType: { equals: itemType } }, { itemId: { equals: itemId } }] },
limit: 10,
depth: 0,
})
.catch(() => ({ docs: [] as { id: number }[] }));
for (const doc of docs) {
await payload.delete({ collection: "market-state", id: doc.id }).catch(() => {});
}
}
};
await cleanupState();
if (resourceId !== undefined) await cleanup("resources", resourceId);
if (assetId !== undefined) await cleanup("assets", assetId);
});
describe("economy math (pure)", () => {
it("rounds to 4 decimal places", () => {
expect(round4(0.123456789)).toBe(0.1235);
expect(round4(1)).toBe(1);
});
it("decays real demand by the decay factor", () => {
expect(decayRealDemand(100, 0.5)).toBe(50);
expect(decayRealDemand(100, 0)).toBe(100);
expect(decayRealDemand(0, 0.5)).toBe(0);
expect(decayRealDemand(10, 1 / 3)).toBe(round4(10 * (2 / 3)));
});
it("drifts artificial demand deterministically from the rng", () => {
// rng() = 1 → full upward step of drift * baseline
expect(driftArtificialDemand(50, 100, 0.05, () => 1)).toBe(55);
// rng() = 0 → full downward step
expect(driftArtificialDemand(50, 100, 0.05, () => 0)).toBe(45);
// rng() = 0.5 → zero step
expect(driftArtificialDemand(50, 100, 0.05, () => 0.5)).toBe(50);
});
it("clamps artificial demand to [0, 2 × baseline]", () => {
expect(driftArtificialDemand(195, 100, 0.05, () => 1)).toBe(200);
expect(driftArtificialDemand(3, 100, 0.05, () => 0)).toBe(0);
});
it("derives the price modifier from the demand/supply ratio, clamped to the band", () => {
expect(priceModifierFromRatio(100, 100, 0.5)).toBe(1);
// shortage: demand 200 / supply 100 → 2 clamped to 1.5
expect(priceModifierFromRatio(100, 200, 0.5)).toBe(1.5);
// surplus: demand 50 / supply 100 → 0.5
expect(priceModifierFromRatio(100, 50, 0.5)).toBe(0.5);
// zero supply → maximum price
expect(priceModifierFromRatio(0, 100, 0.5)).toBe(1.5);
// band larger than 1 is clamped to [0, 1]
expect(priceModifierFromRatio(100, 300, 5)).toBe(2);
});
it("holds the modifier neutral during cold start", () => {
expect(computePriceModifier(100, 60, 4, 0.5, KNOBS)).toBe(1);
expect(computePriceModifier(100, 60, 5, 0.5, KNOBS)).toBe(0.6);
});
it("applies a full economy tick to a snapshot", () => {
const state: MarketStateSnapshot = {
currentSupply: 100,
artificialDemand: 50,
realDemand: 10,
priceModifier: 1.5,
};
const next = applyEconomyTick(state, { baselineDemand: 100, priceBand: 0.5 }, KNOBS, () => 0.5);
// realDemand decays 10 → 5; artificialDemand unchanged (rng 0.5 = zero step)
expect(next.realDemand).toBe(5);
expect(next.artificialDemand).toBe(50);
// total demand 50 + 5 = 55, ratio 55/100 = 0.55 within band; supply untouched
expect(next.currentSupply).toBe(100);
expect(next.priceModifier).toBe(0.55);
});
});
describe("economy engine (database)", () => {
it("ensures market-state docs for enabled tradeable items, copying GM baselines", async () => {
const resource = await payload.create({
collection: "resources",
data: {
name: `${RUN} Resource`,
codeName: `${RUN}-res`,
approvalStatus: "in_progress",
type: "physical",
baseValue: 10,
rarity: "common",
unitOfMeasure: "units",
massPerUnit: 1,
gridWidth: 1,
gridHeight: 1,
tradeable: true,
economy: { enabled: true, baselineSupply: 120, baselineDemand: 60 },
},
});
resourceId = resource.id;
const asset = await payload.create({
collection: "assets",
data: {
name: `${RUN} Asset`,
className: "test",
assetType: "miscellaneous",
approvalStatus: "in_progress",
tradeable: true,
isLive: true,
marketTrading: {
Rarity: "common",
perceivedValue: { baseBuyPrice: 100, baseSellPrice: 80 },
},
economy: { enabled: true, baselineSupply: 80, baselineDemand: 40 },
crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
storageDimensions: { gridWidth: 1, gridHeight: 1 },
},
});
assetId = asset.id;
const { ensureMarketStateDocs } = await import("@/lib/economy/tick");
// Scoped to this spec's fixtures: an unfiltered ensure would create/see
// docs belonging to other test files sharing this database.
const summary = await ensureMarketStateDocs(payload, {
items: [{ itemType: "resource", itemId: resourceId }, { itemType: "asset", itemId: assetId }],
});
expect(summary.created).toBe(2);
const { docs } = await payload.find({
collection: "market-state",
where: { and: [{ itemType: { equals: "resource" } }, { itemId: { equals: resourceId } }] },
limit: 1,
depth: 0,
});
expect(docs).toHaveLength(1);
expect(docs[0].gmBaselineSupply).toBe(120);
expect(docs[0].gmBaselineDemand).toBe(60);
expect(docs[0].currentSupply).toBe(120);
expect(docs[0].artificialDemand).toBe(60);
expect(docs[0].realDemand).toBe(0);
expect(docs[0].priceModifier).toBe(1);
});
it("is idempotent — a second ensure pass creates nothing new", async () => {
const { ensureMarketStateDocs } = await import("@/lib/economy/tick");
const summary = await ensureMarketStateDocs(payload, {
items: [{ itemType: "resource", itemId: resourceId }, { itemType: "asset", itemId: assetId }],
});
expect(summary.created).toBe(0);
});
it("processes a tick: decays real demand, recomputes the modifier, stamps lastComputedAt", async () => {
const { processEconomyTick } = await import("@/lib/economy/tick");
const { docs } = await payload.find({
collection: "market-state",
where: { and: [{ itemType: { equals: "resource" } }, { itemId: { equals: resourceId } }] },
limit: 1,
depth: 0,
});
await payload.update({
collection: "market-state",
id: docs[0].id,
data: { realDemand: 10 },
overrideAccess: true,
});
const now = new Date("2026-09-06T00:00:00Z");
const result = await processEconomyTick(payload, {
now,
knobs: KNOBS,
masterGate: true,
rng: () => 0.5,
items: [{ itemType: "resource", itemId: resourceId }, { itemType: "asset", itemId: assetId }],
});
expect(result.disabled).toBe(false);
expect(result.processed).toBeGreaterThanOrEqual(2);
const resourceChange = result.priceChanges.find(
(c) => c.itemType === "resource" && c.itemId === resourceId,
);
// demand 60 + 5 = 65, supply 120 → 65/120 = 0.5417 within band
expect(resourceChange).toBeDefined();
expect(resourceChange?.from).toBe(1);
expect(resourceChange?.to).toBe(0.5417);
const after = await payload.findByID({ collection: "market-state", id: docs[0].id, depth: 0 });
expect(after.realDemand).toBe(5);
expect(after.priceModifier).toBe(0.5417);
expect(new Date(after.lastComputedAt as unknown as string).toISOString()).toBe(
now.toISOString(),
);
});
it("does not move the price during cold start", async () => {
const { processEconomyTick } = await import("@/lib/economy/tick");
const { docs } = await payload.find({
collection: "market-state",
where: { and: [{ itemType: { equals: "asset" } }, { itemId: { equals: assetId } }] },
limit: 1,
depth: 0,
});
const before = await payload.findByID({ collection: "market-state", id: docs[0].id, depth: 0 });
expect(before.realDemand).toBe(0);
await processEconomyTick(payload, {
now: new Date("2026-09-06T00:01:00Z"),
knobs: KNOBS,
masterGate: true,
rng: () => 0.5,
items: [{ itemType: "asset", itemId: assetId }],
});
const after = await payload.findByID({ collection: "market-state", id: docs[0].id, depth: 0 });
expect(after.priceModifier).toBe(1);
});
it("respects the master gate — disabled economy is a no-op", async () => {
const { processEconomyTick } = await import("@/lib/economy/tick");
const result = await processEconomyTick(payload, { now: new Date(), masterGate: false });
expect(result.disabled).toBe(true);
expect(result.processed).toBe(0);
});
});