"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 { 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>, assetId: number, ): Promise { 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>, userId: number, ): Promise { 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>, listingId: number, ): Promise { 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>, negotiationId: number, ): Promise { 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; 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; 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>, negotiation: MarketNegotiation, listing: MarketListing, buyer: User, agreedPrice: number, historyNote?: string, ): Promise>> { 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 = { status: "accepted", acceptedPrice: agreedPrice }; if (note) { const history = [ ...((negotiation.history ?? []) as NonNullable), { 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>, 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)]; 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> { 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 { 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> { 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> { 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)]; 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> { 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 { 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> { 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), ]; 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 { 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 }; } }