diff --git a/src/lib/market/index.ts b/src/lib/market/index.ts index 547e888..0614397 100644 --- a/src/lib/market/index.ts +++ b/src/lib/market/index.ts @@ -1,4 +1,5 @@ import type { Asset, LockerStorage, MarketListing, User } from "@/payload-types"; +import { recordPurchase } from "@/lib/economy/consumption"; import { ensureLockerStorage, findLockerEmptySpot, @@ -321,6 +322,13 @@ export async function buyListing( depth: 1, })) as unknown as MarketListing; + // Real-demand ingestion: completed purchases of NPC/vendor stock feed the + // economy ledger. The purchase already succeeded, so ingestion must not + // throw — recordPurchase logs and swallows internally. + if (sellerId == null) { + await recordPurchase(payload, { itemId: asset.id, quantity }); + } + return { transactionId: transaction.id, total, @@ -363,18 +371,6 @@ export async function expireListing( })) as unknown as MarketListing; } -/** - * Price an auto-generated entry as a deviation around the asset's base buy - * price. `kind` mirrors the resting valuation: `buy` prices NPC stock near - * the base buy price, `sell` prices it near the base sell price. - */ -export function autoPrice(asset: Asset, kind: "buy" | "sell" = "buy"): number | null { - const base = kind === "buy" ? getBaseBuyPrice(asset) : getBaseSellPrice(asset); - if (base == null || base <= 0) return null; - const deviation = (Math.random() * 2 - 1) * 0.15; - return Math.max(1, Math.round(base * (1 + deviation))); -} - /** Suggested auto-generated quantity: a few units for stackables, else 1. */ export function autoQuantity(asset: Asset): number { if (lockerIsStackable(asset)) { diff --git a/src/scripts/marketTick.ts b/src/scripts/marketTick.ts index 4ce8305..d416006 100644 --- a/src/scripts/marketTick.ts +++ b/src/scripts/marketTick.ts @@ -3,9 +3,11 @@ import { getPayload } from "payload"; import type { Asset, MarketListing } from "@/payload-types"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; -import { AUTO_LISTING_DURATION_MS, autoPrice, autoQuantity, expireListing } from "@/lib/market"; +import { AUTO_LISTING_DURATION_MS, autoQuantity, expireListing, getBaseBuyPrice } from "@/lib/market"; import { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs"; import { expireNegotiationsForListing } from "@/lib/market/negotiations"; +import { getMarketPriceContext } from "@/lib/economy/consumption"; +import { scaleQuantityForShortage } from "@/lib/economy/math"; import { resolveAssetId } from "@/lib/locker"; import { getMainCurrencyId } from "@/lib/banking"; import { createBinLogger, errorMessage } from "./lib/binFileLogger"; @@ -94,17 +96,26 @@ export const script = async (config: SanitizedConfig) => { let generatedNpcCount = 0; for (const asset of assets.docs as unknown as Asset[]) { if (activeAssetIds.has(asset.id)) continue; - const basePrice = autoPrice(asset, "buy"); + const basePrice = getBaseBuyPrice(asset); if (basePrice == null) continue; + // Supply/demand modifier from the economy ledger (neutral 1 when the + // item has no market-state doc yet), then the vendor's own modifier. + const priceCtx = await getMarketPriceContext(payload, "asset", asset.id); + const modifiedPrice = Math.max(1, Math.round(basePrice * (priceCtx?.priceModifier ?? 1))); + // Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs, // fall back to generating a placeholder NPC when none sell this good. const npc = await resolveVendorNpc(payload, asset.id); if (npc.isGenerated === true) generatedNpcCount++; - const price = applyNpcPriceModifier(basePrice, npc); + const price = applyNpcPriceModifier(modifiedPrice, npc); if (price <= 0) continue; - const quantity = autoQuantity(asset); + // Vendors restock less under shortage; bounded so they never stock zero. + const demand = priceCtx ? priceCtx.artificialDemand + priceCtx.realDemand : 0; + const quantity = priceCtx + ? scaleQuantityForShortage(autoQuantity(asset), priceCtx.currentSupply, demand) + : autoQuantity(asset); const expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString(); const minPrice = Math.max(1, Math.round(price * 0.6)); try { diff --git a/tests/int/economy-consumption.int.spec.ts b/tests/int/economy-consumption.int.spec.ts new file mode 100644 index 0000000..05937e4 --- /dev/null +++ b/tests/int/economy-consumption.int.spec.ts @@ -0,0 +1,264 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { Asset, BankAccount, MarketListing, User } from "@/payload-types"; +import { buyListing } from "@/lib/market"; +import { resolveVendorNpc } from "@/lib/market/npcs"; +import { ensureLockerStorage } from "@/lib/locker"; +import { applyTransaction, createAccount, getMainCurrencyId } from "@/lib/banking"; +import { scaleQuantityForShortage } from "@/lib/economy/math"; +import { + getMarketPriceContext, + recordPurchase, + resetMarketStateDocs, +} from "@/lib/economy/consumption"; +import { ensureMarketStateDocs } from "@/lib/economy/tick"; + +/** + * Pure tests for the shortage-scaling helper. The price composition itself + * (round(base × modifier) then applyNpcPriceModifier) lives in the bin script, + * which is not unit-testable; the lib-level pieces below cover the testable + * seams: restock scaling, purchase ingestion, and GM reset. + */ +describe("Economy consumption: scaleQuantityForShortage", () => { + it("keeps the quantity unchanged when supply meets demand", () => { + expect(scaleQuantityForShortage(5, 100, 50)).toBe(5); + }); + + it("keeps the quantity unchanged when demand is zero", () => { + expect(scaleQuantityForShortage(4, 0, 0)).toBe(4); + }); + + it("scales the quantity down proportionally under shortage", () => { + // supply/demand = 50/100 = 0.5 → 5 × 0.5 = 2.5 → floor 2 + expect(scaleQuantityForShortage(5, 50, 100)).toBe(2); + }); + + it("never scales below the 0.25 floor factor", () => { + // supply/demand = 10/100 = 0.1 → clamped to 0.25 → 8 × 0.25 = 2 + expect(scaleQuantityForShortage(8, 10, 100)).toBe(2); + }); + + it("never returns zero, even under extreme shortage", () => { + expect(scaleQuantityForShortage(1, 1, 1000)).toBe(1); + }); +}); + +describe("Economy consumption: DB integration", () => { + let payload: Payload; + + const RUN = `econ-cons-${Date.now().toString(36)}`; + let assetId: number; + let buyer: User; + let buyerAccount: BankAccount; + + const findStateDoc = async (id: number) => { + const docs = await payload.find({ + collection: "market-state", + where: { and: [{ itemType: { equals: "asset" } }, { itemId: { equals: id } }] }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const doc = docs.docs[0]; + if (!doc) throw new Error("market-state doc not found for fixture asset"); + return doc; + }; + + const createFixtureAsset = async (): Promise => { + const created = (await payload.create({ + collection: "assets", + data: { + name: `${RUN} Asset`, + className: "test", + assetType: "miscellaneous", + approvalStatus: "in_progress", + tradeable: true, + isLive: true, + marketTrading: { Rarity: "common", perceivedValue: { baseBuyPrice: 100, baseSellPrice: 80 } }, + crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } }, + storageDimensions: { gridWidth: 1, gridHeight: 1 }, + economy: { enabled: true, baselineSupply: 100, baselineDemand: 50, maxModifier: 0.5 }, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Asset; + return created.id; + }; + + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + + assetId = await createFixtureAsset(); + + buyer = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-buyer`, + discordUsername: `${RUN}-buyer`, + displayName: "BUYER", + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roles: ["user"], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + + await ensureLockerStorage(payload, buyer.id); + buyerAccount = await createAccount(payload, { + name: `${RUN} buyer wallet`, + accountType: "personal", + ownerUserId: buyer.id, + }); + await applyTransaction(payload, { + type: "deposit", + toAccountId: buyerAccount.id, + amount: 10_000, + memo: "economy consumption test funding", + }); + }); + + afterAll(async () => { + const cleanup = async (fn: () => Promise) => { + await fn().catch(() => {}); + }; + + const listings = await payload + .find({ + collection: "market-listings", + where: { asset: { equals: assetId } }, + limit: 100, + depth: 0, + overrideAccess: true, + }) + .catch(() => ({ docs: [] as MarketListing[] })); + for (const listing of listings.docs) { + await cleanup(() => + payload.delete({ collection: "market-listings", id: listing.id, overrideAccess: true }), + ); + } + + await cleanup(async () => { + const doc = await findStateDoc(assetId); + await payload.delete({ collection: "market-state", id: doc.id, overrideAccess: true }); + }); + await cleanup(() => payload.delete({ collection: "assets", id: assetId, overrideAccess: true })); + await cleanup(() => + payload.delete({ collection: "users", id: buyer.id, overrideAccess: true }), + ); + await cleanup(() => + payload.delete({ collection: "bank-accounts", id: buyerAccount.id, overrideAccess: true }), + ); + }); + + it("seeds a market-state doc from the asset's GM baselines", async () => { + const result = await ensureMarketStateDocs(payload, { + items: [{ itemType: "asset", itemId: assetId }], + }); + expect(result.created).toBe(1); + + const doc = await findStateDoc(assetId); + expect(doc.gmBaselineSupply).toBe(100); + expect(doc.gmBaselineDemand).toBe(50); + expect(doc.currentSupply).toBe(100); + expect(doc.artificialDemand).toBe(50); + expect(doc.realDemand).toBe(0); + expect(doc.priceModifier).toBe(1); + }); + + it("recordPurchase adds quantity-weighted real demand", async () => { + await recordPurchase(payload, { itemId: assetId, quantity: 3 }); + const doc = await findStateDoc(assetId); + expect(doc.realDemand).toBe(3); + + await recordPurchase(payload, { itemId: assetId, quantity: 2 }); + const after = await findStateDoc(assetId); + expect(after.realDemand).toBe(5); + }); + + it("getMarketPriceContext returns the ledger state for an item", async () => { + const ctx = await getMarketPriceContext(payload, "asset", assetId); + expect(ctx).not.toBeNull(); + if (!ctx) throw new Error("expected price context"); + expect(ctx.priceModifier).toBe(1); + expect(ctx.currentSupply).toBe(100); + + const missing = await getMarketPriceContext(payload, "asset", assetId + 999_999); + expect(missing).toBeNull(); + }); + + it("buyListing of an NPC listing ingests real demand", async () => { + const npc = await resolveVendorNpc(payload, assetId); + const currencyId = await getMainCurrencyId(payload); + const nowIso = new Date().toISOString(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + + // The previous test left real demand on the doc; this test needs a + // clean ledger to assert the exact ingested quantity. + await payload.update({ + collection: "market-state", + id: (await findStateDoc(assetId)).id, + data: { realDemand: 0 }, + overrideAccess: true, + depth: 0, + }); + + const created = (await payload.create({ + collection: "market-listings", + data: { + asset: assetId, + seller: null, + npc: npc.id, + quantity: 5, + price: 100, + desiredPrice: 100, + minPrice: 60, + currency: currencyId ?? null, + status: "active", + isAutoGenerated: true, + listedAt: nowIso, + expiresAt, + }, + overrideAccess: true, + depth: 0, + })) as unknown as MarketListing; + + const populated = await payload.findByID({ + collection: "market-listings", + id: created.id, + depth: 1, + overrideAccess: true, + }); + + await buyListing(payload, populated, buyer); + + const doc = await findStateDoc(assetId); + expect(doc.realDemand).toBe(5); + + await payload.delete({ collection: "market-listings", id: created.id, overrideAccess: true }); + }); + + it("resetMarketStateDocs rebuilds the ledger from GM baselines", async () => { + await payload.update({ + collection: "market-state", + id: (await findStateDoc(assetId)).id, + data: { realDemand: 10, currentSupply: 3, priceModifier: 1.5 }, + overrideAccess: true, + depth: 0, + }); + + const result = await resetMarketStateDocs(payload, { + items: [{ itemType: "asset", itemId: assetId }], + }); + expect(result.reset).toBe(1); + + const doc = await findStateDoc(assetId); + expect(doc.currentSupply).toBe(100); + expect(doc.artificialDemand).toBe(50); + expect(doc.realDemand).toBe(0); + expect(doc.priceModifier).toBe(1); + }); +}); diff --git a/tests/int/market.int.spec.ts b/tests/int/market.int.spec.ts index 9552a78..95eba5f 100644 --- a/tests/int/market.int.spec.ts +++ b/tests/int/market.int.spec.ts @@ -4,7 +4,6 @@ import config from "@/payload.config"; import { beforeAll, afterAll, describe, expect, it } from "vitest"; import type { Asset, BankAccount, GameNpc, LockerStorage, MarketListing, User } from "@/payload-types"; import { - autoPrice, autoQuantity, buyListing, countCleanQuantity, @@ -445,16 +444,13 @@ describe("Marketplace", () => { expect(countCleanQuantity(after, assetId)).toBe(before + 1); }); - it("autoPrice/autoQuantity produce sane vendor entries", async () => { + it("autoQuantity produces sane vendor entries", async () => { const asset = (await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true, })) as unknown as Asset; - const price = autoPrice(asset, "buy"); - expect(price).not.toBeNull(); - expect(price as number).toBeGreaterThan(0); expect(autoQuantity(asset)).toBeGreaterThan(0); });