import type { SanitizedConfig } from "payload"; 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, 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"; export const script = async (config: SanitizedConfig) => { const payload = await getPayload({ config }); const log = createBinLogger("market-tick", payload.logger); try { log.info("[Market Tick] Starting market tick..."); const now = new Date(); const nowIso = now.toISOString(); // 1. Expire overdue listings (returns stock to sellers for user listings). const expiring = await payload.find({ collection: "market-listings", where: { and: [{ status: { equals: "active" } }, { expiresAt: { less_than_equal: nowIso } }], }, limit: 200, depth: 1, sort: "expiresAt", }); let expiredCount = 0; let expiredUserCount = 0; for (const listing of expiring.docs) { const sellerId = typeof listing.seller === "object" && listing.seller ? (listing.seller as { id: number }).id : (listing.seller as number | null); const isUserListing = sellerId != null && !listing.isAutoGenerated; try { await expireListing(payload, listing as unknown as MarketListing); expiredCount++; if (isUserListing) expiredUserCount++; const assetName = typeof listing.asset === "object" && listing.asset ? listing.asset.name : `asset #${resolveAssetId(listing.asset)}`; await expireNegotiationsForListing(payload, listing.id, assetName, { reason: "expired before you could close the deal", }); await emitGameEvent(payload, { type: EventTypes.MarketListingExpire, message: `Listing for ${listing.quantity}× ${assetName} expired${ isUserListing ? ", stock returned to seller" : "" }`, actor: sellerId ?? undefined, targetCollection: "market-listings", targetId: listing.id, data: { assetId: listing.asset, assetName, quantity: listing.quantity, autoGenerated: listing.isAutoGenerated, }, }); } catch (error) { log.error(`[Market Tick] Error expiring listing ${listing.id}: ${errorMessage(error)}`); } } // 2. Top up auto-generated entries for tradeable live assets with no active listing. const active = await payload.find({ collection: "market-listings", where: { status: { equals: "active" } }, limit: 2000, depth: 0, }); const activeAssetIds = new Set(active.docs.map((d) => resolveAssetId(d.asset))); const assets = await payload.find({ collection: "assets", where: { and: [{ tradeable: { equals: true } }, { isLive: { equals: true } }], }, limit: 2000, depth: 0, sort: "name", }); const currencyId = await getMainCurrencyId(payload); let generatedCount = 0; let generatedNpcCount = 0; for (const asset of assets.docs as unknown as Asset[]) { if (activeAssetIds.has(asset.id)) continue; 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(modifiedPrice, npc); if (price <= 0) continue; // 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 { await payload.create({ collection: "market-listings", data: { asset: asset.id, seller: null, npc: npc.id, quantity, price, desiredPrice: price, minPrice, currency: currencyId ?? null, status: "active", isAutoGenerated: true, listedAt: nowIso, expiresAt, }, overrideAccess: true, depth: 0, }); generatedCount++; } catch (error) { log.error( `[Market Tick] Error generating listing for asset ${asset.id}: ${errorMessage(error)}`, ); } } await emitGameEvent(payload, { type: EventTypes.MarketAutoGenerate, message: `Market tick: expired ${expiredCount} listing(s), generated ${generatedCount} new market entry/entries${generatedNpcCount > 0 ? ` (${generatedNpcCount} new NPC${generatedNpcCount !== 1 ? "s" : ""} generated)` : ""}`, data: { expiredCount, generatedCount, generatedNpcCount, activeListings: active.docs.length, }, }); // 3. Notify connected clients so the market refreshes. const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET; if (notifySecret) { await fetch(`${notifyUrl}/api/game-tick/notify`, { method: "POST", headers: { "content-type": "application/json", "x-game-tick-secret": notifySecret, }, body: JSON.stringify({ processedAt: nowIso, source: "market-tick" }), }).catch((error: unknown) => { log.error(`[Market Tick] Failed to notify app clients: ${errorMessage(error)}`); }); } log.info(`[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`); process.exit(0); } catch (error) { log.error( `[Market Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`, ); process.exit(1); } };