1
0
Fork 0
polaris-task-force/src/scripts/marketTick.ts
Z8MB1E 89b8474e23 feat(market): add trading marketplace with listings, haggling, and NPC vendors
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)
2026-08-10 16:05:57 -04:00

173 lines
6 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,
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);
};