184 lines
7 KiB
TypeScript
184 lines
7 KiB
TypeScript
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);
|
||
}
|
||
};
|