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