205 lines
7.1 KiB
TypeScript
205 lines
7.1 KiB
TypeScript
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<ReturnType<typeof import("payload").getPayload>>;
|
||
|
||
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<NpcDialogueKind, "closed" | "accept" | "good" | "overpay">;
|
||
|
||
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<MarketListing, "price" | "desiredPrice" | "minPrice">,
|
||
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<void> {
|
||
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",
|
||
});
|
||
}
|
||
}
|
||
}
|