1
0
Fork 0
polaris-task-force/tests/int/economy.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

267 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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