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:
parent
ded5d4b341
commit
447df220f5
17 changed files with 24329 additions and 1 deletions
98
docs/economy/supply-demand-design.md
Normal file
98
docs/economy/supply-demand-design.md
Normal 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
115
src/lib/economy/config.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
120
src/lib/economy/consumption.ts
Normal file
120
src/lib/economy/consumption.ts
Normal 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
43
src/lib/economy/events.ts
Normal 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
164
src/lib/economy/math.ts
Normal 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));
|
||||||
|
}
|
||||||
62
src/lib/economy/persistence.ts
Normal file
62
src/lib/economy/persistence.ts
Normal 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
226
src/lib/economy/tick.ts
Normal 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
53
src/lib/economy/types.ts
Normal 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;
|
||||||
22788
src/migrations/20260906_165837_add_market_state_event_target.json
Normal file
22788
src/migrations/20260906_165837_add_market_state_event_target.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
66
src/scripts/economyTick.ts
Normal file
66
src/scripts/economyTick.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
||||||
|
|
|
||||||
292
tests/int/economy-events.int.spec.ts
Normal file
292
tests/int/economy-events.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
267
tests/int/economy.int.spec.ts
Normal file
267
tests/int/economy.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue