1
0
Fork 0
polaris-task-force/tests/int/economy-events.int.spec.ts
Z8MB1E 447df220f5 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>
2026-09-06 17:22:30 -04:00

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