1
0
Fork 0
polaris-task-force/src/scripts/marketTick.ts

184 lines
7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
};