import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { applyEconomyEffect, DEFAULT_ECONOMY_CAPS } from "@/lib/operations/economy"; import { ECONOMY_NOTE_PREFIX, type EconomyRecord } from "@/lib/operations/economyConfig"; import { resetMarketStateDocs } from "@/lib/economy/consumption"; import { applyTransaction, ensurePersonalAccount } from "@/lib/banking"; import { ensureLockerStorage } from "@/lib/locker"; import type { Asset, GameStructure, LockerStorage, MarketListing, Structure } from "@/payload-types"; let payload: Payload; const RUN = `econ-${Date.now().toString(36)}`; const LEXICAL_EMPTY = { root: { children: [{ text: "" }], direction: null, format: "" as const, indent: 0, type: "text", version: 1, }, }; interface EconomyNote { applied: { itemType: string; itemId: number; supply: number }[]; rejected: { itemType: string; itemId: number; supply: number }[]; provenance: { itemType: string; itemId: number }[]; error?: string; } function economyRecordFrom(effect: { reconciliationNotes: string | null }): EconomyRecord | null { const notes = effect.reconciliationNotes ?? ""; const line = notes.split("\n").find((l) => l.startsWith(ECONOMY_NOTE_PREFIX)); if (!line) return null; return JSON.parse(line.slice(ECONOMY_NOTE_PREFIX.length)) as EconomyRecord; } // --------------------------------------------------------------------------- // Fixture tracking for cleanup // --------------------------------------------------------------------------- const syncEventIds: number[] = []; const opEventIds: number[] = []; const opEffectIds: number[] = []; const userIds: number[] = []; const accountIds: number[] = []; const gameStructureIds: number[] = []; const blueprintIds: number[] = []; const resourceIds: number[] = []; const assetIds: number[] = []; const marketStateIds: number[] = []; const listingIds: number[] = []; const lockerIds: number[] = []; let serverId: number; let mapId: number; let actorId: number; let sellerId: number; let buyerId: number; let resourceAlphaId: number; let resourceBetaId: number; let tradeableAssetId: number; let fillerAssetId: number; let hqId: number; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function supplyOf(itemType: "resource" | "asset", itemId: number): Promise { const docs = await payload.find({ collection: "market-state", where: { and: [{ itemType: { equals: itemType } }, { itemId: { equals: itemId } }] }, limit: 1, depth: 0, overrideAccess: true, }); if (docs.docs.length === 0) return null; return (docs.docs[0] as { currentSupply: number }).currentSupply; } async function makeMarketState(itemType: "resource" | "asset", itemId: number): Promise { const existing = await payload.find({ collection: "market-state", where: { and: [{ itemType: { equals: itemType } }, { itemId: { equals: itemId } }] }, limit: 1, depth: 0, overrideAccess: true, }); if (existing.docs.length > 0) return existing.docs[0].id; const doc = await payload.create({ collection: "market-state", data: { itemType, itemId, gmBaselineSupply: 100, gmBaselineDemand: 50, currentSupply: 100, artificialDemand: 50, realDemand: 0, priceModifier: 1, }, overrideAccess: true, depth: 0, }); marketStateIds.push(doc.id); return doc.id; } /** * Full hook chain: creating the raw sync event runs the afterChange hook, * which validates it into an operation-event, derives the pending effect, and * applies settlement. Returns the applied effect. */ async function hookExtraction( label: string, payloadManifest: Record, ): Promise<{ effectId: number; eventId: string }> { const sync = await payload.create({ collection: "arma-sync-events", data: { eventId: `${RUN}-${label}`, server: serverId, type: "operation.extraction", occurredAt: new Date().toISOString(), payload: { operationId: RUN, version: 1, ...payloadManifest }, }, overrideAccess: true, depth: 0, }); syncEventIds.push(sync.id); const events = await payload.find({ collection: "operation-events", where: { sourceEvent: { equals: sync.id } }, limit: 1, depth: 0, overrideAccess: true, }); expect(events.docs).toHaveLength(1); opEventIds.push(events.docs[0].id); const effects = await payload.find({ collection: "operation-effects", where: { operationEvent: { equals: events.docs[0].id } }, limit: 1, depth: 0, overrideAccess: true, }); expect(effects.docs).toHaveLength(1); const effect = effects.docs[0] as { id: number; status: string }; opEffectIds.push(effect.id); expect(effect.status).toBe("applied"); return { effectId: effect.id, eventId: sync.eventId as string }; } /** Create an applied extraction effect for adapter-level tests. The afterChange * hook already consumes every raw sync event (unique sourceEvent), so instead * of creating a second operation-event, the hook-derived effect row is * repurposed with an extraction type and manifest. */ async function manualExtractionEffect(label: string, resourceId: number, amount: number): Promise { const sync = await payload.create({ collection: "arma-sync-events", data: { eventId: `${RUN}-${label}`, server: serverId, type: "operation.start", occurredAt: new Date().toISOString(), payload: { operationId: RUN, version: 1 }, }, overrideAccess: true, depth: 0, }); syncEventIds.push(sync.id); const events = await payload.find({ collection: "operation-events", where: { sourceEvent: { equals: sync.id } }, limit: 1, depth: 0, overrideAccess: true, }); expect(events.docs).toHaveLength(1); opEventIds.push(events.docs[0].id); const effects = await payload.find({ collection: "operation-effects", where: { operationEvent: { equals: events.docs[0].id } }, limit: 1, depth: 0, overrideAccess: true, }); expect(effects.docs).toHaveLength(1); const effect = effects.docs[0] as { id: number }; opEffectIds.push(effect.id); const updated = await payload.update({ collection: "operation-effects", id: effect.id, data: { effectType: "operation.extraction-recorded", payload: { operationId: RUN, kind: "logistics", hqStructureId: hqId, actorId, items: [{ resourceId, amount }], }, status: "applied", appliedAt: new Date().toISOString(), }, overrideAccess: true, depth: 0, }); return updated.id; } // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); const server = await payload.create({ collection: "game-servers", data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" }, overrideAccess: true, depth: 0, }); serverId = server.id; for (const label of ["actor", "seller", "buyer"] as const) { const user = await payload.create({ collection: "users", data: { username: `${RUN}-${label}`, discordUsername: `${RUN}-${label}-discord`, displayName: `${RUN} ${label}`, steamId: `${RUN}-steam-${label}`, password: "Test1234", }, overrideAccess: true, depth: 0, }); userIds.push(user.id); if (label === "actor") actorId = user.id; if (label === "seller") sellerId = user.id; if (label === "buyer") buyerId = user.id; } const map = await payload.create({ collection: "maps", data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, overrideAccess: true, depth: 0, }); mapId = map.id; for (const [label, setter] of [ ["alpha", (id: number) => (resourceAlphaId = id)], ["beta", (id: number) => (resourceBetaId = id)], ] as const) { const resource = await payload.create({ collection: "resources", data: { name: `${RUN} ${label}`, codeName: `${RUN}-${label}`, approvalStatus: "in_progress", type: "physical", baseValue: 1, rarity: "common", unitOfMeasure: "kg", massPerUnit: 1, gridWidth: 1, gridHeight: 1, }, overrideAccess: true, depth: 0, }); resourceIds.push(resource.id); setter(resource.id); } const tradeableAsset = await payload.create({ collection: "assets", data: { name: `${RUN} Tradeable Rifle`, className: `${RUN}_tradeable_rifle`, assetType: "weapon", approvalStatus: "in_progress", tradeable: true, isLive: true, crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } }, storageDimensions: { gridWidth: 1, gridHeight: 1, stackable: false }, }, overrideAccess: true, depth: 0, }); assetIds.push(tradeableAsset.id); tradeableAssetId = tradeableAsset.id; const fillerAsset = await payload.create({ collection: "assets", data: { name: `${RUN} Filler Rifle`, className: `${RUN}_filler_rifle`, assetType: "weapon", approvalStatus: "in_progress", crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } }, storageDimensions: { gridWidth: 1, gridHeight: 1, stackable: false }, }, overrideAccess: true, depth: 0, }); assetIds.push(fillerAsset.id); fillerAssetId = fillerAsset.id; const blueprint = await payload.create({ collection: "structures", data: { name: `${RUN} HQ Depot`, codeName: `${RUN}-hq-depot`, approvalStatus: "in_progress", description: LEXICAL_EMPTY as unknown as Structure["description"], category: "logistics", materials: [{ resource: resourceAlphaId, amount: 1 }], constructionDurationMinutes: 1, terrainType: "land", maxHealth: 100, }, overrideAccess: true, depth: 0, }); blueprintIds.push(blueprint.id); const hq = await payload.create({ collection: "game-structures", data: { name: `${RUN} HQ`, type: blueprint.id, map: mapId, coordinates: [100, 100], constructionStatus: "complete", }, overrideAccess: true, depth: 0, }); gameStructureIds.push(hq.id); hqId = hq.id; }); afterAll(async () => { if (!payload) return; for (const id of listingIds) { await payload.delete({ collection: "market-listings", id, overrideAccess: true }).catch(() => {}); } for (const id of marketStateIds) { await payload.delete({ collection: "market-state", id, overrideAccess: true }).catch(() => {}); } for (const id of opEffectIds) { await payload.delete({ collection: "operation-effects", id, overrideAccess: true }).catch(() => {}); } for (const id of opEventIds) { await payload.delete({ collection: "operation-events", id, overrideAccess: true }).catch(() => {}); } for (const id of syncEventIds) { await payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }).catch(() => {}); } for (const id of lockerIds) { await payload.delete({ collection: "locker-storages", id, overrideAccess: true }).catch(() => {}); } const deleteAccountChain = async (accountId: number) => { const txns = await payload.find({ collection: "bank-transactions", where: { or: [{ fromAccount: { equals: accountId } }, { toAccount: { equals: accountId } }] }, limit: 100, depth: 0, overrideAccess: true, }); for (const txn of txns.docs) { await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "bank-accounts", id: accountId, overrideAccess: true }).catch(() => {}); }; for (const id of accountIds) await deleteAccountChain(id); for (const id of gameStructureIds) { await payload.delete({ collection: "game-structures", id, overrideAccess: true }).catch(() => {}); } for (const id of blueprintIds) { await payload.delete({ collection: "structures", id, overrideAccess: true }).catch(() => {}); } for (const id of assetIds) { await payload.delete({ collection: "assets", id, overrideAccess: true }).catch(() => {}); } for (const id of resourceIds) { await payload.delete({ collection: "resources", id, overrideAccess: true }).catch(() => {}); } for (const id of userIds) { await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {}); }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("Operation economy adapter", () => { it("feeds a logistics extraction into the resource market supply exactly once", async () => { await makeMarketState("resource", resourceAlphaId); const before = (await supplyOf("resource", resourceAlphaId)) as number; const { effectId } = await hookExtraction("logi", { kind: "logistics", hqStructureId: hqId, actorId, items: [{ resourceId: resourceAlphaId, amount: 5 }], }); const result = await applyEconomyEffect(payload, effectId); const record = economyRecordFrom(result as { reconciliationNotes: string | null }); expect(record).not.toBeNull(); expect(record?.applied).toEqual([ { itemType: "resource", itemId: resourceAlphaId, supply: 5 }, ]); expect(record?.rejected).toEqual([]); expect(await supplyOf("resource", resourceAlphaId)).toBe(before + 5); }); it("is a no-op when the same effect is applied twice", async () => { const effectId = await manualExtractionEffect("dup", resourceAlphaId, 5); const before = (await supplyOf("resource", resourceAlphaId)) as number; await applyEconomyEffect(payload, effectId); const afterFirst = (await supplyOf("resource", resourceAlphaId)) as number; const second = await applyEconomyEffect(payload, effectId); expect(await supplyOf("resource", resourceAlphaId)).toBe(afterFirst); expect(await supplyOf("resource", resourceAlphaId)).toBe(before + 5); // The marker is present exactly once: the retry did not append a second record. expect((second as { reconciliationNotes: string }).reconciliationNotes.split(ECONOMY_NOTE_PREFIX).length - 1).toBe(1); }); it("records player extractions as provenance without touching the market", async () => { await makeMarketState("asset", tradeableAssetId); const before = (await supplyOf("asset", tradeableAssetId)) as number; const { effectId } = await hookExtraction("player", { kind: "player", actorId: buyerId, items: [{ assetId: tradeableAssetId, quantity: 2 }], }); const result = await applyEconomyEffect(payload, effectId); const record = economyRecordFrom(result as { reconciliationNotes: string | null }); expect(record?.applied).toEqual([]); expect(record?.provenance).toEqual([{ itemType: "asset", itemId: tradeableAssetId }]); expect(await supplyOf("asset", tradeableAssetId)).toBe(before); }); it("drains the market supply for inventory losses", async () => { await makeMarketState("asset", tradeableAssetId); const before = (await supplyOf("asset", tradeableAssetId)) as number; const sync = await payload.create({ collection: "arma-sync-events", data: { eventId: `${RUN}-loss`, server: serverId, type: "operation.inventory", occurredAt: new Date().toISOString(), payload: { operationId: RUN, version: 1, reason: "loss", items: [{ assetId: tradeableAssetId, quantity: 3 }] }, }, overrideAccess: true, depth: 0, }); syncEventIds.push(sync.id); const events = await payload.find({ collection: "operation-events", where: { sourceEvent: { equals: sync.id } }, limit: 1, depth: 0, overrideAccess: true, }); opEventIds.push(events.docs[0].id); const effects = await payload.find({ collection: "operation-effects", where: { operationEvent: { equals: events.docs[0].id } }, limit: 1, depth: 0, overrideAccess: true, }); opEffectIds.push(effects.docs[0].id); const effectId = effects.docs[0].id; const result = await applyEconomyEffect(payload, effectId); const record = economyRecordFrom(result as { reconciliationNotes: string | null }); expect(record?.applied).toEqual([ { itemType: "asset", itemId: tradeableAssetId, supply: -3 }, ]); expect(await supplyOf("asset", tradeableAssetId)).toBe(before - 3); }); it("caps excess supply and records the rejected remainder", async () => { const effectId = await manualExtractionEffect("cap", resourceAlphaId, 5000); const before = (await supplyOf("resource", resourceAlphaId)) as number; const result = await applyEconomyEffect(payload, effectId, { faucetCapPerItem: 100 }); const record = economyRecordFrom(result as { reconciliationNotes: string | null }); expect(record?.applied).toEqual([ { itemType: "resource", itemId: resourceAlphaId, supply: 100 }, ]); expect(record?.rejected).toEqual([ { itemType: "resource", itemId: resourceAlphaId, supply: 4900 }, ]); expect(await supplyOf("resource", resourceAlphaId)).toBe(before + 100); }); it("keeps items without a market-state ledger provenance-eligible only", async () => { const effectId = await manualExtractionEffect("nol", resourceBetaId, 7); const result = await applyEconomyEffect(payload, effectId); const record = economyRecordFrom(result as { reconciliationNotes: string | null }); expect(record?.applied).toEqual([]); expect(record?.provenance).toEqual([{ itemType: "resource", itemId: resourceBetaId }]); const docs = await payload.find({ collection: "market-state", where: { itemId: { equals: resourceBetaId } }, limit: 1, depth: 0, overrideAccess: true, }); expect(docs.docs).toHaveLength(0); }); it("rejects a purchase into a full locker before any payment moves", async () => { const listing = await payload.create({ collection: "market-listings", data: { asset: tradeableAssetId, seller: sellerId, quantity: 2, price: 100, status: "active", listedAt: new Date().toISOString(), }, overrideAccess: true, depth: 0, }); listingIds.push(listing.id); const buyerAccount = await ensurePersonalAccount(payload, buyerId); accountIds.push(buyerAccount.id); await applyTransaction(payload, { type: "deposit", toAccountId: buyerAccount.id, amount: 10000, memo: `${RUN} buyer funding`, }); const buyerAccountAfterFunding = (await payload.findByID({ collection: "bank-accounts", id: buyerAccount.id, depth: 0, overrideAccess: true, })) as { balance: number }; expect(buyerAccountAfterFunding.balance).toBe(10000); const buyerLocker = await ensureLockerStorage(payload, buyerId); lockerIds.push(buyerLocker.id); const grid = await payload.findByID({ collection: "locker-storages", id: buyerLocker.id, depth: 0, overrideAccess: true, }); const entries = Array.from({ length: 60 }, (_, i) => ({ asset: fillerAssetId, quantity: 1, gridX: i % 10, gridY: Math.floor(i / 10), rotated: false, })); await payload.update({ collection: "locker-storages", id: buyerLocker.id, data: { items: entries }, overrideAccess: true, depth: 0, }); expect((grid as unknown as LockerStorage).items).toBeDefined(); const buyerUser = (await payload.findByID({ collection: "users", id: buyerId, depth: 0, overrideAccess: true, })) as { username: string }; await expect( (async () => { const { buyListing } = await import("@/lib/market"); const listingDoc = (await payload.findByID({ collection: "market-listings", id: listing.id, depth: 1, overrideAccess: true, })) as unknown as MarketListing; const buyerDoc = (await payload.findByID({ collection: "users", id: buyerId, depth: 0, overrideAccess: true, })) as never; await buyListing(payload, listingDoc, buyerDoc, { quantity: 1 }); })(), ).rejects.toThrow(); const after = (await payload.findByID({ collection: "bank-accounts", id: buyerAccount.id, depth: 0, overrideAccess: true, })) as { balance: number }; expect(after.balance).toBe(10000); const stillActive = (await payload.findByID({ collection: "market-listings", id: listing.id, depth: 0, overrideAccess: true, })) as { status: string }; expect(stillActive.status).toBe("active"); expect(buyerUser.username).toContain(RUN); expect((await payload.findByID({ collection: "game-structures", id: hqId, depth: 0, overrideAccess: true })) as GameStructure).toBeDefined(); }); it("rebuilds market baselines on reset while operation provenance survives", async () => { const before = (await supplyOf("resource", resourceAlphaId)) as number; const result = await resetMarketStateDocs(payload, { items: [{ itemType: "resource", itemId: resourceAlphaId }], }); expect(result.reset).toBe(1); const after = (await supplyOf("resource", resourceAlphaId)) as number; expect(after).toBe(100); expect(before).toBeGreaterThanOrEqual(100); const effects = await payload.find({ collection: "operation-effects", where: { operationId: { equals: RUN } }, limit: 10, depth: 0, overrideAccess: true, }); expect(effects.docs.length).toBeGreaterThan(0); const withNote = effects.docs.filter((d) => ((d as { reconciliationNotes: string | null }).reconciliationNotes ?? "").includes(ECONOMY_NOTE_PREFIX), ); expect(withNote.length).toBeGreaterThan(0); }); it("exposes the default caps constant for surfaces", () => { expect(DEFAULT_ECONOMY_CAPS.faucetCapPerItem).toBeGreaterThan(0); expect(DEFAULT_ECONOMY_CAPS.drainCapPerItem).toBeGreaterThan(0); }); });