/** * 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 { 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; } /** Give each DB test a clean event slate so counts are exact and independent. */ async function deleteEconomyEvents(): Promise { 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); }); });