diff --git a/src/app/(frontend)/logistics/market/actions.ts b/src/app/(frontend)/logistics/market/actions.ts new file mode 100644 index 0000000..6e63958 --- /dev/null +++ b/src/app/(frontend)/logistics/market/actions.ts @@ -0,0 +1,974 @@ +"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 }; + } +} diff --git a/src/app/(frontend)/logistics/market/page.tsx b/src/app/(frontend)/logistics/market/page.tsx new file mode 100644 index 0000000..4687654 --- /dev/null +++ b/src/app/(frontend)/logistics/market/page.tsx @@ -0,0 +1,104 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import type { LockerStorage, MarketListing, MarketNegotiation } from "@/payload-types"; +import { StoreIcon } from "lucide-react"; +import { MarketView } from "@/components/frontend/market/MarketView"; +import hasRoles from "@/utils/access-control/hasRoles"; +import { ensureLockerStorage } from "@/lib/locker"; +import { getMainCurrencyName } from "@/lib/banking"; + +export const metadata = { + title: "Market — Polaris Task Force", +}; + +export default async function MarketPage() { + const payload = await getPayload({ config }); + const { user } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + + const userId = user?.id as number; + + const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const listingsRes = await payload.find({ + collection: "market-listings", + where: { + and: [ + { status: { equals: "active" } }, + { + or: [ + { expiresAt: { exists: false } }, + { expiresAt: { greater_than: dayAgo } }, + ], + }, + ], + }, + sort: "-listedAt", + limit: 500, + depth: 2, + }); + const listings = listingsRes.docs as unknown as MarketListing[]; + + const locker = await ensureLockerStorage(payload, userId).catch(() => null); + const lockerFull = locker + ? ((await payload + .findByID({ collection: "locker-storages", id: locker.id, depth: 1, overrideAccess: true }) + .catch(() => null)) as unknown as LockerStorage | null) + : null; + + const myListings = listings.filter((l) => { + const sellerId = + typeof l.seller === "object" && l.seller ? (l.seller as { id: number }).id : (l.seller as number | null); + return sellerId === userId; + }); + + const negotiationsRes = await payload.find({ + collection: "market-negotiations", + sort: "-updatedAt", + limit: 50, + depth: 2, + }); + const negotiations = (negotiationsRes.docs as unknown as MarketNegotiation[]).filter((n) => { + const buyerId = + typeof n.buyer === "object" && n.buyer ? (n.buyer as { id: number }).id : (n.buyer as number | null); + if (buyerId === userId) return true; + const listing = n.listing as unknown as MarketListing | number | null; + if (typeof listing === "object" && listing) { + const sellerId = + typeof listing.seller === "object" && listing.seller + ? (listing.seller as { id: number }).id + : (listing.seller as number | null); + return sellerId === userId; + } + return false; + }); + + const isManager = user ? hasRoles(["admin", "developer"], user) : false; + const currencyLabel = await getMainCurrencyName(payload); + + return ( +
+
+
+ +

Market

+
+

+ Buy and sell gear with other members, or shop the auto-generated market stock. +

+
+ + +
+ ); +} diff --git a/src/collections/game/GameEventLogs.ts b/src/collections/game/GameEventLogs.ts index d6135eb..de70e87 100644 --- a/src/collections/game/GameEventLogs.ts +++ b/src/collections/game/GameEventLogs.ts @@ -4,6 +4,7 @@ import { isDeveloper } from "@/utils/access-control/isRole"; const TARGET_COLLECTIONS = [ { label: "Game Structures", value: "game-structures" }, { label: "Game Vehicles", value: "game-vehicles" }, + { label: "Game NPCs", value: "game-npcs" }, { label: "Structures", value: "structures" }, { label: "Resources", value: "resources" }, { label: "Assets", value: "assets" }, @@ -20,6 +21,8 @@ const TARGET_COLLECTIONS = [ { label: "Ledger Entries", value: "ledger-entries" }, { label: "Locker Storages", value: "locker-storages" }, { label: "Loadouts", value: "loadouts" }, + { label: "Market Listings", value: "market-listings" }, + { label: "Market Negotiations", value: "market-negotiations" }, ] as const; export const GameEventLogs: CollectionConfig = { diff --git a/src/collections/market/MarketListings.ts b/src/collections/market/MarketListings.ts new file mode 100644 index 0000000..0454dee --- /dev/null +++ b/src/collections/market/MarketListings.ts @@ -0,0 +1,149 @@ +import { CollectionConfig } from "payload"; +import { isDeveloper } from "@/utils/access-control/isRole"; + +export const MarketListings: CollectionConfig = { + slug: "market-listings", + admin: { + group: "Market", + useAsTitle: "id", + defaultColumns: ["id", "asset", "seller", "npc", "quantity", "price", "status", "listedAt"], + listSearchableFields: ["id"], + pagination: { defaultLimit: 50 }, + }, + access: { + read: ({ req }) => !!req.user, + create: ({ req }) => !!req.user, + update: ({ req }) => { + if (!req.user) return false; + if (isDeveloper({ req })) return true; + return { + seller: { equals: req.user.id }, + }; + }, + delete: isDeveloper, + }, + fields: [ + { + name: "asset", + type: "relationship", + relationTo: "assets", + required: true, + admin: { + description: "The asset being offered for sale.", + }, + }, + { + name: "seller", + type: "relationship", + relationTo: "users", + admin: { + description: + "The user who listed this item. Null for auto-generated market entries (NPC stock).", + }, + }, + { + name: "npc", + type: "relationship", + relationTo: "game-npcs", + admin: { + description: + "The NPC stocking this entry. Auto-generated market entries are attributed to an NPC instead of a user.", + }, + }, + { + name: "quantity", + type: "number", + required: true, + min: 1, + defaultValue: 1, + }, + { + name: "price", + type: "number", + required: true, + min: 0, + admin: { + description: "Price per single unit, in the market currency.", + }, + }, + { + name: "desiredPrice", + type: "number", + admin: { + description: + "The seller's target price during negotiations. Defaults to the asking price.", + }, + }, + { + name: "minPrice", + type: "number", + admin: { + description: + "The lowest per-unit price the seller will accept. When set below the asking price, the listing is negotiable.", + }, + }, + { + name: "currency", + type: "relationship", + relationTo: "resources", + admin: { + description: "The currency this listing is priced in. Defaults to the main campaign currency.", + }, + }, + { + name: "status", + type: "select", + required: true, + defaultValue: "active", + options: [ + { label: "Active", value: "active" }, + { label: "Sold", value: "sold" }, + { label: "Cancelled", value: "cancelled" }, + { label: "Expired", value: "expired" }, + ], + index: true, + }, + { + name: "isAutoGenerated", + type: "checkbox", + defaultValue: false, + admin: { + description: + "True for system-generated NPC entries. Auto-generated entries have no seller; proceeds go to the unit treasury.", + }, + }, + { + name: "listedAt", + type: "date", + required: true, + defaultValue: () => new Date().toISOString(), + admin: { + date: { pickerAppearance: "dayAndTime" }, + }, + }, + { + name: "expiresAt", + type: "date", + admin: { + description: + "When this listing expires. Expired user listings have their stock returned to the seller; auto-generated entries simply close.", + date: { pickerAppearance: "dayAndTime" }, + }, + }, + { + name: "soldAt", + type: "date", + admin: { + date: { pickerAppearance: "dayAndTime" }, + }, + }, + { + name: "buyer", + type: "relationship", + relationTo: "users", + admin: { + description: "The user who purchased this listing.", + }, + }, + ], +}; diff --git a/src/collections/market/MarketNegotiations.ts b/src/collections/market/MarketNegotiations.ts new file mode 100644 index 0000000..afa9999 --- /dev/null +++ b/src/collections/market/MarketNegotiations.ts @@ -0,0 +1,155 @@ +import { CollectionConfig, Where } from "payload"; +import type { User } from "@/payload-types"; +import hasRoles from "@/utils/access-control/hasRoles"; +import { isDeveloper } from "@/utils/access-control/isRole"; + +export const MarketNegotiations: CollectionConfig = { + slug: "market-negotiations", + admin: { + group: "Market", + useAsTitle: "id", + defaultColumns: ["id", "listing", "buyer", "amount", "patience", "proposedBy", "status", "updatedAt"], + pagination: { defaultLimit: 50 }, + }, + access: { + read: ({ req }) => { + const user = req.user as User | null; + if (!user) return false; + if (hasRoles(["admin", "developer"], user)) return true; + return { + or: [ + { buyer: { equals: user.id } }, + { "listing.seller": { equals: user.id } }, + ], + } as Where; + }, + create: ({ req }) => !!req.user, + update: ({ req }) => { + const user = req.user as User | null; + if (!user) return false; + if (hasRoles(["admin", "developer"], user)) return true; + return { + or: [ + { buyer: { equals: user.id } }, + { "listing.seller": { equals: user.id } }, + ], + } as Where; + }, + delete: isDeveloper, + }, + fields: [ + { + name: "listing", + type: "relationship", + relationTo: "market-listings", + required: true, + index: true, + admin: { + description: "The market listing this negotiation thread belongs to.", + }, + }, + { + name: "buyer", + type: "relationship", + relationTo: "users", + required: true, + index: true, + admin: { + description: "The player interested in buying the listing.", + }, + }, + { + name: "status", + type: "select", + required: true, + defaultValue: "open", + options: [ + { label: "Open", value: "open" }, + { label: "Accepted", value: "accepted" }, + { label: "Rejected", value: "rejected" }, + { label: "Closed (patience exhausted)", value: "closed" }, + { label: "Cancelled", value: "cancelled" }, + { label: "Expired", value: "expired" }, + ], + index: true, + }, + { + name: "patience", + type: "number", + defaultValue: 0, + min: 0, + max: 100, + admin: { + description: + "NPC vendor patience (0–100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.", + }, + }, + { + name: "quantity", + type: "number", + min: 1, + defaultValue: 1, + admin: { + description: "How many units the buyer wants from the listing.", + }, + }, + { + name: "amount", + type: "number", + required: true, + min: 0, + admin: { + description: + "The per-unit price currently on the table. Proposed by the buyer or the seller depending on the proposal.", + }, + }, + { + name: "proposedBy", + type: "select", + required: true, + defaultValue: "buyer", + options: [ + { label: "Buyer", value: "buyer" }, + { label: "Seller", value: "seller" }, + ], + }, + { + name: "acceptedPrice", + type: "number", + admin: { + description: "The per-unit price agreed when the negotiation is accepted.", + }, + }, + { + name: "history", + type: "array", + fields: [ + { + name: "amount", + type: "number", + required: true, + }, + { + name: "from", + type: "select", + required: true, + options: [ + { label: "Buyer", value: "buyer" }, + { label: "Seller", value: "seller" }, + ], + }, + { + name: "note", + type: "text", + }, + { + name: "at", + type: "date", + required: true, + defaultValue: () => new Date().toISOString(), + }, + ], + }, + ], + timestamps: true, +}; diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx index 80b1e71..949db76 100644 --- a/src/components/frontend/blocks/AppSidebar.tsx +++ b/src/components/frontend/blocks/AppSidebar.tsx @@ -84,6 +84,10 @@ const data = { title: "Banking", url: "/logistics/banking", }, + { + title: "Market", + url: "/logistics/market", + }, ], }, ], diff --git a/src/components/frontend/market/ChatBubbleList.tsx b/src/components/frontend/market/ChatBubbleList.tsx new file mode 100644 index 0000000..3d30d60 --- /dev/null +++ b/src/components/frontend/market/ChatBubbleList.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { ChatBubble } from "@/lib/market/chatBubbles"; +import { cn } from "@/lib/utils"; + +interface ChatBubbleListProps { + bubbles: ChatBubble[]; + /** Label for buyer bubbles ("You" when the viewer is the buyer). */ + buyerLabel?: string; + /** Label for seller bubbles ("You" when the viewer is the seller). */ + sellerLabel?: string; + /** Height limit for the scroll area (Tailwind max-h class). */ + maxHeight?: string; +} + +export function ChatBubbleList({ + bubbles, + buyerLabel = "Buyer", + sellerLabel = "Seller", + maxHeight = "max-h-56", +}: ChatBubbleListProps) { + const endRef = useRef(null); + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [bubbles.length]); + + return ( +
+ {bubbles.map((bubble, index) => ( +
+ + {bubble.from === "buyer" ? buyerLabel : sellerLabel} + + + {bubble.text} + +
+ ))} +
+
+ ); +} diff --git a/src/components/frontend/market/Countdown.tsx b/src/components/frontend/market/Countdown.tsx new file mode 100644 index 0000000..eeffb18 --- /dev/null +++ b/src/components/frontend/market/Countdown.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { ClockIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useNow } from "@/hooks/useNow"; + +function formatCountdown(ms: number): string { + if (ms <= 0) return "Expired"; + const totalSeconds = Math.floor(ms / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (days > 0) { + return `${days}d ${hours}h`; + } + if (hours > 0) { + return `${hours}h ${String(minutes).padStart(2, "0")}m`; + } + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +interface CountdownProps { + expiresAt: string | null | undefined; + className?: string; +} + +export function Countdown({ expiresAt, className }: CountdownProps) { + const now = useNow(); + + if (!expiresAt) return null; + + const ms = new Date(expiresAt).getTime() - now; + const urgent = ms > 0 && ms < 60 * 60 * 1000; + + return ( + + + {formatCountdown(ms)} + + ); +} diff --git a/src/components/frontend/market/CreateListingDialog.tsx b/src/components/frontend/market/CreateListingDialog.tsx new file mode 100644 index 0000000..3205913 --- /dev/null +++ b/src/components/frontend/market/CreateListingDialog.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Asset, LockerStorage } from "@/payload-types"; +import { PlusIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { resolveAssetId } from "@/lib/locker"; +import { createMarketListing } from "@/app/(frontend)/logistics/market/actions"; + +interface CreateListingDialogProps { + locker: LockerStorage | null; + currencyLabel: string | null; +} + +interface SellableAsset { + assetId: number; + name: string; + quantity: number; + tradeable: boolean; + isLive: boolean; + basePrice: number | null; +} + +export function CreateListingDialog({ locker, currencyLabel }: CreateListingDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [assetId, setAssetId] = useState(""); + const [quantity, setQuantity] = useState("1"); + const [price, setPrice] = useState(""); + const [negotiable, setNegotiable] = useState(false); + const [minPrice, setMinPrice] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const sellable = useMemo(() => { + if (!locker?.items) return []; + const map = new Map(); + for (const entry of locker.items) { + const asset = entry.asset as Asset; + if (!asset || typeof asset === "number") continue; + const attachments = entry.equipment?.attachments ?? []; + const skinId = resolveAssetId(entry.equipment?.skin); + if (attachments.length > 0 || skinId != null) continue; + + const existing = map.get(asset.id); + const quantity = entry.quantity ?? 0; + if (existing) { + existing.quantity += quantity; + } else { + map.set(asset.id, { + assetId: asset.id, + name: asset.name, + quantity, + tradeable: asset.tradeable === true, + isLive: asset.isLive === true, + basePrice: typeof asset.marketTrading?.perceivedValue?.baseBuyPrice === "number" + ? asset.marketTrading.perceivedValue.baseBuyPrice + : null, + }); + } + } + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, [locker]); + + const selected = sellable.find((s) => s.assetId === Number(assetId)); + const maxQuantity = selected?.quantity ?? 0; + const hasLockerItems = !!locker?.items?.length; + + function reset() { + setAssetId(""); + setQuantity("1"); + setPrice(""); + setNegotiable(false); + setMinPrice(""); + setError(null); + } + + async function handleSubmit() { + if (!selected) { + setError("Select an item to sell."); + return; + } + if (!selected.tradeable) { + setError(`"${selected.name}" is not tradeable on the market.`); + return; + } + if (!selected.isLive) { + setError(`"${selected.name}" is not approved for the market yet.`); + return; + } + const qty = Number(quantity); + if (!Number.isInteger(qty) || qty <= 0 || qty > maxQuantity) { + setError(`Quantity must be between 1 and ${maxQuantity}.`); + return; + } + const pr = Number(price); + if (!Number.isFinite(pr) || pr <= 0) { + setError("Price must be greater than zero."); + return; + } + let minPr: number | undefined; + if (negotiable) { + minPr = Number(minPrice); + if (!Number.isFinite(minPr) || minPr <= 0 || minPr >= pr) { + setError("Minimum price must be between 0 and the asking price."); + return; + } + } + + setLoading(true); + setError(null); + const res = await createMarketListing({ + assetId: selected.assetId, + quantity: qty, + price: pr, + minPrice: minPr, + }); + if (res.success) { + setOpen(false); + reset(); + router.refresh(); + } else { + setError(res.error ?? "Failed to create listing."); + setLoading(false); + } + } + + return ( + { + setOpen(o); + if (!o) reset(); + }} + > + + + + + + Sell Item + + List items from your locker on the market. Listings last 30 days and stock is + returned if they expire. + + +
+
+ + + {sellable.length === 0 && ( +

+ {hasLockerItems + ? "Your locker items are not approved for trading yet." + : "Nothing in your locker can be listed yet."} +

+ )} +
+ + {selected && ( +
+
+ + setQuantity(e.target.value)} + /> +
+
+ + setPrice(e.target.value)} + placeholder={selected.basePrice != null ? `Suggested ~${selected.basePrice}` : "0"} + /> +
+
+ )} + + {selected && ( +
+ + {negotiable && ( +
+ + setMinPrice(e.target.value)} + placeholder="The lowest you will accept" + /> +

+ Buyers can haggle; the final deal leans toward your asking price. +

+
+ )} +
+ )} + + {selected && selected.basePrice != null && ( +

+ Tip + Resting valuation is {selected.basePrice.toLocaleString()} per unit. +

+ )} + + {error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/src/components/frontend/market/ListingCard.tsx b/src/components/frontend/market/ListingCard.tsx new file mode 100644 index 0000000..76302e8 --- /dev/null +++ b/src/components/frontend/market/ListingCard.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Asset, MarketListing } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item"; +import { getAssetTypeLabel } from "@/lib/locker"; +import { getRarityLabel, isNegotiable } from "@/lib/market"; +import { formatAmount } from "@/lib/banking/format"; +import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react"; +import { buyMarketListing, cancelMarketListing } from "@/app/(frontend)/logistics/market/actions"; +import { Countdown } from "@/components/frontend/market/Countdown"; +import { MakeOfferDialog } from "@/components/frontend/market/MakeOfferDialog"; +import { useNow } from "@/hooks/useNow"; + +interface ListingCardProps { + listing: MarketListing; + currentUserId: number; + currencyLabel: string | null; + canManage: boolean; +} + +function npcLabel(listing: MarketListing): string { + const npc = listing.npc; + if (typeof npc !== "object" || !npc) return "Market vendor"; + const name = (npc as { name?: string }).name; + if (!name) return "Market vendor"; + const occupation = (npc as { occupation?: string }).occupation; + return occupation ? `${name} · ${occupation}` : name; +} + +function sellerName(listing: MarketListing): string { + if (listing.isAutoGenerated) return npcLabel(listing); + const seller = listing.seller; + if (typeof seller === "object" && seller) { + const anySeller = seller as { username?: string; displayName?: string }; + return anySeller.displayName || anySeller.username || `User ${(seller as { id: number }).id}`; + } + return npcLabel(listing); +} + +export function ListingCard({ listing, currentUserId, currencyLabel, canManage }: ListingCardProps) { + const router = useRouter(); + const [busy, setBusy] = useState<"buy" | "cancel" | null>(null); + const [error, setError] = useState(null); + const [qty, setQty] = useState(1); + + const asset = listing.asset as Asset; + const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`; + const rarity = typeof asset === "object" && asset ? getRarityLabel(asset) : null; + const assetType = typeof asset === "object" && asset ? getAssetTypeLabel(asset.assetType) : "Item"; + const stock = listing.quantity ?? 1; + const negotiable = isNegotiable(listing); + const isOwn = (() => { + const sellerId = + typeof listing.seller === "object" && listing.seller + ? (listing.seller as { id: number }).id + : (listing.seller as number | null); + return sellerId === currentUserId; + })(); + const canCancel = canManage || (isOwn && !listing.isAutoGenerated); + const now = useNow(); + const expired = !!listing.expiresAt && new Date(listing.expiresAt).getTime() <= now; + const showActions = !expired; + const selectedQty = Math.max(1, Math.min(stock, qty)); + const selectedTotal = (listing.price ?? 0) * selectedQty; + + async function handleBuy() { + setBusy("buy"); + setError(null); + const res = await buyMarketListing(listing.id, selectedQty); + if (res.success) { + router.refresh(); + } else { + setError(res.error ?? "Purchase failed."); + setBusy(null); + } + } + + async function handleCancel() { + setBusy("cancel"); + setError(null); + const res = await cancelMarketListing(listing.id); + if (res.success) { + router.refresh(); + } else { + setError(res.error ?? "Failed to cancel listing."); + setBusy(null); + } + } + + const thumbnail = + typeof asset === "object" && asset && asset["Asset Metadata"]?.thumbnail + ? (asset["Asset Metadata"].thumbnail as { url?: string; width?: number; height?: number }) + : null; + + return ( + + +
+ {thumbnail?.url ? ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {assetName} + + ) : ( + + + + )} + + {assetType} + {assetName} + +
+ {listing.isAutoGenerated && Vendor} + {negotiable && Negotiable} +
+ + + {rarity ? `${rarity} · ` : ""} + {listing.quantity.toLocaleString()} in stock + +
+ + {formatAmount(listing.price ?? 0, { name: currencyLabel ?? undefined })} / unit + + + {stock > 1 && ( + <> + {selectedQty}/{stock} ·{" "} + + )} + total {formatAmount(selectedTotal, { name: currencyLabel ?? undefined })} + +
+ {error &&

{error}

} +
+ + + from {sellerName(listing)} + + + + {showActions && ( + +
+ {stock > 1 && ( + setQty(Math.max(1, Math.min(stock, Number(e.target.value) || 1)))} + className="h-8 w-16 text-xs" + aria-label={`Quantity to buy (max ${stock})`} + /> + )} + + {negotiable && !isOwn && ( + + )} +
+ {canCancel && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/frontend/market/MakeOfferDialog.tsx b/src/components/frontend/market/MakeOfferDialog.tsx new file mode 100644 index 0000000..a11b4da --- /dev/null +++ b/src/components/frontend/market/MakeOfferDialog.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Asset, MarketListing, MarketNegotiation } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { formatAmount } from "@/lib/banking/format"; +import { + acceptMarketOffer, + cancelMarketOffer, + counterMarketOffer, + makeMarketOffer, +} from "@/app/(frontend)/logistics/market/actions"; +import { HandshakeIcon, XIcon } from "lucide-react"; +import { toast } from "sonner"; +import { PatienceMeter } from "@/components/frontend/market/PatienceMeter"; +import { NpcChat } from "@/components/frontend/market/NpcChat"; + +type OfferOutcome = + | { kind: "pending"; negotiation: MarketNegotiation } + | { kind: "countered"; negotiation: MarketNegotiation } + | { kind: "accepted"; price: number } + | { kind: "rejected" } + | { kind: "closed" }; + +/** Keep the dialog visible this long after a deal closes so the user can read the result. */ +const MIN_ACCEPT_VISIBLE_MS = 3500; + +function outcomeOf(negotiation: MarketNegotiation | null): OfferOutcome | null { + if (!negotiation) return null; + if (negotiation.status === "accepted") { + return { kind: "accepted", price: negotiation.acceptedPrice ?? negotiation.amount ?? 0 }; + } + if (negotiation.status === "rejected") return { kind: "rejected" }; + if (negotiation.status === "open") { + return negotiation.proposedBy === "seller" + ? { kind: "countered", negotiation } + : { kind: "pending", negotiation }; + } + return { kind: "closed" }; +} + +interface MakeOfferDialogProps { + listing: MarketListing; + currentUserId: number; + currencyLabel: string | null; +} + +export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeOfferDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(""); + const [counter, setCounter] = useState(""); + const [qty, setQty] = useState(1); + const [negotiation, setNegotiation] = useState(null); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [loadingThread, setLoadingThread] = useState(false); + const [acceptedAt, setAcceptedAt] = useState(null); + const [nowTick, setNowTick] = useState(() => Date.now()); + + const asset = listing.asset as Asset; + const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`; + const isVendor = listing.isAutoGenerated; + const stock = listing.quantity ?? 1; + const outcome = outcomeOf(negotiation); + const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined }); + const clampedQty = Math.max(1, Math.min(stock, qty)); + + const sinceAccept = acceptedAt != null ? nowTick - acceptedAt : 0; + const acceptLocked = acceptedAt != null && sinceAccept < MIN_ACCEPT_VISIBLE_MS; + const acceptRemaining = acceptLocked + ? Math.ceil((MIN_ACCEPT_VISIBLE_MS - sinceAccept) / 1000) + : 0; + + useEffect(() => { + if (acceptedAt == null) return; + const id = window.setInterval(() => setNowTick(Date.now()), 100); + return () => window.clearInterval(id); + }, [acceptedAt]); + + function reset() { + setAmount(""); + setCounter(""); + setQty(1); + setNegotiation(null); + setBusy(null); + setError(null); + setAcceptedAt(null); + } + + async function fetchThread(): Promise { + try { + const res = await fetch( + `/api/market-negotiations?limit=1&sort=-updatedAt&where[listing][equals]=${listing.id}&where[buyer][equals]=${currentUserId}`, + { cache: "no-store" }, + ); + if (res.ok) { + const data = (await res.json()) as { docs?: MarketNegotiation[] }; + if (data.docs && data.docs.length > 0) { + return data.docs[0]; + } + } + } catch { + // Ignore — the dialog still works for making a fresh offer. + } + return null; + } + + async function loadThread() { + setLoadingThread(true); + try { + const thread = await fetchThread(); + if (thread) setNegotiation(thread); + } finally { + setLoadingThread(false); + } + } + + async function run(action: () => Promise<{ success: boolean; error?: string; data?: unknown }>, key: string) { + setBusy(key); + setError(null); + const res = await action(); + if (res.success) { + if (key === "accept") { + // acceptMarketOffer returns the listing, not the negotiation — refetch + // the thread to pick up the vendor's final dialogue line. Delay the + // refresh so the accepted summary stays on screen (a sold listing + // would otherwise unmount the card and close this dialog instantly). + const thread = await fetchThread(); + if (thread) { + setNegotiation(thread); + } else { + setNegotiation((prev) => + prev ? { ...prev, status: "accepted", acceptedPrice: prev.amount ?? 0 } : prev, + ); + } + const agreed = thread?.amount ?? negotiation?.amount ?? 0; + const boughtQty = thread?.quantity ?? negotiation?.quantity ?? stock; + setAcceptedAt(Date.now()); + toast.success("Deal closed", { + description: `Sold ${boughtQty}× at ${fmt(agreed)} each — total ${fmt(agreed * boughtQty)}. The items are in your locker.`, + }); + window.setTimeout(() => router.refresh(), MIN_ACCEPT_VISIBLE_MS); + setBusy(null); + return; + } else { + const data = res.data as MarketNegotiation | undefined; + if (data && typeof data === "object" && "proposedBy" in data) { + setNegotiation(data); + if (isVendor && data.status === "accepted") { + setAcceptedAt(Date.now()); + const dealQty = data.quantity ?? stock; + toast.success("Deal closed", { + description: `The vendor accepted your offer of ${fmt(data.amount ?? 0)} each for ${dealQty}× — total ${fmt((data.amount ?? 0) * dealQty)}.`, + }); + window.setTimeout(() => router.refresh(), MIN_ACCEPT_VISIBLE_MS); + setBusy(null); + return; + } + } + } + router.refresh(); + } else { + setError(res.error ?? "Something went wrong."); + } + setBusy(null); + } + + function handleOffer() { + const value = Number(amount); + if (!Number.isFinite(value) || value <= 0) { + setError("Enter a valid offer amount."); + return; + } + void run(() => makeMarketOffer(listing.id, value, clampedQty), "offer"); + } + + function handleAccept() { + if (!negotiation) return; + void run(() => acceptMarketOffer(negotiation.id), "accept"); + } + + function handleCounter() { + if (!negotiation) return; + const value = Number(counter); + if (!Number.isFinite(value) || value <= 0) { + setError("Enter a valid counter amount."); + return; + } + void run(() => counterMarketOffer(negotiation.id, value), "counter"); + } + + function handleWithdraw() { + if (!negotiation) return; + void run(() => cancelMarketOffer(negotiation.id), "withdraw"); + } + + return ( + { + // After a deal closes, keep the modal on screen for the minimum + // display window so the user can read the confirmation and NPC line. + if (!o && acceptLocked) return; + setOpen(o); + if (o) { + reset(); + void loadThread(); + } else { + setAcceptedAt(null); + } + }} + > + + + + + + Negotiate — {assetName} + + {isVendor ? "Haggle with the vendor." : "Send an offer to the seller."} Asking{" "} + {fmt(listing.price ?? 0)} per unit. + + + +
+ {isVendor && ( + + )} + + {loadingThread && !outcome && ( +

Checking for an existing offer...

+ )} + + {!outcome && !loadingThread && ( + <> +
+ + setAmount(e.target.value)} + placeholder="0" + /> +
+ {stock > 1 && ( +
+ + + setQty(Math.max(1, Math.min(stock, Number(e.target.value) || 1))) + } + /> +
+ )} +

+ {stock}× in stock · total{" "} + + {fmt((Number(amount) || 0) * clampedQty)} + +

+ {error &&

{error}

} + + )} + + {outcome?.kind === "pending" && ( +
+

+ Offer of {fmt(negotiation?.amount ?? 0)} each + for {negotiation?.quantity ?? stock}× sent. Waiting for the{" "} + {isVendor ? "vendor" : "seller"} to respond. +

+ {error &&

{error}

} +
+ )} + + {outcome?.kind === "countered" && ( +
+

+ The {isVendor ? "vendor" : "seller"} counteroffers{" "} + {fmt(negotiation?.amount ?? 0)}{" "} + each for {negotiation?.quantity ?? stock}× — total{" "} + + {fmt((negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock))} + + . +

+
+ + setCounter(e.target.value)} + /> +
+ {error &&

{error}

} +
+ )} + + {outcome?.kind === "accepted" && ( +
+ + Deal closed + +

+ Sold at {fmt(outcome.price)} each for{" "} + {negotiation?.quantity ?? stock}× — total{" "} + + {fmt(outcome.price * (negotiation?.quantity ?? stock))} + + . The items are in your locker. +

+
+ )} + + {outcome?.kind === "rejected" && ( +

+ The seller declined your offer. The negotiation is closed. +

+ )} + + {outcome?.kind === "closed" && ( +

+ {isVendor && negotiation?.status === "closed" + ? "The vendor is done haggling. You can still buy the item at the asking price." + : "This negotiation has been closed."} +

+ )} +
+ + {isVendor && negotiation && negotiation.status !== "accepted" && ( + + )} + + + {!outcome && ( + + )} + + {outcome?.kind === "pending" && ( + + )} + + {outcome?.kind === "countered" && ( + <> + + + + + )} + + {(outcome?.kind === "accepted" || outcome?.kind === "rejected" || outcome?.kind === "closed") && ( + + )} + +
+
+ ); +} diff --git a/src/components/frontend/market/MarketView.tsx b/src/components/frontend/market/MarketView.tsx new file mode 100644 index 0000000..d9502d4 --- /dev/null +++ b/src/components/frontend/market/MarketView.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { Asset, LockerStorage, MarketListing, MarketNegotiation } from "@/payload-types"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RARITY_LABELS } from "@/lib/market"; +import { ListingCard } from "@/components/frontend/market/ListingCard"; +import { CreateListingDialog } from "@/components/frontend/market/CreateListingDialog"; +import { NegotiationPanel } from "@/components/frontend/market/NegotiationPanel"; +import { SearchIcon } from "lucide-react"; + +const ASSET_TYPE_OPTIONS: { value: string; label: string }[] = [ + { value: "weapon", label: "Weapon" }, + { value: "magazine", label: "Magazine" }, + { value: "optic", label: "Optic" }, + { value: "muzzle", label: "Muzzle" }, + { value: "bipod", label: "Bipod" }, + { value: "uniform", label: "Uniform" }, + { value: "vest", label: "Vest" }, + { value: "backpack", label: "Backpack" }, + { value: "helmet", label: "Helmet" }, + { value: "facewear", label: "Facewear" }, + { value: "nvg", label: "NVG" }, + { value: "radio", label: "Radio" }, + { value: "medical", label: "Medical" }, + { value: "tool", label: "Tool" }, + { value: "explosive", label: "Explosive" }, + { value: "skin", label: "Skin" }, + { value: "miscellaneous", label: "Miscellaneous" }, +]; + +const RARITY_OPTIONS = Object.entries(RARITY_LABELS); + +function assetName(listing: MarketListing): string { + const asset = listing.asset as Asset; + return typeof asset === "object" && asset ? asset.name.toLowerCase() : ""; +} + +function assetTypeOf(listing: MarketListing): string | null { + const asset = listing.asset as Asset; + return typeof asset === "object" && asset ? asset.assetType : null; +} + +function rarityOf(listing: MarketListing): string | null { + const asset = listing.asset as Asset; + return typeof asset === "object" && asset ? (asset.marketTrading?.Rarity ?? null) : null; +} + +interface MarketViewProps { + listings: MarketListing[]; + myListings: MarketListing[]; + negotiations: MarketNegotiation[]; + locker: LockerStorage | null; + currentUserId: number; + currencyLabel: string | null; + isManager: boolean; +} + +export function MarketView({ + listings, + myListings, + negotiations, + locker, + currentUserId, + currencyLabel, + isManager, +}: MarketViewProps) { + const [search, setSearch] = useState(""); + const [rarity, setRarity] = useState("all"); + const [assetType, setAssetType] = useState("all"); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + const myIds = new Set(myListings.map((l) => l.id)); + return listings.filter((listing) => { + if (myIds.has(listing.id)) return false; + if (rarity !== "all" && rarityOf(listing) !== rarity) return false; + if (assetType !== "all" && assetTypeOf(listing) !== assetType) return false; + if (q && !assetName(listing).includes(q)) return false; + return true; + }); + }, [listings, myListings, search, rarity, assetType]); + + return ( +
+
+
+
+ +
+ + setSearch(e.target.value)} + /> +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+

+ For sale +

+ + {filtered.length} listing{filtered.length !== 1 ? "s" : ""} + +
+ {filtered.length === 0 ? ( +

+ No listings match. Try clearing the filters. +

+ ) : ( +
+ {filtered.map((listing) => ( + + ))} +
+ )} +
+ + + + {myListings.length > 0 && ( +
+
+

+ My listings +

+ + {myListings.length} active + +
+
+ {myListings.map((listing) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/frontend/market/NegotiationHistoryDialog.tsx b/src/components/frontend/market/NegotiationHistoryDialog.tsx new file mode 100644 index 0000000..acea842 --- /dev/null +++ b/src/components/frontend/market/NegotiationHistoryDialog.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useState } from "react"; +import type { Asset, MarketListing, MarketNegotiation } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { formatAmount } from "@/lib/banking/format"; +import { buildChatBubbles } from "@/lib/market/chatBubbles"; +import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList"; +import { HistoryIcon, ChevronDownIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const STATUS_LABELS: Record = { + open: "Open", + accepted: "Accepted", + rejected: "Rejected", + closed: "Closed", + cancelled: "Cancelled", + expired: "Expired", +}; + +function listingOf(negotiation: MarketNegotiation): MarketListing | null { + return typeof negotiation.listing === "object" && negotiation.listing + ? (negotiation.listing as MarketListing) + : null; +} + +function assetName(listing: MarketListing | null): string { + if (!listing) return "Unknown item"; + const asset = listing.asset as Asset; + return typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`; +} + +function counterpartyLabel(negotiation: MarketNegotiation, role: "buyer" | "seller"): string { + if (role === "seller") { + const listing = listingOf(negotiation); + if (listing?.isAutoGenerated) { + const npc = listing.npc as unknown; + if (typeof npc === "object" && npc && "name" in (npc as object)) { + return (npc as { name: string }).name; + } + return "Market vendor"; + } + const seller = listing?.seller as unknown; + if (typeof seller === "object" && seller && "username" in (seller as object)) { + return ( + (seller as { username?: string; displayName?: string }).displayName || + (seller as { username?: string }).username || + "Seller" + ); + } + return "Seller"; + } + const buyer = negotiation.buyer as unknown; + if (typeof buyer === "object" && buyer && "username" in (buyer as object)) { + return (buyer as { username?: string }).username ?? "Buyer"; + } + return "Buyer"; +} + +function HistoryRow({ + negotiation, + role, + currencyLabel, +}: { + negotiation: MarketNegotiation; + role: "buyer" | "seller"; + currencyLabel: string | null; +}) { + const [expanded, setExpanded] = useState(false); + const listing = listingOf(negotiation); + const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined }); + + const buyerLabel = role === "buyer" ? "You" : counterpartyLabel(negotiation, "buyer"); + const sellerLabel = role === "seller" ? "You" : counterpartyLabel(negotiation, "seller"); + const counterparty = role === "buyer" ? sellerLabel : buyerLabel; + const date = new Date(negotiation.updatedAt).toLocaleDateString(); + const isOpen = negotiation.status === "open"; + + return ( + + +
+ {assetName(listing)} + + {counterparty} · {date} + +
+
+ {fmt(negotiation.amount ?? 0)} + + {STATUS_LABELS[negotiation.status] ?? negotiation.status} + + +
+
+ {listing && ( + + + + )} +
+ ); +} + +interface NegotiationHistoryDialogProps { + negotiations: MarketNegotiation[]; + currentUserId: number; + currencyLabel: string | null; +} + +export function NegotiationHistoryDialog({ + negotiations, + currentUserId, + currencyLabel, +}: NegotiationHistoryDialogProps) { + const [open, setOpen] = useState(false); + + const sorted = [...negotiations].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + return ( + + + + + + + Negotiation history + + Your offers, counters and vendor conversations. Tap a row to expand the chat. + + +
+ {sorted.length === 0 ? ( +

No negotiations yet.

+ ) : ( + sorted.map((negotiation) => { + const buyerId = + typeof negotiation.buyer === "object" && negotiation.buyer + ? negotiation.buyer.id + : (negotiation.buyer as number); + const role: "buyer" | "seller" = buyerId === currentUserId ? "buyer" : "seller"; + return ( + + ); + }) + )} +
+ + + +
+
+ ); +} diff --git a/src/components/frontend/market/NegotiationPanel.tsx b/src/components/frontend/market/NegotiationPanel.tsx new file mode 100644 index 0000000..52d71ca --- /dev/null +++ b/src/components/frontend/market/NegotiationPanel.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { Asset, MarketListing, MarketNegotiation } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { formatAmount } from "@/lib/banking/format"; +import { + acceptMarketOffer, + cancelMarketOffer, + counterMarketOffer, + rejectMarketOffer, +} from "@/app/(frontend)/logistics/market/actions"; +import { HandshakeIcon } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { PatienceMeter } from "@/components/frontend/market/PatienceMeter"; +import { NegotiationHistoryDialog } from "@/components/frontend/market/NegotiationHistoryDialog"; + +const STATUS_LABELS: Record = { + open: "Open", + accepted: "Accepted", + rejected: "Rejected", + closed: "Closed", + cancelled: "Cancelled", + expired: "Expired", +}; + +function listingOf(negotiation: MarketNegotiation): MarketListing | null { + return typeof negotiation.listing === "object" && negotiation.listing + ? (negotiation.listing as MarketListing) + : null; +} + +function assetName(listing: MarketListing | null): string { + if (!listing) return "Unknown item"; + const asset = listing.asset as Asset; + return typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`; +} + +function counterpartyLabel( + negotiation: MarketNegotiation, + role: "buyer" | "seller", +): string { + if (role === "buyer") { + const listing = listingOf(negotiation); + if (listing?.isAutoGenerated) { + const npc = listing.npc as unknown; + if (typeof npc === "object" && npc && "name" in (npc as object)) { + return (npc as { name: string }).name; + } + return "Market vendor"; + } + const seller = listing?.seller as unknown; + if (typeof seller === "object" && seller && "username" in (seller as object)) { + return (seller as { username?: string; displayName?: string }).displayName || + (seller as { username?: string }).username || + "Seller"; + } + return "Seller"; + } + const buyer = negotiation.buyer as unknown; + if (typeof buyer === "object" && buyer && "username" in (buyer as object)) { + return (buyer as { username?: string }).username ?? "Buyer"; + } + return "Buyer"; +} + +function NegotiationRow({ + negotiation, + role, + currencyLabel, +}: { + negotiation: MarketNegotiation; + role: "buyer" | "seller"; + currencyLabel: string | null; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(null); + const [counterOpen, setCounterOpen] = useState(false); + const [counter, setCounter] = useState(""); + const [error, setError] = useState(null); + + const listing = listingOf(negotiation); + const isOpen = negotiation.status === "open"; + const theirProposal = + (role === "buyer" && negotiation.proposedBy === "seller") || + (role === "seller" && negotiation.proposedBy === "buyer"); + const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined }); + + async function run(action: () => Promise<{ success: boolean; error?: string }>, key: string) { + setBusy(key); + setError(null); + const res = await action(); + if (res.success) { + if (key === "accept") { + const qty = negotiation.quantity ?? listing?.quantity ?? 1; + toast.success("Deal closed", { + description: `Sold ${qty}× at ${fmt(negotiation.amount ?? 0)} each — total ${fmt((negotiation.amount ?? 0) * qty)}.`, + }); + } + router.refresh(); + } else { + setError(res.error ?? "Something went wrong."); + } + setBusy(null); + } + + function handleCounter() { + const value = Number(counter); + if (!Number.isFinite(value) || value <= 0) { + setError("Enter a valid amount."); + return; + } + void run(() => counterMarketOffer(negotiation.id, value), "counter"); + } + + const showActions = isOpen && theirProposal; + + return ( +
+
+
+ {assetName(listing)} + + {counterpartyLabel(negotiation, role)} · {listing?.quantity ?? "?"}× in stock + +
+ {STATUS_LABELS[negotiation.status] ?? negotiation.status} +
+ +
+ {fmt(negotiation.amount ?? 0)} + + {negotiation.quantity ? `${negotiation.quantity}× · ` : ""} + {theirProposal + ? role === "buyer" + ? "their counteroffer" + : "buyer's offer" + : "your offer"} + {negotiation.quantity ? ( + <> + {" · total "} + + {fmt((negotiation.amount ?? 0) * negotiation.quantity)} + + + ) : null} + +
+ + {role === "buyer" && listing?.isAutoGenerated && isOpen && ( + + )} + + {isOpen && theirProposal && ( +
+ + + +
+ )} + + {isOpen && role === "buyer" && !theirProposal && ( + + )} + + {counterOpen && ( +
+ setCounter(e.target.value)} + placeholder="Your counter" + /> + +
+ )} + + {error &&

{error}

} +
+ ); +} + +interface NegotiationPanelProps { + negotiations: MarketNegotiation[]; + currentUserId: number; + currencyLabel: string | null; +} + +export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }: NegotiationPanelProps) { + const open = negotiations.filter((n) => n.status === "open"); + const closed = negotiations.filter((n) => n.status !== "open"); + + if (negotiations.length === 0) return null; + + return ( +
+
+ +

+ Negotiations +

+ {open.length > 0 && ( + + {open.length} open + + )} +
+ +
+
+ + {open.length > 0 && ( +
+ {open.map((negotiation) => { + const role: "buyer" | "seller" = + negotiation.buyer && + (typeof negotiation.buyer === "object" + ? negotiation.buyer.id + : negotiation.buyer) === currentUserId + ? "buyer" + : "seller"; + return ( + + ); + })} +
+ )} + + {closed.length > 0 && ( +
+ {closed.slice(0, 6).map((negotiation) => ( + + {assetName(listingOf(negotiation))} · {STATUS_LABELS[negotiation.status] ?? negotiation.status} + + ))} + {closed.length > 6 && ( + + +{closed.length - 6} more + + )} +
+ )} +
+ ); +} diff --git a/src/components/frontend/market/NpcChat.tsx b/src/components/frontend/market/NpcChat.tsx new file mode 100644 index 0000000..fe9850c --- /dev/null +++ b/src/components/frontend/market/NpcChat.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { Asset, MarketListing, MarketNegotiation } from "@/payload-types"; +import { StoreIcon } from "lucide-react"; +import { buildChatBubbles } from "@/lib/market/chatBubbles"; +import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList"; + +interface NpcChatProps { + listing: MarketListing; + negotiation: MarketNegotiation | null; + currencyLabel: string | null; +} + +export function NpcChat({ listing, negotiation, currencyLabel }: NpcChatProps) { + const asset = listing.asset as Asset; + const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`; + const bubbles = buildChatBubbles(negotiation, listing, currencyLabel); + + return ( +
+
+ + Vendor + — {assetName} +
+ +
+ ); +} diff --git a/src/components/frontend/market/PatienceMeter.tsx b/src/components/frontend/market/PatienceMeter.tsx new file mode 100644 index 0000000..c9e5644 --- /dev/null +++ b/src/components/frontend/market/PatienceMeter.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; + +interface PatienceMeterProps { + patience: number; + className?: string; +} + +const TONES = { + green: "bg-emerald-500/20 [&>[data-slot=progress-indicator]]:bg-emerald-500", + amber: "bg-amber-500/20 [&>[data-slot=progress-indicator]]:bg-amber-500", + red: "bg-red-500/20 [&>[data-slot=progress-indicator]]:bg-red-500", +} as const; + +/** + * The vendor's willingness to keep negotiating (NPC vendors only). Rises with + * every bargaining round and sharply for lowball offers; at 100 the + * negotiation closes and the buyer can only buy at the asking price. + */ +export function PatienceMeter({ patience, className }: PatienceMeterProps) { + const value = Math.max(0, Math.min(100, patience)); + const tone = value >= 80 ? "red" : value >= 50 ? "amber" : "green"; + + return ( +
+
+ Vendor patience + = 80 ? "text-red-400" : "text-muted-foreground")}> + {value}% + +
+ +
+ ); +} diff --git a/src/lib/market/chatBubbles.ts b/src/lib/market/chatBubbles.ts new file mode 100644 index 0000000..7fabcbb --- /dev/null +++ b/src/lib/market/chatBubbles.ts @@ -0,0 +1,83 @@ +import type { Asset, MarketListing, MarketNegotiation } from "@/payload-types"; +import { formatAmount } from "@/lib/banking/format"; +import { npcDialogue } from "@/lib/market/npcDialogue"; + +export interface ChatBubble { + from: "buyer" | "seller"; + text: string; +} + +/** Generic notes written by older code paths before NPC dialogue existed. */ +const GENERIC_NOTES = new Set([ + "Initial offer", + "New offer", + "Counteroffer", + "Vendor counteroffer", + "Vendor patience exhausted", +]); + +/** Deterministic line picker so a fallback line doesn't change between renders. */ +function seededRng(seed: number): () => number { + let s = (seed % 2147483647) + 1; + return () => { + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +/** + * Reconstruct the chat transcript of a negotiation: the vendor greeting (for + * auto-generated vendor listings), the buyer's offers as "How does {amount} + * sound?", and the seller's lines from the thread history `note`. Accept/closed + * fallback lines are appended only for vendor listings that have no dialogue. + */ +export function buildChatBubbles( + negotiation: MarketNegotiation | null, + listing: MarketListing, + currencyLabel: string | null, +): ChatBubble[] { + const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined }); + const asset = listing.asset as Asset | null; + const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`; + const isVendor = listing.isAutoGenerated === true; + + const bubbles: ChatBubble[] = []; + + if (isVendor) { + bubbles.push({ + from: "seller", + text: `Welcome. ${assetName}, ${listing.quantity ?? 1} in stock. I'm looking for ${fmt(listing.price)} each. What's your offer?`, + }); + } + + const history = (negotiation?.history ?? []) as NonNullable; + for (const entry of history) { + if (entry.from === "buyer") { + bubbles.push({ + from: "buyer", + text: `How does ${fmt(entry.amount)} sound?`, + }); + } else { + const note = entry.note ?? ""; + const text = note && !GENERIC_NOTES.has(note) ? note : `How about ${fmt(entry.amount)}?`; + bubbles.push({ from: "seller", text }); + } + } + + if (isVendor) { + const lastSellerNote = [...history].reverse().find((entry) => entry.from === "seller")?.note; + const hasDialogue = lastSellerNote != null && !GENERIC_NOTES.has(lastSellerNote); + const fallbackRng = negotiation ? seededRng(negotiation.id) : Math.random; + + if (negotiation?.status === "accepted" && !hasDialogue) { + bubbles.push({ from: "seller", text: npcDialogue("accept", undefined, undefined, fallbackRng) }); + } else if (negotiation?.status === "closed" && !hasDialogue) { + bubbles.push({ + from: "seller", + text: npcDialogue("closed", undefined, listing.price, fallbackRng), + }); + } + } + + return bubbles; +} diff --git a/src/lib/market/index.ts b/src/lib/market/index.ts new file mode 100644 index 0000000..fa83835 --- /dev/null +++ b/src/lib/market/index.ts @@ -0,0 +1,379 @@ +import type { Payload } from "payload"; +import type { Asset, LockerStorage, MarketListing, User } from "@/payload-types"; +import { + ensureLockerStorage, + findLockerEmptySpot, + getLockerGridDimensions, + getLockerItemFootprint, + lockerIsStackable, + lockerMaxStack, + resolveAssetId, + toLockerGridItems, +} from "@/lib/locker"; +import { + applyTransaction, + ensurePersonalAccount, + getMainCurrencyId, +} from "@/lib/banking"; + +type PayloadType = Awaited>; + +export type ListingStatus = "active" | "sold" | "cancelled" | "expired"; + +export type LockerItemEntry = NonNullable[number]; + +/** User listings last 30 days before stock is returned to the seller. */ +export const USER_LISTING_DURATION_MS = 30 * 24 * 60 * 60 * 1000; +/** Auto-generated NPC entries rotate weekly. */ +export const AUTO_LISTING_DURATION_MS = 7 * 24 * 60 * 60 * 1000; + +export const RARITY_LABELS: Record = { + basic: "Basic", + common: "Common", + uncommon: "Uncommon", + rare: "Rare", + veryRare: "Very Rare", + epic: "Epic", + legendary: "Legendary", + unique: "Unique", +}; + +export function getRarityLabel(asset: Asset): string | null { + const rarity = asset.marketTrading?.Rarity; + return rarity ? (RARITY_LABELS[rarity] ?? rarity) : null; +} + +export function getBaseBuyPrice(asset: Asset): number | null { + const value = asset.marketTrading?.perceivedValue?.baseBuyPrice; + return typeof value === "number" && value > 0 ? value : null; +} + +export function getBaseSellPrice(asset: Asset): number | null { + const value = asset.marketTrading?.perceivedValue?.baseSellPrice; + return typeof value === "number" && value > 0 ? value : null; +} + +export function listingTotal(listing: Pick): number { + return (listing.price ?? 0) * (listing.quantity ?? 1); +} + +export interface NegotiationRange { + /** Lowest per-unit price the seller will accept. */ + min: number; + /** Price the seller is hoping to achieve (defaults to the asking price). */ + desired: number; +} + +/** Resolve a listing's negotiation range. Non-negotiable listings have min === desired. */ +export function negotiationRange(listing: Pick): NegotiationRange { + const desired = listing.desiredPrice ?? listing.price ?? 0; + const min = listing.minPrice ?? desired; + return { min, desired }; +} + +/** A listing is negotiable when the seller has set a floor below the asking price. */ +export function isNegotiable(listing: Pick): boolean { + const { min, desired } = negotiationRange(listing); + return min < desired; +} + +/** An entry is sellable if it carries no attachments or applied skin (those would be lost on sale). */ +export function isEntryClean(entry: LockerItemEntry): boolean { + const attachments = entry.equipment?.attachments ?? []; + const skinId = resolveAssetId(entry.equipment?.skin); + return attachments.length === 0 && skinId == null; +} + +/** Total clean (sellable) quantity of an asset across a locker. */ +export function countCleanQuantity(locker: LockerStorage, assetId: number): number { + return (locker.items ?? []).reduce((sum, entry) => { + if (resolveAssetId(entry.asset) !== assetId) return sum; + if (!isEntryClean(entry)) return sum; + return sum + (entry.quantity ?? 0); + }, 0); +} + +/** + * Remove `quantity` of `assetId` from the locker, consuming clean entries + * only. Throws if the locker does not hold enough clean stock. + */ +export function deductLockerQuantity( + locker: LockerStorage, + assetId: number, + quantity: number, +): LockerItemEntry[] { + const items = [...(locker.items ?? [])] as LockerItemEntry[]; + let remaining = quantity; + + const result: LockerItemEntry[] = []; + for (const entry of items) { + if (resolveAssetId(entry.asset) !== assetId || !isEntryClean(entry) || remaining <= 0) { + result.push(entry); + continue; + } + const take = Math.min(entry.quantity, remaining); + remaining -= take; + if (entry.quantity - take > 0) { + result.push({ ...entry, quantity: entry.quantity - take }); + } + } + + if (remaining > 0) { + throw new Error("Not enough sellable stock in locker for this listing."); + } + + return result; +} + +/** + * Add `quantity` of `assetId` to a user's locker, merging into existing + * stacks and filling empty grid spots. Throws if there is no space. + */ +export async function creditLockerQuantity( + payload: PayloadType, + userId: number, + assetId: number, + quantity: number, +): Promise { + const locker = await ensureLockerStorage(payload, userId); + const lockerFull = (await payload.findByID({ + collection: "locker-storages", + id: locker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + + const asset = (await payload.findByID({ + collection: "assets", + id: assetId, + depth: 0, + overrideAccess: true, + })) as unknown as Asset; + if (!asset) throw new Error("Asset not found."); + + const grid = await getLockerGridDimensions(payload); + const items = [...(lockerFull.items ?? [])]; + const gridItems = toLockerGridItems(items); + const stackable = lockerIsStackable(asset); + const maxStack = lockerMaxStack(asset); + const footprint = getLockerItemFootprint(asset, false); + + let remaining = quantity; + + if (stackable) { + for (const entry of gridItems) { + if (remaining <= 0) break; + if (entry.assetId !== assetId) continue; + const space = + maxStack === Infinity + ? remaining + : Math.max(0, Math.min(maxStack - entry.quantity, remaining)); + if (space <= 0) continue; + const idx = items.findIndex((e) => e.id === entry.id); + if (idx >= 0) { + items[idx] = { ...items[idx], quantity: (items[idx].quantity ?? 0) + space }; + remaining -= space; + } + } + } + + while (remaining > 0) { + const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack); + const spot = findLockerEmptySpot(gridItems, footprint.width, footprint.height, grid.width, grid.height); + if (!spot) { + throw new Error( + `No space in buyer's locker for ${asset.name}. Needs ${footprint.width}×${footprint.height} free cells.`, + ); + } + items.push({ + asset: assetId, + quantity: stackAmount, + gridX: spot.x, + gridY: spot.y, + rotated: false, + }); + gridItems.push({ + id: `new-${items.length}`, + assetId, + quantity: stackAmount, + gridX: spot.x, + gridY: spot.y, + rotated: false, + asset, + }); + remaining -= stackAmount; + } + + return (await payload.update({ + collection: "locker-storages", + id: locker.id, + data: { items }, + overrideAccess: true, + depth: 1, + })) as unknown as LockerStorage; +} + +/** Resolve the id of the open unit treasury account, if any. */ +export async function getTreasuryAccountId(payload: PayloadType): Promise { + try { + const result = (await payload.find({ + collection: "bank-accounts", + where: { and: [{ accountType: { equals: "treasury" } }, { status: { equals: "open" } }] }, + limit: 1, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: { id: number }[] }; + return result.docs[0]?.id ?? null; + } catch { + return null; + } +} + +export interface BuyResult { + transactionId: number; + total: number; + unitPrice: number; + quantity: number; + currencyId: number | null; + listing: MarketListing; +} + +/** + * Execute a purchase of an active listing: + * 1. Ensures buyer + seller personal accounts (and treasury for NPC stock). + * 2. Moves funds via `applyTransaction` (`payment` type). + * 3. Credits the buyer's locker with `quantity` units. + * 4. Decrements the listing's stock; the listing is marked sold only when the + * last unit is bought (partial purchases leave it active). + * + * Pass `unitPrice` to buy at an agreed negotiation price instead of the + * listing's asking price, and `quantity` to buy a subset of the stock + * (defaults to the whole listing). + */ +export async function buyListing( + payload: PayloadType, + listing: MarketListing, + buyer: User, + options?: { unitPrice?: number; quantity?: number }, +): Promise { + if (listing.status !== "active") { + throw new Error("This listing is no longer active."); + } + + const asset = listing.asset as Asset; + if (!asset || typeof asset === "number") { + throw new Error("Listing asset is not available."); + } + + const sellerId = + typeof listing.seller === "object" && listing.seller ? listing.seller.id : (listing.seller as number | null); + if (sellerId != null && sellerId === buyer.id) { + throw new Error("You cannot buy your own listing."); + } + + const stock = listing.quantity ?? 1; + const quantity = options?.quantity ?? stock; + if (!Number.isInteger(quantity) || quantity <= 0 || quantity > stock) { + throw new Error(`Quantity must be between 1 and ${stock}.`); + } + + const currencyId = listing.currency + ? typeof listing.currency === "object" && "id" in listing.currency + ? (listing.currency.id as number) + : (listing.currency as number) + : await getMainCurrencyId(payload); + + const unitPrice = options?.unitPrice ?? listing.price; + if (typeof unitPrice !== "number" || unitPrice <= 0) { + throw new Error("Listing price must be greater than zero."); + } + + const total = unitPrice * quantity; + + const buyerAccount = await ensurePersonalAccount(payload, buyer.id as number); + let sellerAccountId: number | undefined; + if (sellerId != null) { + const sellerAccount = await ensurePersonalAccount(payload, sellerId); + sellerAccountId = sellerAccount.id; + } else { + const treasuryId = await getTreasuryAccountId(payload); + if (treasuryId) sellerAccountId = treasuryId; + } + + const transaction = await applyTransaction(payload, { + type: "payment", + fromAccountId: buyerAccount.id, + toAccountId: sellerAccountId, + amount: total, + memo: `Market purchase: ${quantity}× ${asset.name} @ ${unitPrice}`, + actorId: buyer.id as number, + }); + + await creditLockerQuantity(payload, buyer.id as number, asset.id, quantity); + + const remaining = stock - quantity; + const updated = (await payload.update({ + collection: "market-listings", + id: listing.id, + data: + remaining > 0 + ? { quantity: remaining } + : { status: "sold", buyer: buyer.id as number, soldAt: new Date().toISOString() }, + overrideAccess: true, + depth: 1, + })) as unknown as MarketListing; + + return { + transactionId: transaction.id, + total, + unitPrice, + quantity, + currencyId, + listing: updated, + }; +} + +/** + * Expire a listing. User listings have their stock returned to the seller's + * locker; auto-generated entries simply close. Throws if stock cannot be + * returned (e.g. no locker space). + */ +export async function expireListing( + payload: PayloadType, + listing: MarketListing, +): Promise { + const sellerId = + typeof listing.seller === "object" && listing.seller ? listing.seller.id : (listing.seller as number | null); + + if (sellerId != null && !listing.isAutoGenerated) { + await creditLockerQuantity(payload, sellerId, resolveAssetId(listing.asset) as number, listing.quantity); + } + + return (await payload.update({ + collection: "market-listings", + id: listing.id, + data: { status: "expired" }, + overrideAccess: true, + depth: 0, + })) as unknown as MarketListing; +} + +/** + * Price an auto-generated entry as a deviation around the asset's base buy + * price. `kind` mirrors the resting valuation: `buy` prices NPC stock near + * the base buy price, `sell` prices it near the base sell price. + */ +export function autoPrice(asset: Asset, kind: "buy" | "sell" = "buy"): number | null { + const base = kind === "buy" ? getBaseBuyPrice(asset) : getBaseSellPrice(asset); + if (base == null || base <= 0) return null; + const deviation = (Math.random() * 2 - 1) * 0.15; + return Math.max(1, Math.round(base * (1 + deviation))); +} + +/** Suggested auto-generated quantity: a few units for stackables, else 1. */ +export function autoQuantity(asset: Asset): number { + if (lockerIsStackable(asset)) { + return Math.max(1, Math.floor(Math.random() * 3) + 1); + } + return 1; +} diff --git a/src/lib/market/negotiations.ts b/src/lib/market/negotiations.ts new file mode 100644 index 0000000..cb935a2 --- /dev/null +++ b/src/lib/market/negotiations.ts @@ -0,0 +1,200 @@ +import type { MarketListing } from "@/payload-types"; +import { notifyUser } from "@/lib/notifications"; +import { negotiationRange } from "@/lib/market"; +import { npcDialogue, type NpcDialogueKind } from "@/lib/market/npcDialogue"; + +export { npcDialogue, type NpcDialogueKind }; + +type PayloadType = Awaited>; + +export const NPC_PATIENCE = { + /** Patience gained every round the buyer keeps bargaining. */ + roundCost: 12, + /** Extra patience burned when the offer is below the vendor's floor. */ + lowballPenalty: 25, + /** Extra patience burned when the offer barely budges from the previous one. */ + stallPenalty: 35, + /** An offer that moves less than this fraction of the asking price counts as a "stall". */ + stallRatio: 0.01, + /** Patience relieved when the offer is close to the asking price. */ + goodOfferRelief: 9, + /** An offer at or above this fraction of the asking price counts as "good". */ + goodOfferRatio: 0.85, + /** When patience reaches this, the negotiation closes. */ + cap: 100, +} as const; + +export const NPC_ACCEPT = { + /** The vendor concedes this fraction of the gap toward the buyer's offer each round. */ + concedeRatio: 0.3, + /** Offers within this fraction of the vendor's current stance may be accepted outright. */ + marginRatio: 0.02, + /** Chance the vendor accepts an offer inside the margin. */ + chance: 0.85, +} as const; + +export interface NpcState { + /** The vendor's patience meter (0–100). */ + patience?: number; + /** The vendor's most recent counteroffer (null before the first round). */ + lastCounter?: number | null; + /** The buyer offer that drew lastCounter (null before the first round). */ + lastBuyerOffer?: number | null; +} + +export type NpcAcceptReason = "accept" | "good" | "overpay"; + +export type NpcCounterReason = Exclude; + +export type NpcResponse = + | { action: "accept"; amount: number; reason: NpcAcceptReason } + | { + action: "counter"; + amount: number; + patience: number; + closed: boolean; + reason: NpcCounterReason; + }; + +/** + * Auto-response of an NPC vendor to a buyer's offer. + * + * The vendor keeps a private "stance" price that starts at the asking price and + * only ever moves down. Offers at/above the stance are accepted outright; + * offers within a small margin of the stance are accepted with high chance + * (`NPC_ACCEPT.chance`) and otherwise the vendor holds firm. Offers well below + * the stance draw a concession — the stance moves a fraction of the gap toward + * the buyer, but never rises past its previous counter, never drops below the + * vendor's floor (`minPrice`), and the vendor holds firm if the buyer offers + * *less* than their previous offer. Offers that barely budge from the buyer's + * previous offer ("stalls", < 1% of the asking price) burn extra patience. The patience meter + * advances with every round: lowballs burn a lot, reasonable offers cost a + * little, and near-asking offers ease the vendor's mind. At the cap the + * negotiation closes — the buyer can only buy at the asking price. + */ +export function npcResponseToOffer( + listing: Pick, + amount: number, + state: NpcState = {}, + rng: () => number = Math.random, +): NpcResponse { + const { min, desired } = negotiationRange(listing); + const patience = state.patience ?? 0; + const lastCounter = state.lastCounter ?? null; + const lastBuyerOffer = state.lastBuyerOffer ?? null; + + const good = (n: number) => + n >= desired * NPC_PATIENCE.goodOfferRatio ? ("good" as const) : ("accept" as const); + + if (amount >= desired) { + return { action: "accept", amount: desired, reason: amount > desired ? "overpay" : "good" }; + } + + const stance = lastCounter ?? desired; + const margin = Math.round(stance * NPC_ACCEPT.marginRatio); + const movedBackward = lastBuyerOffer != null && amount < lastBuyerOffer; + const stall = + lastBuyerOffer != null && + amount > lastBuyerOffer && + amount - lastBuyerOffer < desired * NPC_PATIENCE.stallRatio; + + if (amount >= stance) return { action: "accept", amount: Math.min(amount, desired), reason: good(amount) }; + if (amount >= stance - margin) { + if (rng() < NPC_ACCEPT.chance) { + return { action: "accept", amount: Math.min(amount, desired), reason: good(amount) }; + } + // Declined inside the margin: hold firm at the current stance. + const nextPatience = advancePatience(patience, amount, min, desired, lastBuyerOffer); + return { + action: "counter", + amount: stance, + patience: nextPatience, + closed: nextPatience >= NPC_PATIENCE.cap, + reason: stall ? "stall" : "firm", + }; + } + + const counter = movedBackward + ? stance + : Math.max(amount, min, Math.round(stance - (stance - amount) * NPC_ACCEPT.concedeRatio)); + + const nextPatience = advancePatience(patience, amount, min, desired, lastBuyerOffer); + return { + action: "counter", + amount: counter, + patience: nextPatience, + closed: nextPatience >= NPC_PATIENCE.cap, + reason: movedBackward ? "backward" : stall ? "stall" : amount < min ? "lowball" : "concede", + }; +} + +function advancePatience( + patience: number, + amount: number, + min: number, + desired: number, + previous: number | null, +): number { + let delta: number; + if (previous != null && amount > previous && amount - previous < desired * NPC_PATIENCE.stallRatio) { + delta = NPC_PATIENCE.roundCost + NPC_PATIENCE.stallPenalty; + } else if (amount < min) { + delta = NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty; + } else if (amount >= desired * NPC_PATIENCE.goodOfferRatio) { + delta = -NPC_PATIENCE.goodOfferRelief; + } else { + delta = NPC_PATIENCE.roundCost; + } + return Math.max(0, Math.min(NPC_PATIENCE.cap, patience + delta)); +} + +export interface ExpireOptions { + /** Reason shown in the buyer notification. */ + reason?: string; +} + +/** + * Close every open negotiation thread on a listing (e.g. when it is sold or + * expired) and notify each interested buyer. + */ +export async function expireNegotiationsForListing( + payload: PayloadType, + listingId: number, + listingName: string, + options?: ExpireOptions, +): Promise { + const open = await payload.find({ + collection: "market-negotiations", + where: { and: [{ listing: { equals: listingId } }, { status: { equals: "open" } }] }, + limit: 100, + depth: 1, + overrideAccess: true, + }); + + const reason = options?.reason ?? "was sold"; + + for (const negotiation of open.docs) { + await payload.update({ + collection: "market-negotiations", + id: negotiation.id, + data: { status: "expired" }, + overrideAccess: true, + depth: 0, + }); + + const buyerId = + typeof negotiation.buyer === "object" && negotiation.buyer + ? negotiation.buyer.id + : (negotiation.buyer as number | null); + + if (buyerId != null) { + await notifyUser(payload, { + userId: buyerId, + type: "market:expired", + title: "Negotiation closed", + message: `${listingName} ${reason} before you could close the deal.`, + link: "/logistics/market", + }); + } + } +} diff --git a/src/lib/market/npcDialogue.ts b/src/lib/market/npcDialogue.ts new file mode 100644 index 0000000..38f5fee --- /dev/null +++ b/src/lib/market/npcDialogue.ts @@ -0,0 +1,75 @@ +export type NpcDialogueKind = + | "firm" + | "lowball" + | "backward" + | "stall" + | "concede" + | "good" + | "overpay" + | "closed" + | "accept"; + +type DialogueLine = (amount: string, asking: string) => string; + +const LINES: Record = { + firm: [ + (amount) => `I'm not going any lower than ${amount}. Take it or leave it.`, + (amount) => `That's my final price — ${amount}. I've got other buyers knocking.`, + (amount) => `I'm holding at ${amount}. My prices are already fair.`, + ], + lowball: [ + (amount) => `That's an insult! I'll come down to ${amount} — not a unit less.`, + (amount) => `You're joking, right? ${amount} and not a unit less.`, + (amount) => `I paid more than that for lunch! ${amount} — final offer.`, + ], + backward: [ + (amount) => `Now you're offering less than before? I'm holding firm at ${amount}.`, + (amount) => `We were making progress and now this? I'm staying at ${amount}.`, + ], + stall: [ + () => "Really? That's hardly different. Negotiate like you mean it.", + () => "A whole dollar more? That's not negotiating, that's teasing. Come back with a real number.", + () => "You're just wearing me down with pennies now. Bring something meaningful.", + ], + concede: [ + (amount) => `Hmm, not sure about that... how about ${amount}?`, + (amount) => `I could stretch to ${amount}... if we're serious about this.`, + (amount) => `Let's not waste each other's time — ${amount}.`, + (amount) => `Alright, I'll bend a little. ${amount}?`, + ], + good: [ + () => "Now we're talking. That's a fair price — you've got a deal.", + () => "You drive a hard bargain, but that's a price I can live with.", + () => "That works for me. A man's gotta make a living, after all.", + ], + overpay: [ + () => "Wait — more than I asked? Sold! You won't regret it.", + () => "Generous! I'd have taken less, but I won't argue. It's yours.", + () => "Whoa, okay! Not going to talk you out of that one. Done deal.", + ], + closed: [ + (_amount, asking) => `Alright, that's enough haggling. Buy it at ${asking} or not at all.`, + (_amount, asking) => `I'm done negotiating. The price is ${asking} — take it or leave it.`, + ], + accept: [ + () => "I think that price is acceptable. Let's shake on it.", + () => "Done. Pleasure doing business with you.", + () => "Deal! Good doing business with you.", + ], +}; + +/** + * Vendor flavour lines for the in-dialog chat. Amounts are formatted plain. + * Pass a custom `rng` (0–1) for deterministic output; otherwise a line is + * picked at random each call. + */ +export function npcDialogue( + kind: NpcDialogueKind, + amount?: number, + asking?: number, + rng: () => number = Math.random, +): string { + const lines = LINES[kind]; + const index = Math.min(lines.length - 1, Math.max(0, Math.floor(rng() * lines.length))); + return lines[index]((amount ?? 0).toLocaleString(), (asking ?? 0).toLocaleString()); +} diff --git a/src/lib/market/npcs.ts b/src/lib/market/npcs.ts new file mode 100644 index 0000000..b074405 --- /dev/null +++ b/src/lib/market/npcs.ts @@ -0,0 +1,213 @@ +import type { GameNpc } from "@/payload-types"; + +type PayloadType = Awaited>; + +export const NPC_FIRST_NAMES = [ + "Marcus", + "Sergei", + "Adrian", + "Marek", + "Viktor", + "Tomas", + "Lukas", + "Nikolai", + "Janek", + "Dominik", + "Emil", + "Petr", + "Karel", + "Milan", + "Oskar", + "Radek", + "Fedor", + "Ivan", + "Andrei", + "Boris", + "Yuri", + "Dmitri", + "Alexei", + "Pavel", + "Stefan", + "Wojtek", + "Jacek", + "Mateusz", + "Piotr", + "Tadeusz", + "Karol", + "Zbigniew", + "Henrik", + "Erling", + "Magnus", + "Anders", + "Lars", + "Mikkel", + "Cedric", + "Antoine", + "Basile", + "Thibault", +] as const; + +export const NPC_LAST_NAMES = [ + "Novak", + "Kovac", + "Havlicek", + "Svoboda", + "Dvorak", + "Cermak", + "Prochazka", + "Jelinek", + "Zeman", + "Kral", + "Barta", + "Fiala", + "Urban", + "Pospisil", + "Kowalski", + "Nowak", + "Wozniak", + "Lewandowski", + "Sokolov", + "Ivanov", + "Petrov", + "Smirnov", + "Volkov", + "Fedorov", + "Morozov", + "Pavlov", + "Kozlov", + "Stepanov", + "Todorov", + "Horvat", + "Markovic", + "Jovanovic", + "Petrovic", + "Berg", + "Larsen", + "Lindqvist", + "Virtanen", + "Fournier", + "Marchand", + "Dupont", +] as const; + +export const NPC_OCCUPATIONS = [ + "Weapons Dealer", + "Gunsmith", + "Arms Dealer", + "Munitions Specialist", + "Ammo Merchant", + "Black Market Trader", + "Quartermaster", + "Supply Officer", + "Field Doctor", + "Medic", + "Apothecary", + "Pharmacist", + "Chef", + "Brewer", + "Blacksmith", + "Mechanic", + "Tailor", + "Carpenter", + "Jeweler", + "Artisan", + "Smuggler", + "Fence", + "Dealer", + "Broker", + "Trader", + "Collector", + "Scavenger", + "Forager", + "Drifter", + "Farmer", + "Fisherman", + "Miner", + "Prospector", + "Radio Operator", +] as const; + +export function randomOf(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** Random "First Last" name for a generated NPC. */ +export function randomNpcName(): string { + return `${randomOf(NPC_FIRST_NAMES)} ${randomOf(NPC_LAST_NAMES)}`; +} + +/** Random occupation for a generated NPC, biased toward market roles. */ +export function randomNpcOccupation(): string { + return randomOf(NPC_OCCUPATIONS); +} + +/** + * Create a randomly-generated NPC placeholder. `isGenerated` is true and + * `isInGame` is false so the GM can later flip them on to include them in + * the game. If `assetId` is given, the NPC is set up as a vendor selling it. + */ +export async function createGeneratedNpc( + payload: PayloadType, + opts: { assetId?: number } = {}, +): Promise { + return (await payload.create({ + collection: "game-npcs", + data: { + name: randomNpcName(), + occupation: randomNpcOccupation(), + isGenerated: true, + isInGame: false, + vendor: { + enabled: true, + sells: opts.assetId ? [opts.assetId] : [], + }, + }, + overrideAccess: true, + depth: 0, + })) as unknown as GameNpc; +} + +/** All enabled vendor NPCs that sell the given asset. */ +export async function findVendorNpcs( + payload: PayloadType, + assetId: number, +): Promise { + const result = await payload.find({ + collection: "game-npcs", + where: { + and: [ + { "vendor.enabled": { equals: true } }, + { "vendor.sells": { equals: assetId } }, + ], + }, + limit: 100, + depth: 0, + overrideAccess: true, + }); + return result.docs as unknown as GameNpc[]; +} + +/** + * Pick an NPC to stock an auto-generated listing for the given asset: + * 1. Prefer NPCs that are officially in the game (GM-assigned vendors). + * 2. Otherwise reuse any existing vendor NPC selling the asset. + * 3. If none exist, generate a new placeholder NPC for the asset. + */ +export async function resolveVendorNpc( + payload: PayloadType, + assetId: number, +): Promise { + const vendors = await findVendorNpcs(payload, assetId); + if (vendors.length > 0) { + const inGame = vendors.filter((npc) => npc.isInGame === true); + const pool = inGame.length > 0 ? inGame : vendors; + return randomOf(pool); + } + return createGeneratedNpc(payload, { assetId }); +} + +/** Apply an NPC's price modifier to an auto-generated price. */ +export function applyNpcPriceModifier(basePrice: number, npc: GameNpc): number { + const modifier = npc.vendor?.priceModifier ?? 1; + return Math.max(1, Math.round(basePrice * modifier)); +} diff --git a/src/payload.config.ts b/src/payload.config.ts index 14b1433..eee07ac 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -38,6 +38,8 @@ import { BankTransactions } from "@/collections/banking/BankTransactions"; import { LedgerEntries } from "@/collections/banking/LedgerEntries"; import { LockerStorages } from "@/collections/locker/LockerStorages"; import { Loadouts } from "@/collections/locker/Loadouts"; +import { MarketListings } from "@/collections/market/MarketListings"; +import { MarketNegotiations } from "@/collections/market/MarketNegotiations"; import { UserNotifications } from "@/collections/users/UserNotifications"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import nodemailer from "nodemailer"; @@ -114,6 +116,10 @@ export default buildConfig({ LockerStorages, Loadouts, + // Market + MarketListings, + MarketNegotiations, + // World Maps, NarrativeEvents, @@ -139,6 +145,10 @@ export default buildConfig({ scriptPath: path.resolve(dirname, "scripts/gameTick.ts"), key: "game-tick", }, + { + scriptPath: path.resolve(dirname, "scripts/marketTick.ts"), + key: "market-tick", + }, ], plugins: [ // payloadCloudPlugin(), diff --git a/src/scripts/marketTick.ts b/src/scripts/marketTick.ts new file mode 100644 index 0000000..ac9382e --- /dev/null +++ b/src/scripts/marketTick.ts @@ -0,0 +1,173 @@ +import type { SanitizedConfig } from "payload"; +import { getPayload } from "payload"; +import type { Asset, MarketListing } from "@/payload-types"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; +import { + AUTO_LISTING_DURATION_MS, + autoPrice, + autoQuantity, + expireListing, +} from "@/lib/market"; +import { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs"; +import { expireNegotiationsForListing } from "@/lib/market/negotiations"; +import { resolveAssetId } from "@/lib/locker"; +import { getMainCurrencyId } from "@/lib/banking"; + +export const script = async (config: SanitizedConfig) => { + const payload = await getPayload({ config }); + + payload.logger.info("[Market Tick] Starting market tick..."); + + const now = new Date(); + const nowIso = now.toISOString(); + + // 1. Expire overdue listings (returns stock to sellers for user listings). + const expiring = await payload.find({ + collection: "market-listings", + where: { + and: [ + { status: { equals: "active" } }, + { expiresAt: { less_than_equal: nowIso } }, + ], + }, + limit: 200, + depth: 1, + sort: "expiresAt", + }); + + let expiredCount = 0; + let expiredUserCount = 0; + for (const listing of expiring.docs) { + const sellerId = + typeof listing.seller === "object" && listing.seller + ? (listing.seller as { id: number }).id + : (listing.seller as number | null); + const isUserListing = sellerId != null && !listing.isAutoGenerated; + try { + await expireListing(payload, listing as unknown as MarketListing); + expiredCount++; + if (isUserListing) expiredUserCount++; + const assetName = + typeof listing.asset === "object" && listing.asset + ? listing.asset.name + : `asset #${resolveAssetId(listing.asset)}`; + await expireNegotiationsForListing(payload, listing.id, assetName, { + reason: "expired before you could close the deal", + }); + await emitGameEvent(payload, { + type: EventTypes.MarketListingExpire, + message: `Listing for ${listing.quantity}× ${assetName} expired${ + isUserListing ? " — stock returned to seller" : "" + }`, + actor: sellerId ?? undefined, + targetCollection: "market-listings", + targetId: listing.id, + data: { assetId: listing.asset, assetName, quantity: listing.quantity, autoGenerated: listing.isAutoGenerated }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + payload.logger.error(`[Market Tick] Error expiring listing ${listing.id}: ${message}`); + } + } + + // 2. Top up auto-generated entries for tradeable live assets with no active listing. + const active = await payload.find({ + collection: "market-listings", + where: { status: { equals: "active" } }, + limit: 2000, + depth: 0, + }); + const activeAssetIds = new Set(active.docs.map((d) => resolveAssetId(d.asset))); + + const assets = await payload.find({ + collection: "assets", + where: { + and: [ + { tradeable: { equals: true } }, + { isLive: { equals: true } }, + ], + }, + limit: 2000, + depth: 0, + sort: "name", + }); + + const currencyId = await getMainCurrencyId(payload); + let generatedCount = 0; + let generatedNpcCount = 0; + for (const asset of assets.docs as unknown as Asset[]) { + if (activeAssetIds.has(asset.id)) continue; + const basePrice = autoPrice(asset, "buy"); + if (basePrice == null) continue; + + // Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs, + // fall back to generating a placeholder NPC when none sell this good. + const npc = await resolveVendorNpc(payload, asset.id); + if (npc.isGenerated === true) generatedNpcCount++; + const price = applyNpcPriceModifier(basePrice, npc); + if (price <= 0) continue; + + const quantity = autoQuantity(asset); + const expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString(); + const minPrice = Math.max(1, Math.round(price * 0.6)); + try { + await payload.create({ + collection: "market-listings", + data: { + asset: asset.id, + seller: null, + npc: npc.id, + quantity, + price, + desiredPrice: price, + minPrice, + currency: currencyId ?? null, + status: "active", + isAutoGenerated: true, + listedAt: nowIso, + expiresAt, + }, + overrideAccess: true, + depth: 0, + }); + generatedCount++; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + payload.logger.error(`[Market Tick] Error generating listing for asset ${asset.id}: ${message}`); + } + } + + await emitGameEvent(payload, { + type: EventTypes.MarketAutoGenerate, + message: `Market tick: expired ${expiredCount} listing(s), generated ${generatedCount} new market entry/entries${generatedNpcCount > 0 ? ` (${generatedNpcCount} new NPC${generatedNpcCount !== 1 ? "s" : ""} generated)` : ""}`, + data: { + expiredCount, + generatedCount, + generatedNpcCount, + activeListings: active.docs.length, + }, + }); + + // 3. Notify connected clients so the market refreshes. + const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, ""); + const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET; + if (notifySecret) { + await fetch(`${notifyUrl}/api/game-tick/notify`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-game-tick-secret": notifySecret, + }, + body: JSON.stringify({ processedAt: nowIso, source: "market-tick" }), + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown error"; + payload.logger.error(`[Market Tick] Failed to notify app clients: ${message}`); + }); + } + + payload.logger.info( + `[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`, + ); + process.exit(0); +}; diff --git a/src/utils/event-log/emit.ts b/src/utils/event-log/emit.ts index 5f5dc38..b4587d1 100644 --- a/src/utils/event-log/emit.ts +++ b/src/utils/event-log/emit.ts @@ -5,7 +5,7 @@ interface GameEventInput { message: string; actor?: number; structure?: number; - targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries" | "locker-storages" | "loadouts"; + targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "game-npcs" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries" | "locker-storages" | "loadouts" | "market-listings" | "market-negotiations"; targetId?: number; data?: Record; } diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index e705076..f64bb2d 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -59,6 +59,22 @@ export const EventTypes = { // Minigames MinigameFlappy: "minigame:flappy", + + // Market + MarketListingCreate: "market:listing-create", + MarketListingCancel: "market:listing-cancel", + MarketListingExpire: "market:listing-expire", + MarketSale: "market:sale", + MarketAutoGenerate: "market:auto-generate", + MarketOffer: "market:offer", + MarketOfferAccept: "market:offer-accept", + MarketOfferReject: "market:offer-reject", + MarketOfferCounter: "market:offer-counter", + MarketOfferCancel: "market:offer-cancel", + MarketOfferClosed: "market:offer-closed", + + // NPCs + NpcGenerate: "npc:generate", } as const; export type EventType = (typeof EventTypes)[keyof typeof EventTypes]; diff --git a/tests/int/market.int.spec.ts b/tests/int/market.int.spec.ts new file mode 100644 index 0000000..e79a548 --- /dev/null +++ b/tests/int/market.int.spec.ts @@ -0,0 +1,936 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; + +import { beforeAll, afterAll, describe, expect, it } from "vitest"; +import type { Asset, BankAccount, GameNpc, LockerStorage, MarketListing, User } from "@/payload-types"; +import { + autoPrice, + autoQuantity, + buyListing, + countCleanQuantity, + creditLockerQuantity, + deductLockerQuantity, + expireListing, + isEntryClean, + isNegotiable, + listingTotal, + negotiationRange, +} from "@/lib/market"; +import { npcDialogue, npcResponseToOffer, NPC_PATIENCE } from "@/lib/market/negotiations"; +import { + applyNpcPriceModifier, + createGeneratedNpc, + randomNpcName, + resolveVendorNpc, +} from "@/lib/market/npcs"; +import { ensureLockerStorage, resolveAssetId } from "@/lib/locker"; +import { applyTransaction, createAccount, getMainCurrencyId } from "@/lib/banking"; +import { notifyUser } from "@/lib/notifications"; + +let payload: Payload; + +const RUN = `mt-${Date.now().toString(36)}`; + +describe("Marketplace", () => { + let assetId: number; + let seller: User; + let buyer: User; + let sellerLocker: LockerStorage; + let buyerLocker: LockerStorage; + let sellerAccount: BankAccount; + let buyerAccount: BankAccount; + let currencyId: number | null; + + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + + // Always create a fresh, live, tradeable asset for the test. The + // "approved" approvalStatus can't be set from an unauthenticated payload + // context (filterOptions restrict non-developers), so use the default. + const created = (await payload.create({ + collection: "assets", + data: { + name: `${RUN} Asset`, + className: "test", + assetType: "miscellaneous", + approvalStatus: "in_progress", + tradeable: true, + isLive: true, + marketTrading: { Rarity: "common", perceivedValue: { baseBuyPrice: 100, baseSellPrice: 80 } }, + crafting: { craftable: false, craftingData: { craftingTimePerUnit: 1, batchSize: 1 } }, + storageDimensions: { gridWidth: 1, gridHeight: 1 }, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Asset; + assetId = created.id; + + const makeUser = async (label: string): Promise => { + const tag = `${RUN}-${label}`; + return (await payload.create({ + collection: "users", + data: { + username: tag, + discordUsername: tag, + displayName: label.toUpperCase(), + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roles: ["user"], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + }; + + seller = await makeUser("seller"); + buyer = await makeUser("buyer"); + + sellerLocker = await ensureLockerStorage(payload, seller.id); + buyerLocker = await ensureLockerStorage(payload, buyer.id); + + // Seed the seller's locker with 5 clean units of the asset. + sellerLocker = (await payload.update({ + collection: "locker-storages", + id: sellerLocker.id, + data: { + items: [ + { asset: assetId, quantity: 5, gridX: 0, gridY: 0, rotated: false }, + ], + }, + overrideAccess: true, + depth: 1, + })) as unknown as LockerStorage; + + currencyId = await getMainCurrencyId(payload); + if (!currencyId) throw new Error("Test requires a main currency in Game Rules."); + + sellerAccount = await createAccount(payload, { + name: `${RUN} Seller Account`, + accountType: "personal", + ownerUserId: seller.id, + }); + buyerAccount = await createAccount(payload, { + name: `${RUN} Buyer Account`, + accountType: "personal", + ownerUserId: buyer.id, + }); + + // Fund the buyer. + await applyTransaction(payload, { + type: "deposit", + toAccountId: buyerAccount.id, + amount: 10000, + memo: `${RUN} test funding`, + }); + }); + + afterAll(async () => { + if (!payload) return; + + const listings = await payload.find({ + collection: "market-listings", + where: { listedAt: { greater_than: new Date(Date.now() - 3_600_000).toISOString() } }, + limit: 200, + depth: 0, + overrideAccess: true, + }); + const listingIds = listings.docs.map((l) => l.id); + // Delete referencing negotiations first — Payload nulls out the listing FK + // on listing delete, which violates the NOT NULL constraint. + if (listingIds.length > 0) { + const negotiations = await payload.find({ + collection: "market-negotiations", + where: { listing: { in: listingIds } }, + limit: 200, + depth: 0, + overrideAccess: true, + }); + for (const negotiation of negotiations.docs) { + await payload.delete({ collection: "market-negotiations", id: negotiation.id, overrideAccess: true }); + } + } + for (const listing of listings.docs) { + await payload.delete({ collection: "market-listings", id: listing.id, overrideAccess: true }); + } + + for (const user of [seller, buyer]) { + if (!user) continue; + const accounts = await payload.find({ + collection: "bank-accounts", + where: { ownerUser: { equals: user.id } }, + limit: 20, + depth: 0, + overrideAccess: true, + }); + for (const account of accounts.docs) { + const txns = await payload.find({ + collection: "bank-transactions", + where: { or: [{ fromAccount: { equals: account.id } }, { toAccount: { equals: account.id } }] }, + limit: 50, + depth: 0, + overrideAccess: true, + }); + for (const txn of txns.docs) { + const entries = await payload.find({ + collection: "ledger-entries", + where: { transaction: { equals: txn.id } }, + limit: 50, + depth: 0, + overrideAccess: true, + }); + for (const entry of entries.docs) { + await payload.delete({ collection: "ledger-entries", id: entry.id, overrideAccess: true }); + } + await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }); + } + await payload.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true }); + } + const lockers = await payload.find({ + collection: "locker-storages", + where: { ownerUser: { equals: user.id } }, + limit: 5, + depth: 0, + overrideAccess: true, + }); + for (const locker of lockers.docs) { + await payload.delete({ collection: "locker-storages", id: locker.id, overrideAccess: true }); + } + await payload.delete({ collection: "users", id: user.id, overrideAccess: true }); + } + + // Delete the asset created for this test. + if (assetId) { + await payload.delete({ collection: "assets", id: assetId, overrideAccess: true }); + } + + // Delete any NPCs created for this test (generated + GM vendor). + const npcs = await payload.find({ + collection: "game-npcs", + where: { "vendor.sells": { equals: assetId } }, + limit: 50, + depth: 0, + overrideAccess: true, + }); + for (const npc of npcs.docs) { + await payload.delete({ collection: "game-npcs", id: npc.id, overrideAccess: true }); + } + }); + + it("isEntryClean treats equipment-bearing entries as unsellable", () => { + expect( + isEntryClean({ asset: assetId, quantity: 1, gridX: 0, gridY: 0 }), + ).toBe(true); + expect( + isEntryClean({ + asset: assetId, + quantity: 1, + gridX: 0, + gridY: 0, + equipment: { attachments: [{ asset: assetId }], skin: null }, + }), + ).toBe(false); + expect( + isEntryClean({ + asset: assetId, + quantity: 1, + gridX: 0, + gridY: 0, + equipment: { skin: assetId }, + }), + ).toBe(false); + }); + + it("countCleanQuantity and deductLockerQuantity only consume clean stock", async () => { + const locker = (await payload.findByID({ + collection: "locker-storages", + id: sellerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + + expect(countCleanQuantity(locker, assetId)).toBe(5); + + const items = deductLockerQuantity(locker, assetId, 5); + expect(items.reduce((sum, e) => sum + (e.quantity ?? 0), 0)).toBe(0); + expect(() => deductLockerQuantity(locker, assetId, 6)).toThrow(); + }); + + it("buys a listing: funds move, buyer's locker is credited, listing is sold", async () => { + // List 2 units. + const sellerLockerFull = (await payload.findByID({ + collection: "locker-storages", + id: sellerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + + const afterDeduct = deductLockerQuantity(sellerLockerFull, assetId, 2); + await payload.update({ + collection: "locker-storages", + id: sellerLocker.id, + data: { items: afterDeduct }, + overrideAccess: true, + depth: 0, + }); + + const listing = (await payload.create({ + collection: "market-listings", + data: { + asset: assetId, + seller: seller.id, + quantity: 2, + price: 100, + currency: currencyId, + status: "active", + listedAt: new Date().toISOString(), + }, + overrideAccess: true, + depth: 0, + })) as unknown as MarketListing; + + expect(listingTotal(listing)).toBe(200); + + const populated = (await payload.findByID({ + collection: "market-listings", + id: listing.id, + depth: 1, + overrideAccess: true, + })) as unknown as MarketListing; + + const buyerBefore = ( + (await payload.findByID({ + collection: "bank-accounts", + id: buyerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount + ).balance ?? 0; + const sellerBefore = ( + (await payload.findByID({ + collection: "bank-accounts", + id: sellerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount + ).balance ?? 0; + + const result = await buyListing(payload, populated, buyer); + + expect(result.listing.status).toBe("sold"); + expect(result.total).toBe(200); + + const buyerAccountAfter = (await payload.findByID({ + collection: "bank-accounts", + id: buyerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount; + const sellerAccountAfter = (await payload.findByID({ + collection: "bank-accounts", + id: sellerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount; + + expect(buyerAccountAfter.balance).toBe(buyerBefore - 200); + expect(sellerAccountAfter.balance).toBe(sellerBefore + 200); + + const buyerLockerAfter = (await payload.findByID({ + collection: "locker-storages", + id: buyerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + expect(countCleanQuantity(buyerLockerAfter, assetId)).toBe(2); + }); + + it("cannot buy a sold/expired listing or your own listing", async () => { + const sold = (await payload.find({ + collection: "market-listings", + where: { status: { equals: "sold" } }, + limit: 1, + depth: 1, + overrideAccess: true, + })) as unknown as { docs: MarketListing[] }; + + if (sold.docs.length > 0) { + await expect(buyListing(payload, sold.docs[0], buyer)).rejects.toThrow(/no longer active/); + } + + // Own listing: re-create one owned by buyer, expect rejection. + const ownListing = (await payload.create({ + collection: "market-listings", + data: { + asset: assetId, + seller: buyer.id, + quantity: 1, + price: 10, + currency: currencyId, + status: "active", + listedAt: new Date().toISOString(), + }, + overrideAccess: true, + depth: 0, + })) as unknown as MarketListing; + const ownPopulated = (await payload.findByID({ + collection: "market-listings", + id: ownListing.id, + depth: 1, + overrideAccess: true, + })) as unknown as MarketListing; + await expect(buyListing(payload, ownPopulated, buyer)).rejects.toThrow(/own listing/); + }); + + it("returns stock to the seller when a listing expires or is cancelled", async () => { + const sellerLockerFull = (await payload.findByID({ + collection: "locker-storages", + id: sellerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + const before = countCleanQuantity(sellerLockerFull, assetId); + + const listing = (await payload.create({ + collection: "market-listings", + data: { + asset: assetId, + seller: seller.id, + quantity: 1, + price: 50, + currency: currencyId, + status: "active", + listedAt: new Date().toISOString(), + }, + overrideAccess: true, + depth: 0, + })) as unknown as MarketListing; + const populated = (await payload.findByID({ + collection: "market-listings", + id: listing.id, + depth: 1, + overrideAccess: true, + })) as unknown as MarketListing; + + const expired = await expireListing(payload, populated); + expect(expired.status).toBe("expired"); + + const after = (await payload.findByID({ + collection: "locker-storages", + id: sellerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + expect(countCleanQuantity(after, assetId)).toBe(before + 1); + }); + + it("autoPrice/autoQuantity produce sane vendor entries", async () => { + const asset = (await payload.findByID({ + collection: "assets", + id: assetId, + depth: 0, + overrideAccess: true, + })) as unknown as Asset; + const price = autoPrice(asset, "buy"); + expect(price).not.toBeNull(); + expect(price as number).toBeGreaterThan(0); + expect(autoQuantity(asset)).toBeGreaterThan(0); + }); + + it("resolveAssetId works on id and populated refs", () => { + expect(resolveAssetId(assetId)).toBe(assetId); + expect(resolveAssetId({ id: assetId } as Asset)).toBe(assetId); + }); + + it("negotiationRange / isNegotiable resolve min, desired, and negotiability", () => { + expect(negotiationRange({ price: 100, desiredPrice: 100, minPrice: 60 })).toEqual({ min: 60, desired: 100 }); + expect(negotiationRange({ price: 100 })).toEqual({ min: 100, desired: 100 }); + expect(negotiationRange({ price: 100, desiredPrice: 90, minPrice: 80 })).toEqual({ min: 80, desired: 90 }); + + expect(isNegotiable({ price: 100, desiredPrice: 100, minPrice: 60 })).toBe(true); + expect(isNegotiable({ price: 100 })).toBe(false); + expect(isNegotiable({ price: 100, desiredPrice: 100, minPrice: 100 })).toBe(false); + }); + + it("npcResponseToOffer accepts at/above desired, counters toward desired", () => { + const listing = { price: 100, desiredPrice: 100, minPrice: 60 }; + + expect(npcResponseToOffer(listing, 100)).toEqual({ action: "accept", amount: 100, reason: "good" }); + expect(npcResponseToOffer(listing, 120)).toEqual({ action: "accept", amount: 100, reason: "overpay" }); + expect(npcResponseToOffer(listing, 60).action).toBe("counter"); + + const counter = npcResponseToOffer(listing, 80); + expect(counter.action).toBe("counter"); + if (counter.action === "counter") { + expect(counter.amount).toBeGreaterThan(80); + expect(counter.amount).toBeLessThan(100); + } + + const near = npcResponseToOffer(listing, 95); + expect(["accept", "counter"]).toContain(near.action); + if (near.action === "counter") { + expect(near.amount).toBeGreaterThan(95); + } + }); + + it("lowball offers burn the vendor's patience fast and eventually close", () => { + const listing = { price: 5000, desiredPrice: 5000, minPrice: 3000 }; + + const first = npcResponseToOffer(listing, 1); + expect(first.action).toBe("counter"); + if (first.action === "counter") { + expect(first.closed).toBe(false); + expect(first.patience).toBe(NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty); + } + + const second = npcResponseToOffer(listing, 1, { patience: first.action === "counter" ? first.patience : 0 }); + expect(second.action).toBe("counter"); + if (second.action === "counter") expect(second.closed).toBe(false); + + const third = npcResponseToOffer(listing, 1, { patience: second.action === "counter" ? second.patience : 0 }); + expect(third.action).toBe("counter"); + if (third.action === "counter") expect(third.closed).toBe(true); + }); + + it("reasonable offers cost a little patience; near-asking offers relieve it", () => { + const listing = { price: 100, desiredPrice: 100, minPrice: 60 }; + + const mid = npcResponseToOffer(listing, 80); + if (mid.action === "counter") expect(mid.patience).toBe(NPC_PATIENCE.roundCost); + + const good = npcResponseToOffer(listing, 90); + if (good.action === "counter") expect(good.patience).toBe(0); + + const bad = npcResponseToOffer(listing, 50, { patience: 50 }); + if (bad.action === "counter") expect(bad.patience).toBe(50 + NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty); + }); + + it("bargaining in the zone eventually exhausts the vendor (finite rounds)", () => { + const listing = { price: 100, desiredPrice: 100, minPrice: 60 }; + let patience = 0; + let closed = false; + let rounds = 0; + for (let i = 0; i < 20; i++) { + const response = npcResponseToOffer(listing, 80, { patience }); + if (response.action === "counter") { + patience = response.patience; + closed = response.closed; + rounds++; + if (closed) break; + } + } + expect(closed).toBe(true); + expect(rounds).toBe(Math.ceil(NPC_PATIENCE.cap / NPC_PATIENCE.roundCost)); + }); + + it("vendor counters are monotonic: never rise past the last counter", () => { + const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; + + const r1 = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null }); + expect(r1.action).toBe("counter"); + const c1 = r1.action === "counter" ? r1.amount : 0; + expect(c1).toBeGreaterThan(3500); + expect(c1).toBeLessThan(4300); + + const r2 = npcResponseToOffer(listing, 3800, { + patience: r1.action === "counter" ? r1.patience : 0, + lastCounter: c1, + lastBuyerOffer: 3500, + }); + expect(r2.action).toBe("counter"); + if (r2.action === "counter") { + expect(r2.amount).toBeLessThanOrEqual(c1); + expect(r2.amount).toBeGreaterThan(3800); + } + }); + + it("counter responses carry a reason and npcDialogue produces vendor lines", () => { + const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; + + const concede = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null }); + expect(concede.action).toBe("counter"); + if (concede.action === "counter") { + expect(concede.reason).toBe("concede"); + expect(npcDialogue(concede.reason, concede.amount)).toContain(concede.amount.toLocaleString()); + } + + const backward = npcResponseToOffer(listing, 3200, { patience: 12, lastCounter: 4060, lastBuyerOffer: 3800 }); + expect(backward.action).toBe("counter"); + if (backward.action === "counter") { + expect(backward.reason).toBe("backward"); + expect(backward.amount).toBe(4060); + } + + const lowball = npcResponseToOffer(listing, 1000, { patience: 0, lastCounter: null, lastBuyerOffer: null }); + expect(lowball.action).toBe("counter"); + if (lowball.action === "counter") expect(lowball.reason).toBe("lowball"); + + const firm = npcResponseToOffer( + listing, + 4300 - Math.round(4300 * 0.02), + { patience: 0, lastCounter: null, lastBuyerOffer: null }, + () => 0.99, + ); + expect(firm.action).toBe("counter"); + if (firm.action === "counter") expect(firm.reason).toBe("firm"); + + expect(npcDialogue("accept", undefined, undefined, () => 0)).toBe( + "I think that price is acceptable. Let's shake on it.", + ); + expect(npcDialogue("closed", undefined, 4300)).toContain("4,300"); + }); + + it("vendor counters never drop below the min acceptable price", () => { + const listing = { price: 5000, desiredPrice: 5000, minPrice: 4100 }; + + const r1 = npcResponseToOffer(listing, 1000, { patience: 0, lastCounter: null, lastBuyerOffer: null }); + expect(r1.action).toBe("counter"); + if (r1.action === "counter") { + expect(r1.amount).toBe(4100); + expect(r1.reason).toBe("lowball"); + } + + // Even after more bargaining, the vendor holds the floor. + const r2 = npcResponseToOffer(listing, 3000, { + patience: r1.action === "counter" ? r1.patience : 0, + lastCounter: r1.action === "counter" ? r1.amount : null, + lastBuyerOffer: 1000, + }); + expect(r2.action).toBe("counter"); + if (r2.action === "counter") expect(r2.amount).toBe(4100); + }); + + it("tiny offer increases are stalls: flagged as such and burn the vendor's patience", () => { + const listing = { price: 5000, desiredPrice: 5000, minPrice: 3000 }; + + // First offer of 3500 (above the floor) draws a normal concession. + const first = npcResponseToOffer(listing, 3500, { patience: 0, lastCounter: null, lastBuyerOffer: null }); + expect(first.action).toBe("counter"); + if (first.action === "counter") { + expect(first.reason).toBe("concede"); + expect(first.patience).toBe(NPC_PATIENCE.roundCost); + } + + // Bumping by 1 (< 1% of the asking price) is a stall: big patience hit. + const second = npcResponseToOffer(listing, 3501, { + patience: first.action === "counter" ? first.patience : 0, + lastCounter: first.action === "counter" ? first.amount : null, + lastBuyerOffer: 3500, + }); + expect(second.action).toBe("counter"); + if (second.action === "counter") { + expect(second.reason).toBe("stall"); + expect(second.patience).toBe( + NPC_PATIENCE.roundCost + NPC_PATIENCE.roundCost + NPC_PATIENCE.stallPenalty, + ); + } + + expect(npcDialogue("stall", undefined, undefined, () => 0)).toBe( + "Really? That's hardly different. Negotiate like you mean it.", + ); + }); + + it("meaningful offer increases are not stalls", () => { + const listing = { price: 5000, desiredPrice: 5000, minPrice: 4100 }; + + const r = npcResponseToOffer(listing, 4200, { + patience: 12, + lastCounter: 4550, + lastBuyerOffer: 3500, + }); + expect(r.action).toBe("counter"); + if (r.action === "counter") { + expect(r.reason).toBe("concede"); + expect(r.patience).toBe(12 + NPC_PATIENCE.roundCost); + } + }); + + it("the vendor holds firm when the buyer moves backward", () => { + const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; + const c1 = 4060; // the vendor's counter after the buyer offered 3500 + + const r = npcResponseToOffer(listing, 3000, { patience: 12, lastCounter: c1, lastBuyerOffer: 3500 }); + expect(r.action).toBe("counter"); + if (r.action === "counter") expect(r.amount).toBe(c1); + }); + + it("accepts an offer at or above the vendor's stance regardless of the roll", () => { + const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; + const state = { patience: 12, lastCounter: 3900, lastBuyerOffer: 3500 }; + + expect(npcResponseToOffer(listing, 3900, state, () => 0.99).action).toBe("accept"); + expect(npcResponseToOffer(listing, 4000, state, () => 0.99).action).toBe("accept"); + }); + + it("accepts with high chance when the offer is within margin of the stance, else holds", () => { + const listing = { price: 4300, desiredPrice: 4300, minPrice: 2580 }; + const state = { patience: 12, lastCounter: 3900, lastBuyerOffer: 3500 }; + // 2% of 3900 = 78 → threshold 3822; 3850 is inside the margin. + const yes = npcResponseToOffer(listing, 3850, state, () => 0); + expect(yes.action).toBe("accept"); + if (yes.action === "accept") expect(yes.amount).toBe(3850); + + const no = npcResponseToOffer(listing, 3850, state, () => 0.99); + expect(no.action).toBe("counter"); + if (no.action === "counter") expect(no.amount).toBe(3900); + }); + + it("buys at a negotiated unit price when one is supplied", async () => { + const sellerLockerFull = (await payload.findByID({ + collection: "locker-storages", + id: sellerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + + const afterDeduct = deductLockerQuantity(sellerLockerFull, assetId, 1); + await payload.update({ + collection: "locker-storages", + id: sellerLocker.id, + data: { items: afterDeduct }, + overrideAccess: true, + depth: 0, + }); + + const listing = (await payload.create({ + collection: "market-listings", + data: { + asset: assetId, + seller: seller.id, + quantity: 2, + price: 100, + desiredPrice: 100, + minPrice: 60, + currency: currencyId, + status: "active", + listedAt: new Date().toISOString(), + }, + overrideAccess: true, + depth: 0, + })) as unknown as MarketListing; + const populated = (await payload.findByID({ + collection: "market-listings", + id: listing.id, + depth: 1, + overrideAccess: true, + })) as unknown as MarketListing; + + const buyerBefore = ( + (await payload.findByID({ + collection: "bank-accounts", + id: buyerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount + ).balance ?? 0; + const sellerBefore = ( + (await payload.findByID({ + collection: "bank-accounts", + id: sellerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount + ).balance ?? 0; + + const result = await buyListing(payload, populated, buyer, { unitPrice: 80 }); + + expect(result.listing.status).toBe("sold"); + expect(result.total).toBe(160); + expect(result.unitPrice).toBe(80); + + const buyerAfter = (await payload.findByID({ + collection: "bank-accounts", + id: buyerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount; + const sellerAfter = (await payload.findByID({ + collection: "bank-accounts", + id: sellerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount; + + expect(buyerAfter.balance).toBe(buyerBefore - 160); + expect(sellerAfter.balance).toBe(sellerBefore + 160); + + const buyerLockerAfter = (await payload.findByID({ + collection: "locker-storages", + id: buyerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + expect(countCleanQuantity(buyerLockerAfter, assetId)).toBeGreaterThanOrEqual(2); + }); + + it("creates in-app notifications for a user", async () => { + await notifyUser(payload, { + userId: buyer.id, + type: "market:offer", + title: "Test notification", + message: "A seller replied to your offer.", + link: "/logistics/market", + }); + + const found = await payload.find({ + collection: "user-notifications", + where: { and: [{ user: { equals: buyer.id } }, { type: { equals: "market:offer" } }] }, + limit: 5, + depth: 0, + overrideAccess: true, + }); + + const doc = found.docs.find((n) => n.title === "Test notification"); + expect(doc).toBeDefined(); + expect(doc?.read).toBe(false); + expect(doc?.link).toBe("/logistics/market"); + + if (doc?.id) { + await payload.delete({ collection: "user-notifications", id: doc.id, overrideAccess: true }); + } + }); + + it("assigns auto stock to NPCs and prefers GM-assigned vendors", async () => { + const generated = await createGeneratedNpc(payload, { assetId }); + expect(generated.isGenerated).toBe(true); + expect(generated.isInGame).toBe(false); + expect(generated.vendor?.enabled).toBe(true); + expect((generated.vendor?.sells ?? []).map(resolveAssetId)).toContain(assetId); + expect(generated.name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/); + expect(randomNpcName()).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/); + + const vendor = (await payload.create({ + collection: "game-npcs", + data: { + name: "GM Vendor", + occupation: "Weapons Dealer", + isGenerated: false, + isInGame: true, + vendor: { enabled: true, sells: [assetId] }, + }, + overrideAccess: true, + depth: 0, + })) as unknown as GameNpc; + + const picked = await resolveVendorNpc(payload, assetId); + expect(picked.id).toBe(vendor.id); + + expect(applyNpcPriceModifier(100, vendor)).toBe(100); + expect( + applyNpcPriceModifier(100, { + ...vendor, + vendor: { enabled: true, priceModifier: 0.5, sells: [assetId] }, + } as GameNpc), + ).toBe(50); + }); + + it("buys a partial quantity and keeps the listing active", async () => { + const listing = (await payload.create({ + collection: "market-listings", + data: { + asset: assetId, + seller: seller.id, + quantity: 3, + price: 100, + desiredPrice: 100, + minPrice: 60, + currency: currencyId, + status: "active", + listedAt: new Date().toISOString(), + }, + overrideAccess: true, + depth: 0, + })) as unknown as MarketListing; + const populated = (await payload.findByID({ + collection: "market-listings", + id: listing.id, + depth: 1, + overrideAccess: true, + })) as unknown as MarketListing; + + const buyerBefore = ( + (await payload.findByID({ + collection: "bank-accounts", + id: buyerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount + ).balance ?? 0; + + const result = await buyListing(payload, populated, buyer, { quantity: 1 }); + + expect(result.quantity).toBe(1); + expect(result.total).toBe(100); + expect(result.unitPrice).toBe(100); + expect(result.listing.status).toBe("active"); + expect(result.listing.quantity).toBe(2); + + const buyerAfter = (await payload.findByID({ + collection: "bank-accounts", + id: buyerAccount.id, + depth: 0, + overrideAccess: true, + })) as unknown as BankAccount; + expect(buyerAfter.balance).toBe(buyerBefore - 100); + + const buyerLockerAfter = (await payload.findByID({ + collection: "locker-storages", + id: buyerLocker.id, + depth: 1, + overrideAccess: true, + })) as unknown as LockerStorage; + expect(countCleanQuantity(buyerLockerAfter, assetId)).toBeGreaterThanOrEqual(1); + + // Buying the remaining stock marks the listing sold. + const reFetched = (await payload.findByID({ + collection: "market-listings", + id: listing.id, + depth: 1, + overrideAccess: true, + })) as unknown as MarketListing; + expect(reFetched.quantity).toBe(2); + + const sold = await buyListing(payload, reFetched, buyer); + expect(sold.quantity).toBe(2); + expect(sold.total).toBe(200); + expect(sold.listing.status).toBe("sold"); + }); + + it("rejects invalid buy quantities", async () => { + const listing = (await payload.create({ + collection: "market-listings", + data: { + asset: assetId, + seller: seller.id, + quantity: 3, + price: 100, + desiredPrice: 100, + minPrice: 60, + currency: currencyId, + status: "active", + listedAt: new Date().toISOString(), + }, + overrideAccess: true, + depth: 0, + })) as unknown as MarketListing; + const populated = (await payload.findByID({ + collection: "market-listings", + id: listing.id, + depth: 1, + overrideAccess: true, + })) as unknown as MarketListing; + + await expect(buyListing(payload, populated, buyer, { quantity: 0 })).rejects.toThrow( + /Quantity must be between/, + ); + await expect(buyListing(payload, populated, buyer, { quantity: -1 })).rejects.toThrow( + /Quantity must be between/, + ); + await expect(buyListing(payload, populated, buyer, { quantity: 4 })).rejects.toThrow( + /Quantity must be between/, + ); + await expect(buyListing(payload, populated, buyer, { quantity: 1.5 })).rejects.toThrow( + /Quantity must be between/, + ); + }); +});