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>
267 lines
9.5 KiB
TypeScript
267 lines
9.5 KiB
TypeScript
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);
|
||
});
|
||
});
|