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