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, autoPrice, autoQuantity, expireListing, } from "@/lib/market"; import { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs"; import { expireNegotiationsForListing } from "@/lib/market/negotiations"; import { resolveAssetId } from "@/lib/locker"; import { getMainCurrencyId } from "@/lib/banking"; export const script = async (config: SanitizedConfig) => { const payload = await getPayload({ config }); payload.logger.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) { const message = error instanceof Error ? error.message : "Unknown error"; payload.logger.error(`[Market Tick] Error expiring listing ${listing.id}: ${message}`); } } // 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 = autoPrice(asset, "buy"); if (basePrice == null) continue; // 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); if (price <= 0) continue; const quantity = 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) { const message = error instanceof Error ? error.message : "Unknown error"; payload.logger.error(`[Market Tick] Error generating listing for asset ${asset.id}: ${message}`); } } 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) => { const message = error instanceof Error ? error.message : "Unknown error"; payload.logger.error(`[Market Tick] Failed to notify app clients: ${message}`); }); } payload.logger.info( `[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`, ); process.exit(0); };