diff --git a/src/lib/market/index.ts b/src/lib/market/index.ts index 0614397..71bb9e4 100644 --- a/src/lib/market/index.ts +++ b/src/lib/market/index.ts @@ -16,6 +16,7 @@ import { getMainCurrencyId, getTreasuryAccountId, } from "@/lib/banking"; +import { preflightLockerCredit } from "@/lib/operations/settlementFit"; export { getTreasuryAccountId } from "@/lib/banking"; @@ -299,6 +300,21 @@ export async function buyListing( if (treasuryId) sellerAccountId = treasuryId; } + // Preflight: verify the buyer's locker has room BEFORE moving money so a + // full locker can never leave the buyer paid with no items. This mirrors + // the real credit path in `creditLockerQuantity` (merge-then-place); the + // writer re-runs its own check, so a passing preflight cannot partially + // apply. Reuses the settlement-layer pure preflight so the two stay in sync. + const buyerLocker = await ensureLockerStorage(payload, buyer.id as number); + const buyerLockerFull = (await payload.findByID({ + collection: "locker-storages", + id: buyerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + const grid = await getLockerGridDimensions(payload); + preflightLockerCredit(buyerLockerFull, grid, [{ assetId: asset.id, quantity, asset }]); + const transaction = await applyTransaction(payload, { type: "payment", fromAccountId: buyerAccount.id, @@ -308,7 +324,31 @@ export async function buyListing( actorId: buyer.id as number, }); - await creditLockerQuantity(payload, buyer.id as number, asset.id, quantity); + try { + await creditLockerQuantity(payload, buyer.id as number, asset.id, quantity); + } catch (error) { + // Compensating refund: the payment moved but the locker credit failed + // (e.g. a concurrent deposit filled the locker between the preflight and + // the credit). Reverse the payment so the buyer is never left paid with + // no items; a refund failure is logged and the original error rethrown. + try { + await applyTransaction(payload, { + type: "adjustment", + fromAccountId: sellerAccountId, + toAccountId: buyerAccount.id, + amount: total, + memo: `Refund: purchase TXN ${transaction.id} failed to credit locker`, + actorId: buyer.id as number, + }); + } catch (refundError) { + payload.logger.error( + `[Market] CRITICAL: purchase ${transaction.id} credit failed AND refund failed; manual reconciliation required: ${ + refundError instanceof Error ? refundError.message : "unknown" + }`, + ); + } + throw error instanceof Error ? error : new Error("Locker credit failed after payment."); + } const remaining = stock - quantity; const updated = (await payload.update({ diff --git a/src/lib/operations/economy.ts b/src/lib/operations/economy.ts new file mode 100644 index 0000000..7243850 --- /dev/null +++ b/src/lib/operations/economy.ts @@ -0,0 +1,203 @@ +/** + * Operation economy-effect adapter - Payload interaction. + * + * Consumes APPLIED operation effects and adjusts the abstract supply/demand + * ledger (`market-state`) exactly once per effect. Mirrors the settlement + * dispatcher's fresh-re-read + terminal-guard shape so a replay applies + * nothing twice, and records the applied/rejected deltas on the effect row as + * reconciliation evidence. + * + * Only market-state docs that already exist are touched; items without a ledger + * are recorded as provenance-eligible only (never auto-marketable). + */ + +import type { Payload } from "payload"; +import type { OperationEffect } from "@/payload-types"; + +import { findByIdOrNull } from "./settlementResolve"; +import { + clampEconomyCaps, + computeEconomyDelta, + DEFAULT_ECONOMY_CAPS, + EconomyEffectCaps, + EconomyPlan, + EconomyRecord, + hasEconomyProcessed, + appendEconomyNote, + MarketDeltaSummary, + ProvenanceItem, + ECONOMY_NOTE_PREFIX, +} from "./economyConfig"; + +type PayloadType = Awaited>; + +interface MarketStateRow { + id: number; + currentSupply: number; +} + +async function findMarketStateRow( + payload: PayloadType, + 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, + }); + const doc = docs.docs[0] as { id: number; currentSupply: number } | undefined; + if (!doc) return null; + return { id: doc.id, currentSupply: doc.currentSupply ?? 0 }; +} + +async function applyEconomyDelta( + payload: PayloadType, + plan: EconomyPlan, + caps: EconomyEffectCaps, +): Promise<{ applied: MarketDeltaSummary[]; rejected: MarketDeltaSummary[]; provenance: ProvenanceItem[] }> { + const applied: MarketDeltaSummary[] = []; + const rejected: MarketDeltaSummary[] = []; + const provenance: ProvenanceItem[] = [...plan.provenance]; + + for (const delta of plan.deltas) { + const row = await findMarketStateRow(payload, delta.itemType, delta.itemId); + if (!row) { + // No market-state ledger for this item: provenance-eligible only. + // Never create a doc or listing (extraction loot is not auto-marketable). + provenance.push({ itemType: delta.itemType, itemId: delta.itemId }); + continue; + } + + const magnitude = Math.abs(delta.supply); + const cap = delta.supply > 0 ? caps.faucetCapPerItem : caps.drainCapPerItem; + let appliedSupply = delta.supply; + let rejectedAmount = 0; + if (magnitude > cap) { + appliedSupply = delta.supply > 0 ? cap : -cap; + rejectedAmount = magnitude - cap; + } + + await payload.update({ + collection: "market-state", + id: row.id, + data: { currentSupply: Math.max(0, row.currentSupply + appliedSupply) }, + depth: 0, + overrideAccess: true, + }); + + if (appliedSupply !== 0) applied.push({ ...delta, supply: appliedSupply }); + if (rejectedAmount > 0) { + rejected.push({ itemType: delta.itemType, itemId: delta.itemId, supply: rejectedAmount }); + } + } + + return { applied, rejected, provenance }; +} + +/** + * Apply one effect's market impact. Never throws: a recoverable failure is + * recorded on the effect row as reconciliation evidence (the effect status is + * left for settlement) so the ledger write path stays unbreakable. + */ +export async function applyEconomyEffect( + payload: PayloadType, + effectId: number, + caps?: Partial, +): Promise { + const effect = (await findByIdOrNull(payload, "operation-effects", effectId)) as + | OperationEffect + | null; + if (!effect) return null; + + // Only effects settlement has applied are eligible; pending/failed/reversed + // rows are never reprocessed. + if (effect.status !== "applied") return effect; + + // Idempotency: the economy changes market state exactly once per effect. + if (hasEconomyProcessed(effect.reconciliationNotes)) return effect; + + try { + const plan = computeEconomyDelta(effect); + // Nothing for the economy to touch: leave the effect for other consumers. + if (plan.deltas.length === 0 && plan.provenance.length === 0) return effect; + + const resolvedCaps = clampEconomyCaps(caps); + const { applied, rejected, provenance } = await applyEconomyDelta(payload, plan, resolvedCaps); + + return (await payload.update({ + collection: "operation-effects", + id: effect.id, + data: { + reconciliationNotes: appendEconomyNote(effect.reconciliationNotes, { + operationId: effect.operationId, + effectKey: effect.effectKey, + effectType: effect.effectType, + applied, + rejected, + provenance, + }), + }, + depth: 0, + overrideAccess: true, + })) as unknown as OperationEffect; + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + payload.logger.error(`[Operations] Economy effect ${effect.effectKey} failed: ${message}`); + return (await payload.update({ + collection: "operation-effects", + id: effect.id, + data: { + reconciliationNotes: appendEconomyNote(effect.reconciliationNotes, { + operationId: effect.operationId, + effectKey: effect.effectKey, + effectType: effect.effectType, + applied: [], + rejected: [], + provenance: [], + error: message, + }), + }, + depth: 0, + overrideAccess: true, + })) as unknown as OperationEffect; + } +} + +/** + * Apply the economy impact of every not-yet-processed applied effect for an + * operation. Returns the number of effects that had economy impact. Skips + * pending/failed/reversed rows and effects already marked by the economy. + */ +export async function applyEconomyEffectsForOperation( + payload: PayloadType, + operationId: string, + caps?: Partial, +): Promise { + const resolvedCaps = clampEconomyCaps(caps); + const found = await payload.find({ + collection: "operation-effects", + where: { + and: [{ operationId: { equals: operationId } }, { status: { equals: "applied" } }], + }, + limit: 200, + depth: 0, + overrideAccess: true, + }); + + let processed = 0; + for (const doc of found.docs as OperationEffect[]) { + if (doc.reconciliationNotes?.includes(ECONOMY_NOTE_PREFIX)) continue; + const plan = computeEconomyDelta({ effectType: doc.effectType, payload: doc.payload }); + if (plan.deltas.length === 0 && plan.provenance.length === 0) continue; + await applyEconomyEffect(payload, doc.id, resolvedCaps); + processed += 1; + } + return processed; +} + +export { DEFAULT_ECONOMY_CAPS }; diff --git a/src/lib/operations/economyConfig.ts b/src/lib/operations/economyConfig.ts new file mode 100644 index 0000000..5d20e81 --- /dev/null +++ b/src/lib/operations/economyConfig.ts @@ -0,0 +1,171 @@ +/** + * Operation economy-effect adapter - configuration and pure delta logic. + * + * Consumes APPLIED operation effects and adjusts the abstract supply/demand + * ledger (`market-state`) exactly once per effect. This is a PURE module + * (no Payload imports) so the delta rules and caps are unit-testable without + * a database and importable anywhere. + * + * Supply model: a logistics extraction deposits goods that flow into the + * market ledger as available supply (a faucet); death/loss removes goods from + * circulation (a drain). Player extraction credits the extractor's locker, not + * the market, so those goods are recorded as provenance-eligible only. + * + * Provenance guard: the adapter records provenance eligibility, it never flips + * an asset's `tradeable`/`isLive` flags and never creates listings, so + * extraction loot is never auto-marketable. Vendor `minPrice` floors are never + * surfaced in any output. + */ + +import { + parseExtractionPayload, + parseInventoryLossPayload, + parseLogisticsLine, + parsePlayerLine, +} from "./settlementParse"; + +export type EconomyItemType = "resource" | "asset"; + +/** A net supply change to one item's market-state ledger. */ +export interface MarketDelta { + itemType: EconomyItemType; + itemId: number; + /** Positive = faucet (supply in), negative = drain (supply out). */ + supply: number; +} + +/** An item that is provenance-eligible for the market but has no ledger write. */ +export interface ProvenanceItem { + itemType: EconomyItemType; + itemId: number; +} + +/** The market impact of one operation effect. */ +export interface EconomyPlan { + deltas: MarketDelta[]; + provenance: ProvenanceItem[]; +} + +/** A single applied or rejected supply movement, recorded as reconciliation evidence. */ +export interface MarketDeltaSummary { + itemType: EconomyItemType; + itemId: number; + supply: number; +} + +/** The economy record stored on the effect row after processing. */ +export interface EconomyRecord { + operationId: string; + effectKey: string; + effectType: string; + applied: MarketDeltaSummary[]; + rejected: MarketDeltaSummary[]; + provenance: ProvenanceItem[]; + /** Populated only when applying the delta failed; the effect status is left for settlement. */ + error?: string; +} + +/** + * Faucet/drain caps: the largest single effect may move one item's supply by. + * Excess is rejected (not applied) and recorded as reconciliation evidence so + * a runaway extraction or loss cannot flood or hollow out the market ledger. + */ +export interface EconomyEffectCaps { + faucetCapPerItem: number; + drainCapPerItem: number; +} + +export const DEFAULT_ECONOMY_CAPS: EconomyEffectCaps = { + faucetCapPerItem: 1000, + drainCapPerItem: 1000, +}; + +/** Coerce partial/invalid/absent cap overrides into valid non-negative caps. */ +export function clampEconomyCaps(caps?: Partial): EconomyEffectCaps { + const toCap = (value: number | null | undefined, fallback: number): number => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback; + return { + faucetCapPerItem: toCap(caps?.faucetCapPerItem, DEFAULT_ECONOMY_CAPS.faucetCapPerItem), + drainCapPerItem: toCap(caps?.drainCapPerItem, DEFAULT_ECONOMY_CAPS.drainCapPerItem), + }; +} + +/** + * Marker written onto an effect's `reconciliationNotes` once the economy has + * processed it. Preserves the settlement-layer note (written first) by + * appending after it; idempotency is detected by the marker's presence. + */ +export const ECONOMY_NOTE_PREFIX = "economy:"; + +export function hasEconomyProcessed(notes: string | null | undefined): boolean { + return typeof notes === "string" && notes.includes(ECONOMY_NOTE_PREFIX); +} + +/** Append the economy record to any existing reconciliation note. */ +export function appendEconomyNote(existing: string | null | undefined, record: EconomyRecord): string { + const prefix = typeof existing === "string" && existing ? `${existing}\n` : ""; + return `${prefix}${ECONOMY_NOTE_PREFIX}${JSON.stringify(record)}`; +} + +/** + * Compute one effect's market impact. Untrusted effect payloads are parsed + * exactly once here; anything the parsers reject yields an empty plan (the + * adapter records nothing and the effect carries no market delta). + */ +export function computeEconomyDelta(effect: { + effectType: string; + payload?: unknown; +}): EconomyPlan { + switch (effect.effectType) { + case "operation.extraction-recorded": + return extractionDelta(effect.payload); + case "operation.inventory-recorded": + return lossDelta(effect.payload); + default: + return { deltas: [], provenance: [] }; + } +} + +function extractionDelta(raw: unknown): EconomyPlan { + const parsed = parseExtractionPayload(raw); + if (!parsed.ok) return { deltas: [], provenance: [] }; + const ext = parsed.value; + if (!ext.kind) return { deltas: [], provenance: [] }; + + if (ext.kind === "logistics") { + // Extracted goods flow into the market ledger as available supply. + const deltas: MarketDelta[] = []; + for (const rawLine of ext.items ?? []) { + const line = parseLogisticsLine(rawLine); + if (!line.ok) continue; + deltas.push({ itemType: "resource", itemId: line.value.resourceId, supply: line.value.amount }); + } + return { deltas, provenance: [] }; + } + + // Player extraction credits the extractor's locker, not the market. The + // goods are provenance-eligible (the player may list them later) but are + // never auto-marketable here. + const provenance: ProvenanceItem[] = []; + for (const rawLine of ext.items ?? []) { + const line = parsePlayerLine(rawLine); + if (!line.ok) continue; + provenance.push({ itemType: "asset", itemId: line.value.assetId }); + } + return { deltas: [], provenance }; +} + +function lossDelta(raw: unknown): EconomyPlan { + const parsed = parseInventoryLossPayload(raw); + if (!parsed.ok) return { deltas: [], provenance: [] }; + const loss = parsed.value; + if (!loss.reason) return { deltas: [], provenance: [] }; + // Lost goods leave circulation: a drain on the market ledger. + const deltas: MarketDelta[] = []; + for (const rawLine of loss.items ?? []) { + const line = parsePlayerLine(rawLine); + if (!line.ok) continue; + deltas.push({ itemType: "asset", itemId: line.value.assetId, supply: -line.value.quantity }); + } + return { deltas, provenance: [] }; +} diff --git a/tests/int/operation-economy.int.spec.ts b/tests/int/operation-economy.int.spec.ts new file mode 100644 index 0000000..5aa4776 --- /dev/null +++ b/tests/int/operation-economy.int.spec.ts @@ -0,0 +1,667 @@ +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); + }); +});