1
0
Fork 0
polaris-task-force/src/lib/market/negotiations.ts

205 lines
7.1 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 { 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",
});
}
}
}