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>
292 lines
9.5 KiB
TypeScript
292 lines
9.5 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|