1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/market/actions.ts
Z8MB1E 89b8474e23 feat(market): add trading marketplace with listings, haggling, and NPC vendors
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)
2026-08-10 16:05:57 -04:00

974 lines
34 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.

"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 };
}
}