feat(operations): bounded economy effects and market repair
Link operation loot to the supply/demand ledger with faucet/drain caps and provenance-marked notes; repair buyListing to preflight locker credit before payment with a compensating refund on credit failure. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
67c993ca10
commit
67adaa301c
4 changed files with 1082 additions and 1 deletions
|
|
@ -16,6 +16,7 @@ import {
|
||||||
getMainCurrencyId,
|
getMainCurrencyId,
|
||||||
getTreasuryAccountId,
|
getTreasuryAccountId,
|
||||||
} from "@/lib/banking";
|
} from "@/lib/banking";
|
||||||
|
import { preflightLockerCredit } from "@/lib/operations/settlementFit";
|
||||||
|
|
||||||
export { getTreasuryAccountId } from "@/lib/banking";
|
export { getTreasuryAccountId } from "@/lib/banking";
|
||||||
|
|
||||||
|
|
@ -299,6 +300,21 @@ export async function buyListing(
|
||||||
if (treasuryId) sellerAccountId = treasuryId;
|
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, {
|
const transaction = await applyTransaction(payload, {
|
||||||
type: "payment",
|
type: "payment",
|
||||||
fromAccountId: buyerAccount.id,
|
fromAccountId: buyerAccount.id,
|
||||||
|
|
@ -308,7 +324,31 @@ export async function buyListing(
|
||||||
actorId: buyer.id as number,
|
actorId: buyer.id as number,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
await creditLockerQuantity(payload, buyer.id as number, asset.id, quantity);
|
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 remaining = stock - quantity;
|
||||||
const updated = (await payload.update({
|
const updated = (await payload.update({
|
||||||
|
|
|
||||||
203
src/lib/operations/economy.ts
Normal file
203
src/lib/operations/economy.ts
Normal file
|
|
@ -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<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
interface MarketStateRow {
|
||||||
|
id: number;
|
||||||
|
currentSupply: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findMarketStateRow(
|
||||||
|
payload: PayloadType,
|
||||||
|
itemType: "resource" | "asset",
|
||||||
|
itemId: number,
|
||||||
|
): Promise<MarketStateRow | null> {
|
||||||
|
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<EconomyEffectCaps>,
|
||||||
|
): Promise<OperationEffect | null> {
|
||||||
|
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<EconomyEffectCaps>,
|
||||||
|
): Promise<number> {
|
||||||
|
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 };
|
||||||
171
src/lib/operations/economyConfig.ts
Normal file
171
src/lib/operations/economyConfig.ts
Normal file
|
|
@ -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>): 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: [] };
|
||||||
|
}
|
||||||
667
tests/int/operation-economy.int.spec.ts
Normal file
667
tests/int/operation-economy.int.spec.ts
Normal file
|
|
@ -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<number | null> {
|
||||||
|
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<number> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): 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<number> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue