import type { MarketListing } from "@/payload-types"; import { notifyUser } from "@/lib/notifications"; import { negotiationRange } from "@/lib/market"; import { npcDialogue, type NpcDialogueKind } from "@/lib/market/npcDialogue"; export { npcDialogue, type NpcDialogueKind }; type PayloadType = Awaited>; export const NPC_PATIENCE = { /** Patience gained every round the buyer keeps bargaining. */ roundCost: 12, /** Extra patience burned when the offer is below the vendor's floor. */ lowballPenalty: 25, /** Extra patience burned when the offer barely budges from the previous one. */ stallPenalty: 35, /** An offer that moves less than this fraction of the asking price counts as a "stall". */ stallRatio: 0.01, /** Patience relieved when the offer is close to the asking price. */ goodOfferRelief: 9, /** An offer at or above this fraction of the asking price counts as "good". */ goodOfferRatio: 0.85, /** When patience reaches this, the negotiation closes. */ cap: 100, } as const; export const NPC_ACCEPT = { /** The vendor concedes this fraction of the gap toward the buyer's offer each round. */ concedeRatio: 0.3, /** Offers within this fraction of the vendor's current stance may be accepted outright. */ marginRatio: 0.02, /** Chance the vendor accepts an offer inside the margin. */ chance: 0.85, } as const; export interface NpcState { /** The vendor's patience meter (0–100). */ patience?: number; /** The vendor's most recent counteroffer (null before the first round). */ lastCounter?: number | null; /** The buyer offer that drew lastCounter (null before the first round). */ lastBuyerOffer?: number | null; } export type NpcAcceptReason = "accept" | "good" | "overpay"; export type NpcCounterReason = Exclude; export type NpcResponse = | { action: "accept"; amount: number; reason: NpcAcceptReason } | { action: "counter"; amount: number; patience: number; closed: boolean; reason: NpcCounterReason; }; /** * Auto-response of an NPC vendor to a buyer's offer. * * The vendor keeps a private "stance" price that starts at the asking price and * only ever moves down. Offers at/above the stance are accepted outright; * offers within a small margin of the stance are accepted with high chance * (`NPC_ACCEPT.chance`) and otherwise the vendor holds firm. Offers well below * the stance draw a concession — the stance moves a fraction of the gap toward * the buyer, but never rises past its previous counter, never drops below the * vendor's floor (`minPrice`), and the vendor holds firm if the buyer offers * *less* than their previous offer. Offers that barely budge from the buyer's * previous offer ("stalls", < 1% of the asking price) burn extra patience. The patience meter * advances with every round: lowballs burn a lot, reasonable offers cost a * little, and near-asking offers ease the vendor's mind. At the cap the * negotiation closes — the buyer can only buy at the asking price. */ export function npcResponseToOffer( listing: Pick, amount: number, state: NpcState = {}, rng: () => number = Math.random, ): NpcResponse { const { min, desired } = negotiationRange(listing); const patience = state.patience ?? 0; const lastCounter = state.lastCounter ?? null; const lastBuyerOffer = state.lastBuyerOffer ?? null; const good = (n: number) => n >= desired * NPC_PATIENCE.goodOfferRatio ? ("good" as const) : ("accept" as const); if (amount >= desired) { return { action: "accept", amount: desired, reason: amount > desired ? "overpay" : "good" }; } const stance = lastCounter ?? desired; const margin = Math.round(stance * NPC_ACCEPT.marginRatio); const movedBackward = lastBuyerOffer != null && amount < lastBuyerOffer; const stall = lastBuyerOffer != null && amount > lastBuyerOffer && amount - lastBuyerOffer < desired * NPC_PATIENCE.stallRatio; if (amount >= stance) return { action: "accept", amount: Math.min(amount, desired), reason: good(amount) }; if (amount >= stance - margin) { if (rng() < NPC_ACCEPT.chance) { return { action: "accept", amount: Math.min(amount, desired), reason: good(amount) }; } // Declined inside the margin: hold firm at the current stance. const nextPatience = advancePatience(patience, amount, min, desired, lastBuyerOffer); return { action: "counter", amount: stance, patience: nextPatience, closed: nextPatience >= NPC_PATIENCE.cap, reason: stall ? "stall" : "firm", }; } const counter = movedBackward ? stance : Math.max(amount, min, Math.round(stance - (stance - amount) * NPC_ACCEPT.concedeRatio)); const nextPatience = advancePatience(patience, amount, min, desired, lastBuyerOffer); return { action: "counter", amount: counter, patience: nextPatience, closed: nextPatience >= NPC_PATIENCE.cap, reason: movedBackward ? "backward" : stall ? "stall" : amount < min ? "lowball" : "concede", }; } function advancePatience( patience: number, amount: number, min: number, desired: number, previous: number | null, ): number { let delta: number; if ( previous != null && amount > previous && amount - previous < desired * NPC_PATIENCE.stallRatio ) { delta = NPC_PATIENCE.roundCost + NPC_PATIENCE.stallPenalty; } else if (amount < min) { delta = NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty; } else if (amount >= desired * NPC_PATIENCE.goodOfferRatio) { delta = -NPC_PATIENCE.goodOfferRelief; } else { delta = NPC_PATIENCE.roundCost; } return Math.max(0, Math.min(NPC_PATIENCE.cap, patience + delta)); } export interface ExpireOptions { /** Reason shown in the buyer notification. */ reason?: string; } /** * Close every open negotiation thread on a listing (e.g. when it is sold or * expired) and notify each interested buyer. */ export async function expireNegotiationsForListing( payload: PayloadType, listingId: number, listingName: string, options?: ExpireOptions, ): Promise { const open = await payload.find({ collection: "market-negotiations", where: { and: [{ listing: { equals: listingId } }, { status: { equals: "open" } }] }, limit: 100, depth: 1, overrideAccess: true, }); const reason = options?.reason ?? "was sold"; for (const negotiation of open.docs) { await payload.update({ collection: "market-negotiations", id: negotiation.id, data: { status: "expired" }, overrideAccess: true, depth: 0, }); const buyerId = typeof negotiation.buyer === "object" && negotiation.buyer ? negotiation.buyer.id : (negotiation.buyer as number | null); if (buyerId != null) { await notifyUser(payload, { userId: buyerId, type: "market:expired", title: "Negotiation closed", message: `${listingName} ${reason} before you could close the deal.`, link: "/logistics/market", }); } } }