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); }); });