1
0
Fork 0
polaris-task-force/src/lib/market/npcs.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

213 lines
4.3 KiB
TypeScript

import type { GameNpc } from "@/payload-types";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export const NPC_FIRST_NAMES = [
"Marcus",
"Sergei",
"Adrian",
"Marek",
"Viktor",
"Tomas",
"Lukas",
"Nikolai",
"Janek",
"Dominik",
"Emil",
"Petr",
"Karel",
"Milan",
"Oskar",
"Radek",
"Fedor",
"Ivan",
"Andrei",
"Boris",
"Yuri",
"Dmitri",
"Alexei",
"Pavel",
"Stefan",
"Wojtek",
"Jacek",
"Mateusz",
"Piotr",
"Tadeusz",
"Karol",
"Zbigniew",
"Henrik",
"Erling",
"Magnus",
"Anders",
"Lars",
"Mikkel",
"Cedric",
"Antoine",
"Basile",
"Thibault",
] as const;
export const NPC_LAST_NAMES = [
"Novak",
"Kovac",
"Havlicek",
"Svoboda",
"Dvorak",
"Cermak",
"Prochazka",
"Jelinek",
"Zeman",
"Kral",
"Barta",
"Fiala",
"Urban",
"Pospisil",
"Kowalski",
"Nowak",
"Wozniak",
"Lewandowski",
"Sokolov",
"Ivanov",
"Petrov",
"Smirnov",
"Volkov",
"Fedorov",
"Morozov",
"Pavlov",
"Kozlov",
"Stepanov",
"Todorov",
"Horvat",
"Markovic",
"Jovanovic",
"Petrovic",
"Berg",
"Larsen",
"Lindqvist",
"Virtanen",
"Fournier",
"Marchand",
"Dupont",
] as const;
export const NPC_OCCUPATIONS = [
"Weapons Dealer",
"Gunsmith",
"Arms Dealer",
"Munitions Specialist",
"Ammo Merchant",
"Black Market Trader",
"Quartermaster",
"Supply Officer",
"Field Doctor",
"Medic",
"Apothecary",
"Pharmacist",
"Chef",
"Brewer",
"Blacksmith",
"Mechanic",
"Tailor",
"Carpenter",
"Jeweler",
"Artisan",
"Smuggler",
"Fence",
"Dealer",
"Broker",
"Trader",
"Collector",
"Scavenger",
"Forager",
"Drifter",
"Farmer",
"Fisherman",
"Miner",
"Prospector",
"Radio Operator",
] as const;
export function randomOf<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
/** Random "First Last" name for a generated NPC. */
export function randomNpcName(): string {
return `${randomOf(NPC_FIRST_NAMES)} ${randomOf(NPC_LAST_NAMES)}`;
}
/** Random occupation for a generated NPC, biased toward market roles. */
export function randomNpcOccupation(): string {
return randomOf(NPC_OCCUPATIONS);
}
/**
* Create a randomly-generated NPC placeholder. `isGenerated` is true and
* `isInGame` is false so the GM can later flip them on to include them in
* the game. If `assetId` is given, the NPC is set up as a vendor selling it.
*/
export async function createGeneratedNpc(
payload: PayloadType,
opts: { assetId?: number } = {},
): Promise<GameNpc> {
return (await payload.create({
collection: "game-npcs",
data: {
name: randomNpcName(),
occupation: randomNpcOccupation(),
isGenerated: true,
isInGame: false,
vendor: {
enabled: true,
sells: opts.assetId ? [opts.assetId] : [],
},
},
overrideAccess: true,
depth: 0,
})) as unknown as GameNpc;
}
/** All enabled vendor NPCs that sell the given asset. */
export async function findVendorNpcs(
payload: PayloadType,
assetId: number,
): Promise<GameNpc[]> {
const result = await payload.find({
collection: "game-npcs",
where: {
and: [
{ "vendor.enabled": { equals: true } },
{ "vendor.sells": { equals: assetId } },
],
},
limit: 100,
depth: 0,
overrideAccess: true,
});
return result.docs as unknown as GameNpc[];
}
/**
* Pick an NPC to stock an auto-generated listing for the given asset:
* 1. Prefer NPCs that are officially in the game (GM-assigned vendors).
* 2. Otherwise reuse any existing vendor NPC selling the asset.
* 3. If none exist, generate a new placeholder NPC for the asset.
*/
export async function resolveVendorNpc(
payload: PayloadType,
assetId: number,
): Promise<GameNpc> {
const vendors = await findVendorNpcs(payload, assetId);
if (vendors.length > 0) {
const inGame = vendors.filter((npc) => npc.isInGame === true);
const pool = inGame.length > 0 ? inGame : vendors;
return randomOf(pool);
}
return createGeneratedNpc(payload, { assetId });
}
/** Apply an NPC's price modifier to an auto-generated price. */
export function applyNpcPriceModifier(basePrice: number, npc: GameNpc): number {
const modifier = npc.vendor?.priceModifier ?? 1;
return Math.max(1, Math.round(basePrice * modifier));
}