1
0
Fork 0
polaris-task-force/tests/int/economy-consumption.int.spec.ts
Z8MB1E d4c8d81e2f feat(economy): ingest purchase demand and price vendor listings from the ledger
Market-tick prices new NPC listings from the economy ledger (base x priceModifier, vendor modifier applied) instead of the old autoPrice +/-15% randomness, and scales restock quantity down under shortage. buyListing records real demand from completed purchases of NPC/vendor stock.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 17:22:32 -04:00

264 lines
8.7 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 { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Asset, BankAccount, MarketListing, User } from "@/payload-types";
import { buyListing } from "@/lib/market";
import { resolveVendorNpc } from "@/lib/market/npcs";
import { ensureLockerStorage } from "@/lib/locker";
import { applyTransaction, createAccount, getMainCurrencyId } from "@/lib/banking";
import { scaleQuantityForShortage } from "@/lib/economy/math";
import {
getMarketPriceContext,
recordPurchase,
resetMarketStateDocs,
} from "@/lib/economy/consumption";
import { ensureMarketStateDocs } from "@/lib/economy/tick";
/**
* Pure tests for the shortage-scaling helper. The price composition itself
* (round(base × modifier) then applyNpcPriceModifier) lives in the bin script,
* which is not unit-testable; the lib-level pieces below cover the testable
* seams: restock scaling, purchase ingestion, and GM reset.
*/
describe("Economy consumption: scaleQuantityForShortage", () => {
it("keeps the quantity unchanged when supply meets demand", () => {
expect(scaleQuantityForShortage(5, 100, 50)).toBe(5);
});
it("keeps the quantity unchanged when demand is zero", () => {
expect(scaleQuantityForShortage(4, 0, 0)).toBe(4);
});
it("scales the quantity down proportionally under shortage", () => {
// supply/demand = 50/100 = 0.5 → 5 × 0.5 = 2.5 → floor 2
expect(scaleQuantityForShortage(5, 50, 100)).toBe(2);
});
it("never scales below the 0.25 floor factor", () => {
// supply/demand = 10/100 = 0.1 → clamped to 0.25 → 8 × 0.25 = 2
expect(scaleQuantityForShortage(8, 10, 100)).toBe(2);
});
it("never returns zero, even under extreme shortage", () => {
expect(scaleQuantityForShortage(1, 1, 1000)).toBe(1);
});
});
describe("Economy consumption: DB integration", () => {
let payload: Payload;
const RUN = `econ-cons-${Date.now().toString(36)}`;
let assetId: number;
let buyer: User;
let buyerAccount: BankAccount;
const findStateDoc = async (id: number) => {
const docs = await payload.find({
collection: "market-state",
where: { and: [{ itemType: { equals: "asset" } }, { itemId: { equals: id } }] },
limit: 1,
depth: 0,
overrideAccess: true,
});
const doc = docs.docs[0];
if (!doc) throw new Error("market-state doc not found for fixture asset");
return doc;
};
const createFixtureAsset = async (): Promise<number> => {
const created = (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 } },
crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
storageDimensions: { gridWidth: 1, gridHeight: 1 },
economy: { enabled: true, baselineSupply: 100, baselineDemand: 50, maxModifier: 0.5 },
},
overrideAccess: true,
depth: 0,
})) as unknown as Asset;
return created.id;
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
assetId = await createFixtureAsset();
buyer = (await payload.create({
collection: "users",
data: {
username: `${RUN}-buyer`,
discordUsername: `${RUN}-buyer`,
displayName: "BUYER",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
await ensureLockerStorage(payload, buyer.id);
buyerAccount = await createAccount(payload, {
name: `${RUN} buyer wallet`,
accountType: "personal",
ownerUserId: buyer.id,
});
await applyTransaction(payload, {
type: "deposit",
toAccountId: buyerAccount.id,
amount: 10_000,
memo: "economy consumption test funding",
});
});
afterAll(async () => {
const cleanup = async (fn: () => Promise<unknown>) => {
await fn().catch(() => {});
};
const listings = await payload
.find({
collection: "market-listings",
where: { asset: { equals: assetId } },
limit: 100,
depth: 0,
overrideAccess: true,
})
.catch(() => ({ docs: [] as MarketListing[] }));
for (const listing of listings.docs) {
await cleanup(() =>
payload.delete({ collection: "market-listings", id: listing.id, overrideAccess: true }),
);
}
await cleanup(async () => {
const doc = await findStateDoc(assetId);
await payload.delete({ collection: "market-state", id: doc.id, overrideAccess: true });
});
await cleanup(() => payload.delete({ collection: "assets", id: assetId, overrideAccess: true }));
await cleanup(() =>
payload.delete({ collection: "users", id: buyer.id, overrideAccess: true }),
);
await cleanup(() =>
payload.delete({ collection: "bank-accounts", id: buyerAccount.id, overrideAccess: true }),
);
});
it("seeds a market-state doc from the asset's GM baselines", async () => {
const result = await ensureMarketStateDocs(payload, {
items: [{ itemType: "asset", itemId: assetId }],
});
expect(result.created).toBe(1);
const doc = await findStateDoc(assetId);
expect(doc.gmBaselineSupply).toBe(100);
expect(doc.gmBaselineDemand).toBe(50);
expect(doc.currentSupply).toBe(100);
expect(doc.artificialDemand).toBe(50);
expect(doc.realDemand).toBe(0);
expect(doc.priceModifier).toBe(1);
});
it("recordPurchase adds quantity-weighted real demand", async () => {
await recordPurchase(payload, { itemId: assetId, quantity: 3 });
const doc = await findStateDoc(assetId);
expect(doc.realDemand).toBe(3);
await recordPurchase(payload, { itemId: assetId, quantity: 2 });
const after = await findStateDoc(assetId);
expect(after.realDemand).toBe(5);
});
it("getMarketPriceContext returns the ledger state for an item", async () => {
const ctx = await getMarketPriceContext(payload, "asset", assetId);
expect(ctx).not.toBeNull();
if (!ctx) throw new Error("expected price context");
expect(ctx.priceModifier).toBe(1);
expect(ctx.currentSupply).toBe(100);
const missing = await getMarketPriceContext(payload, "asset", assetId + 999_999);
expect(missing).toBeNull();
});
it("buyListing of an NPC listing ingests real demand", async () => {
const npc = await resolveVendorNpc(payload, assetId);
const currencyId = await getMainCurrencyId(payload);
const nowIso = new Date().toISOString();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
// The previous test left real demand on the doc; this test needs a
// clean ledger to assert the exact ingested quantity.
await payload.update({
collection: "market-state",
id: (await findStateDoc(assetId)).id,
data: { realDemand: 0 },
overrideAccess: true,
depth: 0,
});
const created = (await payload.create({
collection: "market-listings",
data: {
asset: assetId,
seller: null,
npc: npc.id,
quantity: 5,
price: 100,
desiredPrice: 100,
minPrice: 60,
currency: currencyId ?? null,
status: "active",
isAutoGenerated: true,
listedAt: nowIso,
expiresAt,
},
overrideAccess: true,
depth: 0,
})) as unknown as MarketListing;
const populated = await payload.findByID({
collection: "market-listings",
id: created.id,
depth: 1,
overrideAccess: true,
});
await buyListing(payload, populated, buyer);
const doc = await findStateDoc(assetId);
expect(doc.realDemand).toBe(5);
await payload.delete({ collection: "market-listings", id: created.id, overrideAccess: true });
});
it("resetMarketStateDocs rebuilds the ledger from GM baselines", async () => {
await payload.update({
collection: "market-state",
id: (await findStateDoc(assetId)).id,
data: { realDemand: 10, currentSupply: 3, priceModifier: 1.5 },
overrideAccess: true,
depth: 0,
});
const result = await resetMarketStateDocs(payload, {
items: [{ itemType: "asset", itemId: assetId }],
});
expect(result.reset).toBe(1);
const doc = await findStateDoc(assetId);
expect(doc.currentSupply).toBe(100);
expect(doc.artificialDemand).toBe(50);
expect(doc.realDemand).toBe(0);
expect(doc.priceModifier).toBe(1);
});
});