Tarkov-style flea market where users list locker items at a fixed price and buyers pay through the banking system; items transfer directly into the buyer's locker. - market-listings + market-negotiations collections and the market page with search/rarity filters and a Countdown timer on each card - service lib (buyListing with partial-quantity + negotiated-price support, clean-stock rule, creditLockerQuantity, auto pricing) - negotiation flow: make/accept/reject/counter/withdraw, strictly- increasing buyer offers, NPC vendor auto-resolution - NPC vendors: game-npcs attribution, patience meter, dialogue, chat bubbles, historical threads dialog - 30d user / 7d auto listing durations, market-tick bin for expiry + vendor restock, SSE notify on tick - market event types + game-npcs/market target collections in the event log; sidebar Market entry under Logistics - partial-quantity purchases (buyListing/buyMarketListing/makeMarketOffer) with full int test coverage (25 tests)
173 lines
6 KiB
TypeScript
173 lines
6 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,
|
||
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);
|
||
};
|