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)
974 lines
34 KiB
TypeScript
974 lines
34 KiB
TypeScript
"use server";
|
||
|
||
import config from "@payload-config";
|
||
import { getPayload } from "payload";
|
||
import type { Asset, LockerStorage, MarketListing, MarketNegotiation, User } from "@/payload-types";
|
||
import hasRoles from "@/utils/access-control/hasRoles";
|
||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||
import {
|
||
countCleanQuantity,
|
||
creditLockerQuantity,
|
||
deductLockerQuantity,
|
||
buyListing,
|
||
negotiationRange,
|
||
USER_LISTING_DURATION_MS,
|
||
} from "@/lib/market";
|
||
import { ensureLockerStorage, resolveAssetId } from "@/lib/locker";
|
||
import { getMainCurrencyId } from "@/lib/banking";
|
||
import { notifyUser } from "@/lib/notifications";
|
||
import { expireNegotiationsForListing, npcDialogue, npcResponseToOffer, type NpcState } from "@/lib/market/negotiations";
|
||
|
||
export interface ActionResult<T = undefined> {
|
||
success: boolean;
|
||
error?: string;
|
||
data?: T;
|
||
}
|
||
|
||
async function authenticate() {
|
||
const payloadConfig = await config;
|
||
const payload = await getPayload({ config: payloadConfig });
|
||
const { headers } = await import("next/headers");
|
||
const hdrs = await headers();
|
||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||
|
||
if (!user) {
|
||
throw new Error("Unauthorized");
|
||
}
|
||
|
||
return { payload, user };
|
||
}
|
||
|
||
function isMarketManager(user: User): boolean {
|
||
return hasRoles(["admin", "developer"], user);
|
||
}
|
||
|
||
function sellerIdOf(listing: MarketListing): number | null {
|
||
return typeof listing.seller === "object" && listing.seller
|
||
? (listing.seller as { id: number }).id
|
||
: (listing.seller as number | null);
|
||
}
|
||
|
||
async function getAssetOrThrow(
|
||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||
assetId: number,
|
||
): Promise<Asset> {
|
||
const asset = (await payload
|
||
.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true })
|
||
.catch(() => null)) as unknown as Asset | null;
|
||
if (!asset) throw new Error("Asset not found.");
|
||
return asset;
|
||
}
|
||
|
||
async function getLockerWithItems(
|
||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||
userId: number,
|
||
): Promise<LockerStorage> {
|
||
const locker = await ensureLockerStorage(payload, userId);
|
||
return (await payload.findByID({
|
||
collection: "locker-storages",
|
||
id: locker.id,
|
||
depth: 1,
|
||
overrideAccess: true,
|
||
})) as unknown as LockerStorage;
|
||
}
|
||
|
||
async function getListingOrThrow(
|
||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||
listingId: number,
|
||
): Promise<MarketListing> {
|
||
const listing = (await payload
|
||
.findByID({ collection: "market-listings", id: listingId, depth: 1, overrideAccess: true })
|
||
.catch(() => null)) as unknown as MarketListing | null;
|
||
if (!listing) throw new Error("Listing not found.");
|
||
return listing;
|
||
}
|
||
|
||
function assetNameOf(listing: MarketListing): string {
|
||
const asset = listing.asset as Asset;
|
||
return typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`;
|
||
}
|
||
|
||
async function getNegotiationOrThrow(
|
||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||
negotiationId: number,
|
||
): Promise<MarketNegotiation> {
|
||
const negotiation = (await payload
|
||
.findByID({ collection: "market-negotiations", id: negotiationId, depth: 2, overrideAccess: true })
|
||
.catch(() => null)) as unknown as MarketNegotiation | null;
|
||
if (!negotiation) throw new Error("Negotiation not found.");
|
||
return negotiation;
|
||
}
|
||
|
||
function negotiationBuyerId(negotiation: MarketNegotiation): number | null {
|
||
return typeof negotiation.buyer === "object" && negotiation.buyer
|
||
? (negotiation.buyer as { id: number }).id
|
||
: (negotiation.buyer as number | null);
|
||
}
|
||
|
||
function negotiationListing(negotiation: MarketNegotiation): MarketListing {
|
||
return negotiation.listing as MarketListing;
|
||
}
|
||
|
||
/**
|
||
* Vendor-negotiation state derived from a thread *before* the buyer's latest
|
||
* offer overwrites it. `lastCounter` is the counter currently on the table
|
||
* when the vendor proposed it; `lastBuyerOffer` is the offer that drew it.
|
||
*/
|
||
function npcStateOf(negotiation: MarketNegotiation): NpcState {
|
||
const history = (negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>;
|
||
const lastBuyerEntry = [...history].reverse().find((entry) => entry.from === "buyer");
|
||
return {
|
||
patience: negotiation.patience ?? 0,
|
||
lastCounter: negotiation.proposedBy === "seller" ? (negotiation.amount ?? null) : null,
|
||
lastBuyerOffer: lastBuyerEntry ? (lastBuyerEntry.amount ?? null) : null,
|
||
};
|
||
}
|
||
|
||
/** The buyer's most recent offer on a thread (from history), if any. */
|
||
function lastBuyerOfferOf(negotiation: MarketNegotiation): number | null {
|
||
const history = (negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>;
|
||
const lastBuyerEntry = [...history].reverse().find((entry) => entry.from === "buyer");
|
||
return lastBuyerEntry ? (lastBuyerEntry.amount ?? null) : null;
|
||
}
|
||
|
||
/**
|
||
* The buyer's offers must be strictly increasing within a negotiation — they
|
||
* can never repeat an amount or offer less than they already have. Returns an
|
||
* error message when the offer violates this, otherwise null.
|
||
*/
|
||
function enforceHigherBuyerOffer(negotiation: MarketNegotiation, amount: number): string | null {
|
||
const lastOffer = lastBuyerOfferOf(negotiation);
|
||
if (lastOffer != null && amount <= lastOffer) {
|
||
return `Your offer must be higher than your previous offer of ${lastOffer.toLocaleString()}.`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Role of the acting user in a negotiation: "buyer", "seller", or null. */
|
||
function partyRoleOf(
|
||
negotiation: MarketNegotiation,
|
||
userId: number,
|
||
): "buyer" | "seller" | null {
|
||
if (negotiationBuyerId(negotiation) === userId) return "buyer";
|
||
const listing = negotiationListing(negotiation);
|
||
if (typeof listing === "object" && listing) {
|
||
const sellerId = sellerIdOf(listing as MarketListing);
|
||
if (sellerId === userId) return "seller";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Finish a negotiation at an agreed per-unit price: buy, mark the thread
|
||
* accepted, close sibling threads, notify both parties, emit events.
|
||
* `historyNote` (vendor dialogue) is appended to the thread history when given.
|
||
*/
|
||
async function finalizeNegotiation(
|
||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||
negotiation: MarketNegotiation,
|
||
listing: MarketListing,
|
||
buyer: User,
|
||
agreedPrice: number,
|
||
historyNote?: string,
|
||
): Promise<Awaited<ReturnType<typeof buyListing>>> {
|
||
const buyerId = buyer.id as number;
|
||
const sellerId = sellerIdOf(listing);
|
||
const assetName = assetNameOf(listing);
|
||
const quantity = negotiation.quantity ?? (listing.quantity ?? 1);
|
||
const total = agreedPrice * quantity;
|
||
|
||
const result = await buyListing(payload, listing, buyer, { unitPrice: agreedPrice, quantity });
|
||
|
||
const note = historyNote ?? (sellerId == null ? npcDialogue("accept") : undefined);
|
||
const data: Record<string, unknown> = { status: "accepted", acceptedPrice: agreedPrice };
|
||
if (note) {
|
||
const history = [
|
||
...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>),
|
||
{ amount: agreedPrice, from: "seller" as const, note, at: new Date().toISOString() },
|
||
];
|
||
data.history = history;
|
||
}
|
||
|
||
await payload.update({
|
||
collection: "market-negotiations",
|
||
id: negotiation.id,
|
||
data,
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
|
||
await expireNegotiationsForListing(payload, listing.id, assetName);
|
||
|
||
await notifyUser(payload, {
|
||
userId: buyerId,
|
||
type: "market:accept",
|
||
title: "Deal closed",
|
||
message: `You bought ${quantity}× ${assetName} for ${total.toLocaleString()} (${agreedPrice.toLocaleString()} each).`,
|
||
link: "/logistics/market",
|
||
});
|
||
|
||
if (sellerId != null) {
|
||
await notifyUser(payload, {
|
||
userId: sellerId,
|
||
type: "market:sold",
|
||
title: "Your listing sold",
|
||
message: `${quantity}× ${assetName} sold for ${total.toLocaleString()} (${agreedPrice.toLocaleString()} each) after negotiation.`,
|
||
link: "/logistics/market",
|
||
});
|
||
}
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketOfferAccept,
|
||
message: `Offer of ${agreedPrice.toLocaleString()} accepted for ${quantity}× ${assetName}`,
|
||
actor: buyerId,
|
||
targetCollection: "market-negotiations",
|
||
targetId: negotiation.id,
|
||
data: { listingId: listing.id, assetName, quantity, agreedPrice, total },
|
||
});
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketSale,
|
||
message: `Purchased ${quantity}× ${assetName} for ${total.toLocaleString()} (negotiated)`,
|
||
actor: buyerId,
|
||
targetCollection: "market-listings",
|
||
targetId: listing.id,
|
||
data: {
|
||
listingId: listing.id,
|
||
assetName,
|
||
quantity,
|
||
price: listing.price,
|
||
total,
|
||
unitPrice: agreedPrice,
|
||
transactionId: result.transactionId,
|
||
},
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Auto-resolve a buyer's offer against an NPC vendor listing, mutating the
|
||
* negotiation to reflect the vendor's response. The vendor either accepts
|
||
* (offer at/above the asking price), counters while advancing its patience
|
||
* meter, or closes the negotiation once patience is exhausted.
|
||
*/
|
||
async function resolveNpcOffer(
|
||
payload: Awaited<ReturnType<typeof getPayload>>,
|
||
negotiation: MarketNegotiation,
|
||
listing: MarketListing,
|
||
amount: number,
|
||
state: NpcState,
|
||
): Promise<"accepted" | "closed" | "countered"> {
|
||
const buyer = negotiation.buyer as User;
|
||
if (!buyer || typeof buyer === "number") {
|
||
throw new Error("Negotiation buyer is unavailable.");
|
||
}
|
||
|
||
const response = npcResponseToOffer(listing, amount, state);
|
||
|
||
if (response.action === "accept") {
|
||
await finalizeNegotiation(
|
||
payload,
|
||
negotiation,
|
||
listing,
|
||
buyer,
|
||
response.amount,
|
||
npcDialogue(response.reason, response.amount, listing.price),
|
||
);
|
||
return "accepted";
|
||
}
|
||
|
||
const history = [...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>)];
|
||
history.push({
|
||
amount: response.amount,
|
||
from: "seller",
|
||
note: response.closed
|
||
? npcDialogue("closed", response.amount, listing.price)
|
||
: npcDialogue(response.reason, response.amount),
|
||
at: new Date().toISOString(),
|
||
});
|
||
|
||
await payload.update({
|
||
collection: "market-negotiations",
|
||
id: negotiation.id,
|
||
data: {
|
||
amount: response.amount,
|
||
proposedBy: "seller",
|
||
patience: response.patience,
|
||
status: response.closed ? "closed" : "open",
|
||
history,
|
||
},
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
|
||
if (response.closed) {
|
||
await notifyUser(payload, {
|
||
userId: buyer.id as number,
|
||
type: "market:closed",
|
||
title: "Negotiations closed",
|
||
message: `The vendor's patience ran out on ${assetNameOf(listing)}. You can still buy it at the asking price of ${(listing.price ?? 0).toLocaleString()} each.`,
|
||
link: "/logistics/market",
|
||
});
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketOfferClosed,
|
||
message: `Vendor patience exhausted on ${assetNameOf(listing)} (${response.patience}%)`,
|
||
actor: buyer.id as number,
|
||
targetCollection: "market-negotiations",
|
||
targetId: negotiation.id,
|
||
data: {
|
||
listingId: listing.id,
|
||
quantity: negotiation.quantity ?? (listing.quantity ?? 1),
|
||
amount,
|
||
counter: response.amount,
|
||
patience: response.patience,
|
||
},
|
||
});
|
||
return "closed";
|
||
}
|
||
|
||
await notifyUser(payload, {
|
||
userId: buyer.id as number,
|
||
type: "market:counter",
|
||
title: "The vendor countered",
|
||
message: `${assetNameOf(listing)} — vendor proposes ${response.amount.toLocaleString()} each.`,
|
||
link: "/logistics/market",
|
||
});
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketOfferCounter,
|
||
message: `Vendor countered ${amount.toLocaleString()} → ${response.amount.toLocaleString()} on ${assetNameOf(listing)}`,
|
||
actor: buyer.id as number,
|
||
targetCollection: "market-negotiations",
|
||
targetId: negotiation.id,
|
||
data: {
|
||
listingId: listing.id,
|
||
quantity: negotiation.quantity ?? (listing.quantity ?? 1),
|
||
amount,
|
||
counter: response.amount,
|
||
patience: response.patience,
|
||
},
|
||
});
|
||
return "countered";
|
||
}
|
||
|
||
export async function createMarketListing(input: {
|
||
assetId: number;
|
||
quantity: number;
|
||
price: number;
|
||
minPrice?: number;
|
||
}): Promise<ActionResult<MarketListing>> {
|
||
try {
|
||
const { payload, user } = await authenticate();
|
||
const userId = user.id as number;
|
||
|
||
if (!Number.isInteger(input.quantity) || input.quantity <= 0) {
|
||
return { success: false, error: "Quantity must be a positive integer." };
|
||
}
|
||
if (!Number.isFinite(input.price) || input.price <= 0) {
|
||
return { success: false, error: "Price must be greater than zero." };
|
||
}
|
||
if (
|
||
input.minPrice != null &&
|
||
(!Number.isFinite(input.minPrice) || input.minPrice <= 0 || input.minPrice > input.price)
|
||
) {
|
||
return {
|
||
success: false,
|
||
error: "Minimum price must be between 0 and the asking price.",
|
||
};
|
||
}
|
||
|
||
const asset = await getAssetOrThrow(payload, input.assetId);
|
||
if (asset.tradeable !== true) {
|
||
return { success: false, error: `"${asset.name}" is not tradeable on the market.` };
|
||
}
|
||
if (asset.isLive !== true) {
|
||
return { success: false, error: `"${asset.name}" is not approved for market use yet.` };
|
||
}
|
||
|
||
const locker = await getLockerWithItems(payload, userId);
|
||
const available = countCleanQuantity(locker, asset.id);
|
||
if (available < input.quantity) {
|
||
return {
|
||
success: false,
|
||
error: `Only ${available.toLocaleString()}× ${asset.name} available to sell (items with attachments or skins cannot be listed).`,
|
||
};
|
||
}
|
||
|
||
const currencyId = await getMainCurrencyId(payload);
|
||
|
||
const updatedItems = deductLockerQuantity(locker, asset.id, input.quantity);
|
||
await payload.update({
|
||
collection: "locker-storages",
|
||
id: locker.id,
|
||
data: { items: updatedItems },
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
|
||
const listing = (await payload.create({
|
||
collection: "market-listings",
|
||
data: {
|
||
asset: asset.id,
|
||
seller: userId,
|
||
quantity: input.quantity,
|
||
price: input.price,
|
||
desiredPrice: input.price,
|
||
minPrice: input.minPrice ?? null,
|
||
currency: currencyId ?? null,
|
||
status: "active",
|
||
listedAt: new Date().toISOString(),
|
||
expiresAt: new Date(Date.now() + USER_LISTING_DURATION_MS).toISOString(),
|
||
},
|
||
overrideAccess: true,
|
||
depth: 1,
|
||
})) as unknown as MarketListing;
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketListingCreate,
|
||
message: `Listed ${input.quantity}× ${asset.name} for ${input.price.toLocaleString()} each`,
|
||
actor: userId,
|
||
targetCollection: "market-listings",
|
||
targetId: listing.id,
|
||
data: {
|
||
assetId: asset.id,
|
||
assetName: asset.name,
|
||
quantity: input.quantity,
|
||
price: input.price,
|
||
minPrice: input.minPrice ?? null,
|
||
},
|
||
});
|
||
|
||
return { success: true, data: listing };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Unknown error";
|
||
return message === "Unauthorized"
|
||
? { success: false, error: "You must be logged in." }
|
||
: { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
export async function cancelMarketListing(listingId: number): Promise<ActionResult> {
|
||
try {
|
||
const { payload, user } = await authenticate();
|
||
const userId = user.id as number;
|
||
const listing = await getListingOrThrow(payload, listingId);
|
||
|
||
if (listing.status !== "active") {
|
||
return { success: false, error: "Only active listings can be cancelled." };
|
||
}
|
||
const sellerId = sellerIdOf(listing);
|
||
if (sellerId !== userId && !isMarketManager(user)) {
|
||
return { success: false, error: "You can only cancel your own listings." };
|
||
}
|
||
if (listing.isAutoGenerated) {
|
||
return { success: false, error: "Auto-generated market entries cannot be cancelled." };
|
||
}
|
||
if (sellerId == null) {
|
||
return { success: false, error: "This listing has no seller." };
|
||
}
|
||
|
||
const asset = listing.asset as Asset;
|
||
const assetName =
|
||
typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`;
|
||
|
||
await creditLockerQuantity(payload, sellerId, resolveAssetId(listing.asset) as number, listing.quantity);
|
||
|
||
await payload.update({
|
||
collection: "market-listings",
|
||
id: listingId,
|
||
data: { status: "cancelled" },
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
|
||
await expireNegotiationsForListing(payload, listingId, assetName, {
|
||
reason: "was cancelled",
|
||
});
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketListingCancel,
|
||
message: `Cancelled listing for ${listing.quantity}× ${assetName}`,
|
||
actor: userId,
|
||
targetCollection: "market-listings",
|
||
targetId: listingId,
|
||
data: { assetId: listing.asset, assetName, quantity: listing.quantity },
|
||
});
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Unknown error";
|
||
return message === "Unauthorized"
|
||
? { success: false, error: "You must be logged in." }
|
||
: { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
export async function buyMarketListing(
|
||
listingId: number,
|
||
quantity?: number,
|
||
): Promise<ActionResult<MarketListing>> {
|
||
try {
|
||
const { payload, user } = await authenticate();
|
||
const userId = user.id as number;
|
||
|
||
const listing = await getListingOrThrow(payload, listingId);
|
||
if (listing.status !== "active") {
|
||
return { success: false, error: "This listing is no longer active." };
|
||
}
|
||
|
||
const qty = quantity ?? (listing.quantity ?? 1);
|
||
if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) {
|
||
return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` };
|
||
}
|
||
|
||
const asset = listing.asset as Asset;
|
||
const assetName =
|
||
typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`;
|
||
const total = (listing.price ?? 0) * qty;
|
||
|
||
const result = await buyListing(payload, listing, user as User, { quantity: qty });
|
||
|
||
await expireNegotiationsForListing(payload, listingId, assetName);
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketSale,
|
||
message: `Purchased ${qty}× ${assetName} for ${total.toLocaleString()}`,
|
||
actor: userId,
|
||
targetCollection: "market-listings",
|
||
targetId: listingId,
|
||
data: {
|
||
listingId,
|
||
assetId: listing.asset,
|
||
assetName,
|
||
quantity: qty,
|
||
price: listing.price,
|
||
total,
|
||
transactionId: result.transactionId,
|
||
},
|
||
});
|
||
|
||
return { success: true, data: result.listing };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Unknown error";
|
||
return message === "Unauthorized"
|
||
? { success: false, error: "You must be logged in." }
|
||
: { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open (or continue) a negotiation on an active listing. For NPC vendor
|
||
* listings one persistent thread per listing+buyer carries the vendor's
|
||
* patience meter; the offer is auto-resolved immediately (accept / counter /
|
||
* close). For player listings the seller is notified and may respond.
|
||
*/
|
||
export async function makeMarketOffer(
|
||
listingId: number,
|
||
amount: number,
|
||
quantity?: number,
|
||
): Promise<ActionResult<MarketNegotiation>> {
|
||
try {
|
||
const { payload, user } = await authenticate();
|
||
const userId = user.id as number;
|
||
|
||
const listing = await getListingOrThrow(payload, listingId);
|
||
if (listing.status !== "active") {
|
||
return { success: false, error: "This listing is no longer active." };
|
||
}
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
return { success: false, error: "Offer must be greater than zero." };
|
||
}
|
||
|
||
const qty = quantity ?? (listing.quantity ?? 1);
|
||
if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) {
|
||
return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` };
|
||
}
|
||
|
||
const sellerId = sellerIdOf(listing);
|
||
if (sellerId === userId) {
|
||
return { success: false, error: "You cannot negotiate with your own listing." };
|
||
}
|
||
|
||
const assetName = assetNameOf(listing);
|
||
const { min, desired } = negotiationRange(listing);
|
||
|
||
let negotiation: MarketNegotiation;
|
||
|
||
if (sellerId == null) {
|
||
// NPC vendor: one persistent thread per listing+buyer holds the patience
|
||
// meter, so a new offer continues (or reopens) the same thread instead of
|
||
// resetting the vendor's patience.
|
||
const existing = await payload.find({
|
||
collection: "market-negotiations",
|
||
where: { and: [{ listing: { equals: listingId } }, { buyer: { equals: userId } }] },
|
||
limit: 1,
|
||
depth: 0,
|
||
overrideAccess: true,
|
||
});
|
||
const thread = existing.docs[0] as unknown as MarketNegotiation | undefined;
|
||
|
||
let npcState: NpcState = { patience: 0, lastCounter: null, lastBuyerOffer: null };
|
||
|
||
if (thread) {
|
||
if (thread.status === "accepted") {
|
||
return { success: false, error: "This negotiation was already completed." };
|
||
}
|
||
if (thread.status === "closed") {
|
||
return {
|
||
success: false,
|
||
error: "The vendor won't negotiate further. Buy at the asking price instead.",
|
||
};
|
||
}
|
||
const higher = enforceHigherBuyerOffer(thread, amount);
|
||
if (higher) return { success: false, error: higher };
|
||
npcState = npcStateOf(thread);
|
||
const history = [...((thread.history ?? []) as NonNullable<MarketNegotiation["history"]>)];
|
||
history.push({ amount, from: "buyer", note: "New offer", at: new Date().toISOString() });
|
||
negotiation = (await payload.update({
|
||
collection: "market-negotiations",
|
||
id: thread.id,
|
||
data: { amount, proposedBy: "buyer", status: "open", quantity: qty, history },
|
||
overrideAccess: true,
|
||
depth: 1,
|
||
})) as unknown as MarketNegotiation;
|
||
} else {
|
||
negotiation = (await payload.create({
|
||
collection: "market-negotiations",
|
||
data: {
|
||
listing: listingId,
|
||
buyer: userId,
|
||
status: "open",
|
||
amount,
|
||
proposedBy: "buyer",
|
||
quantity: qty,
|
||
history: [{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() }],
|
||
},
|
||
overrideAccess: true,
|
||
depth: 1,
|
||
})) as unknown as MarketNegotiation;
|
||
}
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketOffer,
|
||
message: `${amount.toLocaleString()} offer made on ${qty}× ${assetName}`,
|
||
actor: userId,
|
||
targetCollection: "market-negotiations",
|
||
targetId: negotiation.id,
|
||
data: { listingId, assetName, quantity: qty, amount, min, desired },
|
||
});
|
||
|
||
await resolveNpcOffer(payload, negotiation, listing, amount, npcState);
|
||
const refreshed = await getNegotiationOrThrow(payload, negotiation.id);
|
||
return { success: true, data: refreshed };
|
||
}
|
||
|
||
const prior = await payload.find({
|
||
collection: "market-negotiations",
|
||
where: { and: [{ listing: { equals: listingId } }, { buyer: { equals: userId } }] },
|
||
sort: "-createdAt",
|
||
limit: 1,
|
||
depth: 0,
|
||
overrideAccess: true,
|
||
});
|
||
const priorThread = prior.docs[0] as unknown as MarketNegotiation | undefined;
|
||
if (priorThread && priorThread.status === "open") {
|
||
return { success: false, error: "You already have an open offer on this listing." };
|
||
}
|
||
if (priorThread) {
|
||
const higher = enforceHigherBuyerOffer(priorThread, amount);
|
||
if (higher) return { success: false, error: higher };
|
||
}
|
||
|
||
negotiation = (await payload.create({
|
||
collection: "market-negotiations",
|
||
data: {
|
||
listing: listingId,
|
||
buyer: userId,
|
||
status: "open",
|
||
amount,
|
||
proposedBy: "buyer",
|
||
quantity: qty,
|
||
history: [{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() }],
|
||
},
|
||
overrideAccess: true,
|
||
depth: 1,
|
||
})) as unknown as MarketNegotiation;
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketOffer,
|
||
message: `${amount.toLocaleString()} offer made on ${qty}× ${assetName}`,
|
||
actor: userId,
|
||
targetCollection: "market-negotiations",
|
||
targetId: negotiation.id,
|
||
data: { listingId, assetName, quantity: qty, amount, min, desired },
|
||
});
|
||
|
||
const buyerName = (user as { username?: string }).username ?? "A buyer";
|
||
await notifyUser(payload, {
|
||
userId: sellerId,
|
||
type: "market:offer",
|
||
title: "New offer on your listing",
|
||
message: `${buyerName} offers ${amount.toLocaleString()} each for ${qty}× ${assetName} (asking ${listing.price.toLocaleString()}).`,
|
||
link: "/logistics/market",
|
||
});
|
||
|
||
return { success: true, data: negotiation };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Unknown error";
|
||
return message === "Unauthorized"
|
||
? { success: false, error: "You must be logged in." }
|
||
: { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Accept the proposal currently on the table. Only the counterparty of the
|
||
* current proposal may accept (the seller accepts the buyer's offer, the
|
||
* buyer accepts the seller's counteroffer). Completes the sale at the
|
||
* agreed price.
|
||
*/
|
||
export async function acceptMarketOffer(negotiationId: number): Promise<ActionResult<MarketListing>> {
|
||
try {
|
||
const { payload, user } = await authenticate();
|
||
const userId = user.id as number;
|
||
|
||
const negotiation = await getNegotiationOrThrow(payload, negotiationId);
|
||
if (negotiation.status !== "open") {
|
||
return { success: false, error: "This negotiation is no longer open." };
|
||
}
|
||
|
||
const role = partyRoleOf(negotiation, userId);
|
||
if (!role) return { success: false, error: "You are not part of this negotiation." };
|
||
if (role === negotiation.proposedBy) {
|
||
return { success: false, error: "You cannot accept your own proposal." };
|
||
}
|
||
|
||
const listing = negotiationListing(negotiation);
|
||
if (!listing || typeof listing === "number" || listing.status !== "active") {
|
||
return { success: false, error: "This listing is no longer active." };
|
||
}
|
||
|
||
const agreedPrice = negotiation.amount ?? 0;
|
||
if (agreedPrice <= 0) return { success: false, error: "No valid price agreed." };
|
||
|
||
const buyer = negotiation.buyer as User;
|
||
if (!buyer || typeof buyer === "number") {
|
||
return { success: false, error: "Negotiation buyer is unavailable." };
|
||
}
|
||
|
||
const result = await finalizeNegotiation(payload, negotiation, listing, buyer, agreedPrice);
|
||
|
||
return { success: true, data: result.listing };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Unknown error";
|
||
return message === "Unauthorized"
|
||
? { success: false, error: "You must be logged in." }
|
||
: { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reject the proposal currently on the table. The counterparty of the current
|
||
* proposal rejects it; the thread closes and the other side is notified.
|
||
*/
|
||
export async function rejectMarketOffer(negotiationId: number): Promise<ActionResult> {
|
||
try {
|
||
const { payload, user } = await authenticate();
|
||
const userId = user.id as number;
|
||
|
||
const negotiation = await getNegotiationOrThrow(payload, negotiationId);
|
||
if (negotiation.status !== "open") {
|
||
return { success: false, error: "This negotiation is no longer open." };
|
||
}
|
||
|
||
const role = partyRoleOf(negotiation, userId);
|
||
if (!role) return { success: false, error: "You are not part of this negotiation." };
|
||
if (role === negotiation.proposedBy) {
|
||
return { success: false, error: "You cannot reject your own proposal. Withdraw it instead." };
|
||
}
|
||
|
||
const listing = negotiationListing(negotiation);
|
||
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
|
||
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing";
|
||
|
||
await payload.update({
|
||
collection: "market-negotiations",
|
||
id: negotiation.id,
|
||
data: { status: "rejected" },
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
|
||
if (role === "seller") {
|
||
const buyerId = negotiationBuyerId(negotiation);
|
||
if (buyerId != null) {
|
||
await notifyUser(payload, {
|
||
userId: buyerId,
|
||
type: "market:reject",
|
||
title: "Offer declined",
|
||
message: `The seller declined your offer of ${(negotiation.amount ?? 0).toLocaleString()} each for ${assetName}.`,
|
||
link: "/logistics/market",
|
||
});
|
||
}
|
||
}
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketOfferReject,
|
||
message: `Offer of ${(negotiation.amount ?? 0).toLocaleString()} rejected on ${assetName}`,
|
||
actor: userId,
|
||
targetCollection: "market-negotiations",
|
||
targetId: negotiationId,
|
||
data: { listingId, amount: negotiation.amount },
|
||
});
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Unknown error";
|
||
return message === "Unauthorized"
|
||
? { success: false, error: "You must be logged in." }
|
||
: { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Counter the proposal on the table with a new amount. The counterparty of
|
||
* the current proposal counters; the thread stays open. For NPC vendor
|
||
* listings the vendor responds instantly to the buyer's counter.
|
||
*/
|
||
export async function counterMarketOffer(
|
||
negotiationId: number,
|
||
amount: number,
|
||
): Promise<ActionResult<MarketNegotiation>> {
|
||
try {
|
||
const { payload, user } = await authenticate();
|
||
const userId = user.id as number;
|
||
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
return { success: false, error: "Offer must be greater than zero." };
|
||
}
|
||
|
||
const negotiation = await getNegotiationOrThrow(payload, negotiationId);
|
||
if (negotiation.status !== "open") {
|
||
return { success: false, error: "This negotiation is no longer open." };
|
||
}
|
||
|
||
const role = partyRoleOf(negotiation, userId);
|
||
if (!role) return { success: false, error: "You are not part of this negotiation." };
|
||
if (role === negotiation.proposedBy) {
|
||
return { success: false, error: "You cannot counter your own proposal." };
|
||
}
|
||
if (role === "buyer") {
|
||
const higher = enforceHigherBuyerOffer(negotiation, amount);
|
||
if (higher) return { success: false, error: higher };
|
||
}
|
||
|
||
const listing = negotiationListing(negotiation);
|
||
if (!listing || typeof listing === "number" || listing.status !== "active") {
|
||
return { success: false, error: "This listing is no longer active." };
|
||
}
|
||
|
||
const history = [
|
||
...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>),
|
||
];
|
||
history.push({ amount, from: role, note: "Counteroffer", at: new Date().toISOString() });
|
||
|
||
const updated = (await payload.update({
|
||
collection: "market-negotiations",
|
||
id: negotiation.id,
|
||
data: { amount, proposedBy: role, history },
|
||
overrideAccess: true,
|
||
depth: 1,
|
||
})) as unknown as MarketNegotiation;
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketOfferCounter,
|
||
message: `${role === "buyer" ? "Buyer" : "Seller"} countered with ${amount.toLocaleString()} on ${assetNameOf(listing)}`,
|
||
actor: userId,
|
||
targetCollection: "market-negotiations",
|
||
targetId: negotiationId,
|
||
data: { listingId: listing.id, amount },
|
||
});
|
||
|
||
const sellerId = sellerIdOf(listing);
|
||
|
||
if (sellerId == null) {
|
||
// The pre-update thread still carries the vendor's counter (proposedBy
|
||
// "seller"), so derive the state before the buyer's counter replaced it.
|
||
await resolveNpcOffer(payload, updated, listing, amount, npcStateOf(negotiation));
|
||
const refreshed = await getNegotiationOrThrow(payload, negotiationId);
|
||
return { success: true, data: refreshed };
|
||
}
|
||
|
||
const counterpartyId = role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number);
|
||
await notifyUser(payload, {
|
||
userId: counterpartyId,
|
||
type: "market:counter",
|
||
title: "Counteroffer received",
|
||
message: `${role === "buyer" ? "The buyer" : "The seller"} proposes ${amount.toLocaleString()} each for ${assetNameOf(listing)}.`,
|
||
link: "/logistics/market",
|
||
});
|
||
|
||
return { success: true, data: updated };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Unknown error";
|
||
return message === "Unauthorized"
|
||
? { success: false, error: "You must be logged in." }
|
||
: { success: false, error: message };
|
||
}
|
||
}
|
||
|
||
/** Withdraw from a negotiation. Either party may close the thread. */
|
||
export async function cancelMarketOffer(negotiationId: number): Promise<ActionResult> {
|
||
try {
|
||
const { payload, user } = await authenticate();
|
||
const userId = user.id as number;
|
||
|
||
const negotiation = await getNegotiationOrThrow(payload, negotiationId);
|
||
if (negotiation.status !== "open") {
|
||
return { success: false, error: "This negotiation is no longer open." };
|
||
}
|
||
|
||
const role = partyRoleOf(negotiation, userId);
|
||
if (!role) return { success: false, error: "You are not part of this negotiation." };
|
||
|
||
const listing = negotiationListing(negotiation);
|
||
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
|
||
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing";
|
||
const sellerId = typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null;
|
||
|
||
await payload.update({
|
||
collection: "market-negotiations",
|
||
id: negotiation.id,
|
||
data: { status: "cancelled" },
|
||
overrideAccess: true,
|
||
depth: 0,
|
||
});
|
||
|
||
if (role === "buyer" && sellerId != null) {
|
||
await notifyUser(payload, {
|
||
userId: sellerId,
|
||
type: "market:withdrawn",
|
||
title: "Offer withdrawn",
|
||
message: `The buyer withdrew their offer on ${assetName}.`,
|
||
link: "/logistics/market",
|
||
});
|
||
}
|
||
|
||
await emitGameEvent(payload, {
|
||
type: EventTypes.MarketOfferCancel,
|
||
message: `${role === "buyer" ? "Buyer" : "Seller"} withdrew negotiation on ${assetName}`,
|
||
actor: userId,
|
||
targetCollection: "market-negotiations",
|
||
targetId: negotiationId,
|
||
data: { listingId, amount: negotiation.amount },
|
||
});
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "Unknown error";
|
||
return message === "Unauthorized"
|
||
? { success: false, error: "You must be logged in." }
|
||
: { success: false, error: message };
|
||
}
|
||
}
|