1
0
Fork 0

feat(market): add trading marketplace with listings, haggling, and NPC vendors

Tarkov-style flea market where users list locker items at a fixed price
and buyers pay through the banking system; items transfer directly
into the buyer's locker.

- market-listings + market-negotiations collections and the market page
  with search/rarity filters and a Countdown timer on each card
- service lib (buyListing with partial-quantity + negotiated-price
  support, clean-stock rule, creditLockerQuantity, auto pricing)
- negotiation flow: make/accept/reject/counter/withdraw, strictly-
  increasing buyer offers, NPC vendor auto-resolution
- NPC vendors: game-npcs attribution, patience meter, dialogue, chat
  bubbles, historical threads dialog
- 30d user / 7d auto listing durations, market-tick bin for expiry +
  vendor restock, SSE notify on tick
- market event types + game-npcs/market target collections in the
  event log; sidebar Market entry under Logistics
- partial-quantity purchases (buyListing/buyMarketListing/makeMarketOffer)
  with full int test coverage (25 tests)
This commit is contained in:
Jason Fraley 2026-08-10 16:05:57 -04:00
parent 3a46d4e5be
commit 89b8474e23
26 changed files with 5188 additions and 1 deletions

View file

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

View file

@ -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 (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<StoreIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Market</h1>
</div>
<p className="text-sm text-muted-foreground">
Buy and sell gear with other members, or shop the auto-generated market stock.
</p>
</div>
<MarketView
listings={listings}
myListings={myListings}
negotiations={negotiations}
locker={lockerFull}
currentUserId={userId}
currencyLabel={currencyLabel}
isManager={isManager}
/>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { isDeveloper } from "@/utils/access-control/isRole";
const TARGET_COLLECTIONS = [ const TARGET_COLLECTIONS = [
{ label: "Game Structures", value: "game-structures" }, { label: "Game Structures", value: "game-structures" },
{ label: "Game Vehicles", value: "game-vehicles" }, { label: "Game Vehicles", value: "game-vehicles" },
{ label: "Game NPCs", value: "game-npcs" },
{ label: "Structures", value: "structures" }, { label: "Structures", value: "structures" },
{ label: "Resources", value: "resources" }, { label: "Resources", value: "resources" },
{ label: "Assets", value: "assets" }, { label: "Assets", value: "assets" },
@ -20,6 +21,8 @@ const TARGET_COLLECTIONS = [
{ label: "Ledger Entries", value: "ledger-entries" }, { label: "Ledger Entries", value: "ledger-entries" },
{ label: "Locker Storages", value: "locker-storages" }, { label: "Locker Storages", value: "locker-storages" },
{ label: "Loadouts", value: "loadouts" }, { label: "Loadouts", value: "loadouts" },
{ label: "Market Listings", value: "market-listings" },
{ label: "Market Negotiations", value: "market-negotiations" },
] as const; ] as const;
export const GameEventLogs: CollectionConfig = { export const GameEventLogs: CollectionConfig = {

View file

@ -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.",
},
},
],
};

View file

@ -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,
};

View file

@ -84,6 +84,10 @@ const data = {
title: "Banking", title: "Banking",
url: "/logistics/banking", url: "/logistics/banking",
}, },
{
title: "Market",
url: "/logistics/market",
},
], ],
}, },
], ],

View file

@ -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<HTMLDivElement | null>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [bubbles.length]);
return (
<div className={cn("flex flex-col gap-2 overflow-y-auto px-3 pb-3", maxHeight)}>
{bubbles.map((bubble, index) => (
<div
key={index}
className={cn(
"flex w-fit max-w-[85%] flex-col gap-0.5 rounded-lg border px-2.5 py-1.5 text-sm",
bubble.from === "buyer"
? "self-end border-blue-500/30 bg-blue-500/10"
: "self-start border-amber-500/30 bg-amber-500/5",
)}
>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{bubble.from === "buyer" ? buyerLabel : sellerLabel}
</span>
<span
className={cn(
bubble.from === "seller" ? "text-amber-100/90" : "text-blue-100/90",
)}
>
{bubble.text}
</span>
</div>
))}
<div ref={endRef} />
</div>
);
}

View file

@ -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 (
<span
className={cn(
"inline-flex items-center gap-1 font-mono text-xs",
ms <= 0 ? "text-red-400" : urgent ? "text-amber-400" : "text-muted-foreground",
className,
)}
title={new Date(expiresAt).toLocaleString()}
>
<ClockIcon className="size-3" />
{formatCountdown(ms)}
</span>
);
}

View file

@ -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<string | null>(null);
const sellable = useMemo<SellableAsset[]>(() => {
if (!locker?.items) return [];
const map = new Map<number, SellableAsset>();
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 (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) reset();
}}
>
<DialogTrigger asChild>
<Button size="sm">
<PlusIcon className="size-4" />
Sell Items
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Sell Item</DialogTitle>
<DialogDescription>
List items from your locker on the market. Listings last 30 days and stock is
returned if they expire.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Item</Label>
<Select value={assetId} onValueChange={(v) => { setAssetId(v); setQuantity("1"); }}>
<SelectTrigger>
<SelectValue placeholder="Select an item from your locker" />
</SelectTrigger>
<SelectContent>
{sellable.map((s) => (
<SelectItem
key={s.assetId}
value={String(s.assetId)}
disabled={!s.tradeable || !s.isLive}
>
{s.name} — {s.quantity.toLocaleString()}×
</SelectItem>
))}
</SelectContent>
</Select>
{sellable.length === 0 && (
<p className="text-xs text-muted-foreground">
{hasLockerItems
? "Your locker items are not approved for trading yet."
: "Nothing in your locker can be listed yet."}
</p>
)}
</div>
{selected && (
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-1.5">
<Label>Quantity</Label>
<Input
type="number"
min={1}
max={maxQuantity}
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<Label>Price per unit ({currencyLabel ?? "units"})</Label>
<Input
type="number"
min={0.01}
step="any"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder={selected.basePrice != null ? `Suggested ~${selected.basePrice}` : "0"}
/>
</div>
</div>
)}
{selected && (
<div className="flex flex-col gap-2 rounded-md border p-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={negotiable}
onCheckedChange={(c) => {
setNegotiable(c === true);
if (c !== true) setMinPrice("");
}}
/>
Allow offers below the asking price
</label>
{negotiable && (
<div className="flex flex-col gap-1.5">
<Label>Minimum price per unit ({currencyLabel ?? "units"})</Label>
<Input
type="number"
min={0.01}
step="any"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="The lowest you will accept"
/>
<p className="text-xs text-muted-foreground">
Buyers can haggle; the final deal leans toward your asking price.
</p>
</div>
)}
</div>
)}
{selected && selected.basePrice != null && (
<p className="text-xs text-muted-foreground">
<Badge variant="outline" className="mr-1">Tip</Badge>
Resting valuation is {selected.basePrice.toLocaleString()} per unit.
</p>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading || !selected}>
{loading ? "Listing..." : "List Item"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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<string | null>(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 (
<Item variant="outline" className="h-full">
<ItemHeader>
<div className="flex items-center gap-2">
{thumbnail?.url ? (
<ItemMedia variant="image">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={thumbnail.url} alt={assetName} className="size-full object-cover" />
</ItemMedia>
) : (
<ItemMedia variant="icon">
<PackageIcon />
</ItemMedia>
)}
<ItemTitle className="flex flex-col">
<span className="font-mono text-xs text-muted-foreground">{assetType}</span>
<span>{assetName}</span>
</ItemTitle>
</div>
{listing.isAutoGenerated && <Badge variant="secondary">Vendor</Badge>}
{negotiable && <Badge variant="outline">Negotiable</Badge>}
</ItemHeader>
<ItemContent>
<ItemDescription>
{rarity ? `${rarity} · ` : ""}
{listing.quantity.toLocaleString()} in stock
</ItemDescription>
<div className="flex items-center justify-between gap-2 w-full">
<span className="text-sm font-semibold font-mono">
{formatAmount(listing.price ?? 0, { name: currencyLabel ?? undefined })} / unit
</span>
<span className="text-xs text-muted-foreground">
{stock > 1 && (
<>
{selectedQty}/{stock} ·{" "}
</>
)}
total {formatAmount(selectedTotal, { name: currencyLabel ?? undefined })}
</span>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
</ItemContent>
<ItemFooter>
<span className="flex items-center justify-between gap-2 w-full">
<span className="text-xs text-muted-foreground">from {sellerName(listing)}</span>
<Countdown expiresAt={listing.expiresAt} />
</span>
</ItemFooter>
{showActions && (
<ItemActions className="justify-between">
<div className="flex items-center gap-2">
{stock > 1 && (
<Input
type="number"
min={1}
max={stock}
step={1}
value={selectedQty}
onChange={(e) => 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})`}
/>
)}
<Button
size="sm"
onClick={handleBuy}
disabled={busy !== null || isOwn}
title={isOwn ? "You cannot buy your own listing" : undefined}
>
<ShoppingCartIcon className="size-4" />
{busy === "buy" ? "Buying..." : stock > 1 ? `Buy ${selectedQty}` : "Buy"}
</Button>
{negotiable && !isOwn && (
<MakeOfferDialog listing={listing} currentUserId={currentUserId} currencyLabel={currencyLabel} />
)}
</div>
{canCancel && (
<Button size="sm" variant="ghost" onClick={handleCancel} disabled={busy !== null}>
<XIcon className="size-4" />
{busy === "cancel" ? "..." : "Cancel"}
</Button>
)}
</ItemActions>
)}
</Item>
);
}

View file

@ -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<MarketNegotiation | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loadingThread, setLoadingThread] = useState(false);
const [acceptedAt, setAcceptedAt] = useState<number | null>(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<MarketNegotiation | null> {
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 (
<Dialog
open={open}
onOpenChange={(o) => {
// 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);
}
}}
>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<HandshakeIcon className="size-4" />
Offer
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Negotiate — {assetName}</DialogTitle>
<DialogDescription>
{isVendor ? "Haggle with the vendor." : "Send an offer to the seller."} Asking{" "}
<span className="font-mono">{fmt(listing.price ?? 0)}</span> per unit.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{isVendor && (
<NpcChat listing={listing} negotiation={negotiation} currencyLabel={currencyLabel} />
)}
{loadingThread && !outcome && (
<p className="text-sm text-muted-foreground">Checking for an existing offer...</p>
)}
{!outcome && !loadingThread && (
<>
<div className="flex flex-col gap-1.5">
<Label>Your offer ({currencyLabel ?? "units"} per unit)</Label>
<Input
type="number"
min={0.01}
step="any"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0"
/>
</div>
{stock > 1 && (
<div className="flex flex-col gap-1.5">
<Label>Quantity</Label>
<Input
type="number"
min={1}
max={stock}
step={1}
value={clampedQty}
onChange={(e) =>
setQty(Math.max(1, Math.min(stock, Number(e.target.value) || 1)))
}
/>
</div>
)}
<p className="text-xs text-muted-foreground">
{stock}× in stock · total{" "}
<span className="font-mono">
{fmt((Number(amount) || 0) * clampedQty)}
</span>
</p>
{error && <p className="text-sm text-red-400">{error}</p>}
</>
)}
{outcome?.kind === "pending" && (
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each
for {negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
{isVendor ? "vendor" : "seller"} to respond.
</p>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)}
{outcome?.kind === "countered" && (
<div className="flex flex-col gap-3">
<p className="text-sm">
The {isVendor ? "vendor" : "seller"} counteroffers{" "}
<span className="font-mono font-semibold">{fmt(negotiation?.amount ?? 0)}</span>{" "}
each for {negotiation?.quantity ?? stock}× — total{" "}
<span className="font-mono">
{fmt((negotiation?.amount ?? 0) * (negotiation?.quantity ?? stock))}
</span>
.
</p>
<div className="flex flex-col gap-1.5">
<Label>Your counter ({currencyLabel ?? "units"} per unit)</Label>
<Input
type="number"
min={0.01}
step="any"
value={counter}
onChange={(e) => setCounter(e.target.value)}
/>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)}
{outcome?.kind === "accepted" && (
<div className="flex flex-col gap-1">
<Badge variant="outline" className="w-fit">
Deal closed
</Badge>
<p className="text-sm text-muted-foreground">
Sold at <span className="font-mono">{fmt(outcome.price)}</span> each for{" "}
{negotiation?.quantity ?? stock}× — total{" "}
<span className="font-mono">
{fmt(outcome.price * (negotiation?.quantity ?? stock))}
</span>
. The items are in your locker.
</p>
</div>
)}
{outcome?.kind === "rejected" && (
<p className="text-sm text-muted-foreground">
The seller declined your offer. The negotiation is closed.
</p>
)}
{outcome?.kind === "closed" && (
<p className="text-sm text-muted-foreground">
{isVendor && negotiation?.status === "closed"
? "The vendor is done haggling. You can still buy the item at the asking price."
: "This negotiation has been closed."}
</p>
)}
</div>
{isVendor && negotiation && negotiation.status !== "accepted" && (
<PatienceMeter patience={negotiation.patience ?? 0} />
)}
<DialogFooter className="flex-wrap">
{!outcome && (
<Button onClick={handleOffer} disabled={busy !== null || loadingThread}>
{busy === "offer" ? "Sending..." : "Make Offer"}
</Button>
)}
{outcome?.kind === "pending" && (
<Button variant="outline" onClick={handleWithdraw} disabled={busy !== null}>
<XIcon className="size-4" />
{busy === "withdraw" ? "..." : "Withdraw"}
</Button>
)}
{outcome?.kind === "countered" && (
<>
<Button onClick={handleAccept} disabled={busy !== null}>
{busy === "accept" ? "Buying..." : "Accept"}
</Button>
<Button variant="outline" onClick={handleCounter} disabled={busy !== null}>
{busy === "counter" ? "..." : "Counter"}
</Button>
<Button variant="ghost" onClick={handleWithdraw} disabled={busy !== null}>
<XIcon className="size-4" />
{busy === "withdraw" ? "..." : "Withdraw"}
</Button>
</>
)}
{(outcome?.kind === "accepted" || outcome?.kind === "rejected" || outcome?.kind === "closed") && (
<Button variant="outline" onClick={() => setOpen(false)} disabled={acceptLocked}>
{acceptLocked ? `Closing in ${acceptRemaining}s…` : "Close"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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 (
<div className="flex flex-col gap-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>Search</Label>
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Search items..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>Rarity</Label>
<Select value={rarity} onValueChange={setRarity}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All rarities</SelectItem>
{RARITY_OPTIONS.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Select value={assetType} onValueChange={setAssetType}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{ASSET_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<CreateListingDialog locker={locker} currencyLabel={currencyLabel} />
</div>
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
For sale
</h2>
<span className="text-xs text-muted-foreground">
{filtered.length} listing{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No listings match. Try clearing the filters.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
{filtered.map((listing) => (
<ListingCard
key={listing.id}
listing={listing}
currentUserId={currentUserId}
currencyLabel={currencyLabel}
canManage={isManager}
/>
))}
</div>
)}
</section>
<NegotiationPanel
negotiations={negotiations}
currentUserId={currentUserId}
currencyLabel={currencyLabel}
/>
{myListings.length > 0 && (
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
My listings
</h2>
<span className="text-xs text-muted-foreground">
{myListings.length} active
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
{myListings.map((listing) => (
<ListingCard
key={listing.id}
listing={listing}
currentUserId={currentUserId}
currencyLabel={currencyLabel}
canManage={isManager}
/>
))}
</div>
</section>
)}
</div>
);
}

View file

@ -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<string, string> = {
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 (
<Collapsible open={expanded} onOpenChange={setExpanded} className="rounded-md border">
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{assetName(listing)}</span>
<span className="text-xs text-muted-foreground">
{counterparty} · {date}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-sm">{fmt(negotiation.amount ?? 0)}</span>
<Badge variant={isOpen ? "default" : "secondary"}>
{STATUS_LABELS[negotiation.status] ?? negotiation.status}
</Badge>
<ChevronDownIcon
className={cn(
"size-4 text-muted-foreground transition-transform",
expanded && "rotate-180",
)}
/>
</div>
</CollapsibleTrigger>
{listing && (
<CollapsibleContent className="border-t">
<ChatBubbleList
bubbles={buildChatBubbles(negotiation, listing, currencyLabel)}
buyerLabel={buyerLabel}
sellerLabel={sellerLabel}
maxHeight="max-h-64"
/>
</CollapsibleContent>
)}
</Collapsible>
);
}
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<HistoryIcon className="size-4" />
History
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Negotiation history</DialogTitle>
<DialogDescription>
Your offers, counters and vendor conversations. Tap a row to expand the chat.
</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-2 overflow-y-auto pr-1">
{sorted.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No negotiations yet.</p>
) : (
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 (
<HistoryRow
key={negotiation.id}
negotiation={negotiation}
role={role}
currencyLabel={currencyLabel}
/>
);
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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<string, string> = {
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<string | null>(null);
const [counterOpen, setCounterOpen] = useState(false);
const [counter, setCounter] = useState("");
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-2 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{assetName(listing)}</span>
<span className="text-xs text-muted-foreground">
{counterpartyLabel(negotiation, role)} · {listing?.quantity ?? "?"}× in stock
</span>
</div>
<Badge variant={isOpen ? "default" : "secondary"}>{STATUS_LABELS[negotiation.status] ?? negotiation.status}</Badge>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="font-mono">{fmt(negotiation.amount ?? 0)}</span>
<span className="text-xs text-muted-foreground">
{negotiation.quantity ? `${negotiation.quantity}× · ` : ""}
{theirProposal
? role === "buyer"
? "their counteroffer"
: "buyer's offer"
: "your offer"}
{negotiation.quantity ? (
<>
{" · total "}
<span className="font-mono">
{fmt((negotiation.amount ?? 0) * negotiation.quantity)}
</span>
</>
) : null}
</span>
</div>
{role === "buyer" && listing?.isAutoGenerated && isOpen && (
<PatienceMeter patience={negotiation.patience ?? 0} />
)}
{isOpen && theirProposal && (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => void run(() => acceptMarketOffer(negotiation.id), "accept")}
disabled={busy !== null}
>
{busy === "accept" ? "Buying..." : "Accept"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setCounterOpen((v) => !v)}
disabled={busy !== null}
>
Counter
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void run(() => rejectMarketOffer(negotiation.id), "reject")}
disabled={busy !== null}
>
{busy === "reject" ? "..." : "Reject"}
</Button>
</div>
)}
{isOpen && role === "buyer" && !theirProposal && (
<Button
size="sm"
variant="ghost"
className="w-fit"
onClick={() => void run(() => cancelMarketOffer(negotiation.id), "withdraw")}
disabled={busy !== null}
>
{busy === "withdraw" ? "..." : "Withdraw"}
</Button>
)}
{counterOpen && (
<div className="flex items-center gap-2">
<Input
type="number"
min={0.01}
step="any"
className="w-36"
value={counter}
onChange={(e) => setCounter(e.target.value)}
placeholder="Your counter"
/>
<Button size="sm" onClick={handleCounter} disabled={busy !== null}>
{busy === "counter" ? "..." : "Send"}
</Button>
</div>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
);
}
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 (
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<HandshakeIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Negotiations
</h2>
{open.length > 0 && (
<Badge variant="outline" className="text-xs">
{open.length} open
</Badge>
)}
<div className="ml-auto">
<NegotiationHistoryDialog
negotiations={negotiations}
currentUserId={currentUserId}
currencyLabel={currencyLabel}
/>
</div>
</div>
{open.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{open.map((negotiation) => {
const role: "buyer" | "seller" =
negotiation.buyer &&
(typeof negotiation.buyer === "object"
? negotiation.buyer.id
: negotiation.buyer) === currentUserId
? "buyer"
: "seller";
return (
<NegotiationRow
key={negotiation.id}
negotiation={negotiation}
role={role}
currencyLabel={currencyLabel}
/>
);
})}
</div>
)}
{closed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{closed.slice(0, 6).map((negotiation) => (
<Badge key={negotiation.id} variant="secondary" className="font-normal">
{assetName(listingOf(negotiation))} · {STATUS_LABELS[negotiation.status] ?? negotiation.status}
</Badge>
))}
{closed.length > 6 && (
<span className={cn("text-xs text-muted-foreground self-center")}>
+{closed.length - 6} more
</span>
)}
</div>
)}
</section>
);
}

View file

@ -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 (
<div className="flex flex-col gap-2 rounded-md border bg-card">
<div className="flex items-center gap-2 border-b px-3 py-2">
<StoreIcon className="size-4 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Vendor</span>
<span className="text-xs text-muted-foreground">— {assetName}</span>
</div>
<ChatBubbleList bubbles={bubbles} buyerLabel="You" sellerLabel="Vendor" />
</div>
);
}

View file

@ -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 (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Vendor patience</span>
<span className={cn("font-mono text-xs", value >= 80 ? "text-red-400" : "text-muted-foreground")}>
{value}%
</span>
</div>
<Progress value={value} className={TONES[tone]} />
</div>
);
}

View file

@ -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<MarketNegotiation["history"]>;
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;
}

379
src/lib/market/index.ts Normal file
View file

@ -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<ReturnType<typeof import("payload").getPayload>>;
export type ListingStatus = "active" | "sold" | "cancelled" | "expired";
export type LockerItemEntry = NonNullable<LockerStorage["items"]>[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<string, string> = {
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<MarketListing, "price" | "quantity">): 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<MarketListing, "price" | "desiredPrice" | "minPrice">): 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<MarketListing, "price" | "desiredPrice" | "minPrice">): 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<LockerStorage> {
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<number | null> {
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<BuyResult> {
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<MarketListing> {
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;
}

View file

@ -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<ReturnType<typeof import("payload").getPayload>>;
export const NPC_PATIENCE = {
/** Patience gained every round the buyer keeps bargaining. */
roundCost: 12,
/** Extra patience burned when the offer is below the vendor's floor. */
lowballPenalty: 25,
/** Extra patience burned when the offer barely budges from the previous one. */
stallPenalty: 35,
/** An offer that moves less than this fraction of the asking price counts as a "stall". */
stallRatio: 0.01,
/** Patience relieved when the offer is close to the asking price. */
goodOfferRelief: 9,
/** An offer at or above this fraction of the asking price counts as "good". */
goodOfferRatio: 0.85,
/** When patience reaches this, the negotiation closes. */
cap: 100,
} as const;
export const NPC_ACCEPT = {
/** The vendor concedes this fraction of the gap toward the buyer's offer each round. */
concedeRatio: 0.3,
/** Offers within this fraction of the vendor's current stance may be accepted outright. */
marginRatio: 0.02,
/** Chance the vendor accepts an offer inside the margin. */
chance: 0.85,
} as const;
export interface NpcState {
/** The vendor's patience meter (0–100). */
patience?: number;
/** The vendor's most recent counteroffer (null before the first round). */
lastCounter?: number | null;
/** The buyer offer that drew lastCounter (null before the first round). */
lastBuyerOffer?: number | null;
}
export type NpcAcceptReason = "accept" | "good" | "overpay";
export type NpcCounterReason = Exclude<NpcDialogueKind, "closed" | "accept" | "good" | "overpay">;
export type NpcResponse =
| { action: "accept"; amount: number; reason: NpcAcceptReason }
| {
action: "counter";
amount: number;
patience: number;
closed: boolean;
reason: NpcCounterReason;
};
/**
* Auto-response of an NPC vendor to a buyer's offer.
*
* The vendor keeps a private "stance" price that starts at the asking price and
* only ever moves down. Offers at/above the stance are accepted outright;
* offers within a small margin of the stance are accepted with high chance
* (`NPC_ACCEPT.chance`) and otherwise the vendor holds firm. Offers well below
* the stance draw a concession — the stance moves a fraction of the gap toward
* the buyer, but never rises past its previous counter, never drops below the
* vendor's floor (`minPrice`), and the vendor holds firm if the buyer offers
* *less* than their previous offer. Offers that barely budge from the buyer's
* previous offer ("stalls", < 1% of the asking price) burn extra patience. The patience meter
* advances with every round: lowballs burn a lot, reasonable offers cost a
* little, and near-asking offers ease the vendor's mind. At the cap the
* negotiation closes — the buyer can only buy at the asking price.
*/
export function npcResponseToOffer(
listing: Pick<MarketListing, "price" | "desiredPrice" | "minPrice">,
amount: number,
state: NpcState = {},
rng: () => number = Math.random,
): NpcResponse {
const { min, desired } = negotiationRange(listing);
const patience = state.patience ?? 0;
const lastCounter = state.lastCounter ?? null;
const lastBuyerOffer = state.lastBuyerOffer ?? null;
const good = (n: number) =>
n >= desired * NPC_PATIENCE.goodOfferRatio ? ("good" as const) : ("accept" as const);
if (amount >= desired) {
return { action: "accept", amount: desired, reason: amount > desired ? "overpay" : "good" };
}
const stance = lastCounter ?? desired;
const margin = Math.round(stance * NPC_ACCEPT.marginRatio);
const movedBackward = lastBuyerOffer != null && amount < lastBuyerOffer;
const stall =
lastBuyerOffer != null &&
amount > lastBuyerOffer &&
amount - lastBuyerOffer < desired * NPC_PATIENCE.stallRatio;
if (amount >= stance) return { action: "accept", amount: Math.min(amount, desired), reason: good(amount) };
if (amount >= stance - margin) {
if (rng() < NPC_ACCEPT.chance) {
return { action: "accept", amount: Math.min(amount, desired), reason: good(amount) };
}
// Declined inside the margin: hold firm at the current stance.
const nextPatience = advancePatience(patience, amount, min, desired, lastBuyerOffer);
return {
action: "counter",
amount: stance,
patience: nextPatience,
closed: nextPatience >= NPC_PATIENCE.cap,
reason: stall ? "stall" : "firm",
};
}
const counter = movedBackward
? stance
: Math.max(amount, min, Math.round(stance - (stance - amount) * NPC_ACCEPT.concedeRatio));
const nextPatience = advancePatience(patience, amount, min, desired, lastBuyerOffer);
return {
action: "counter",
amount: counter,
patience: nextPatience,
closed: nextPatience >= NPC_PATIENCE.cap,
reason: movedBackward ? "backward" : stall ? "stall" : amount < min ? "lowball" : "concede",
};
}
function advancePatience(
patience: number,
amount: number,
min: number,
desired: number,
previous: number | null,
): number {
let delta: number;
if (previous != null && amount > previous && amount - previous < desired * NPC_PATIENCE.stallRatio) {
delta = NPC_PATIENCE.roundCost + NPC_PATIENCE.stallPenalty;
} else if (amount < min) {
delta = NPC_PATIENCE.roundCost + NPC_PATIENCE.lowballPenalty;
} else if (amount >= desired * NPC_PATIENCE.goodOfferRatio) {
delta = -NPC_PATIENCE.goodOfferRelief;
} else {
delta = NPC_PATIENCE.roundCost;
}
return Math.max(0, Math.min(NPC_PATIENCE.cap, patience + delta));
}
export interface ExpireOptions {
/** Reason shown in the buyer notification. */
reason?: string;
}
/**
* Close every open negotiation thread on a listing (e.g. when it is sold or
* expired) and notify each interested buyer.
*/
export async function expireNegotiationsForListing(
payload: PayloadType,
listingId: number,
listingName: string,
options?: ExpireOptions,
): Promise<void> {
const open = await payload.find({
collection: "market-negotiations",
where: { and: [{ listing: { equals: listingId } }, { status: { equals: "open" } }] },
limit: 100,
depth: 1,
overrideAccess: true,
});
const reason = options?.reason ?? "was sold";
for (const negotiation of open.docs) {
await payload.update({
collection: "market-negotiations",
id: negotiation.id,
data: { status: "expired" },
overrideAccess: true,
depth: 0,
});
const buyerId =
typeof negotiation.buyer === "object" && negotiation.buyer
? negotiation.buyer.id
: (negotiation.buyer as number | null);
if (buyerId != null) {
await notifyUser(payload, {
userId: buyerId,
type: "market:expired",
title: "Negotiation closed",
message: `${listingName} ${reason} before you could close the deal.`,
link: "/logistics/market",
});
}
}
}

View file

@ -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<NpcDialogueKind, DialogueLine[]> = {
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());
}

213
src/lib/market/npcs.ts Normal file
View file

@ -0,0 +1,213 @@
import type { GameNpc } from "@/payload-types";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
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<T>(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<GameNpc> {
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<GameNpc[]> {
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<GameNpc> {
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));
}

View file

@ -38,6 +38,8 @@ import { BankTransactions } from "@/collections/banking/BankTransactions";
import { LedgerEntries } from "@/collections/banking/LedgerEntries"; import { LedgerEntries } from "@/collections/banking/LedgerEntries";
import { LockerStorages } from "@/collections/locker/LockerStorages"; import { LockerStorages } from "@/collections/locker/LockerStorages";
import { Loadouts } from "@/collections/locker/Loadouts"; 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 { UserNotifications } from "@/collections/users/UserNotifications";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
@ -114,6 +116,10 @@ export default buildConfig({
LockerStorages, LockerStorages,
Loadouts, Loadouts,
// Market
MarketListings,
MarketNegotiations,
// World // World
Maps, Maps,
NarrativeEvents, NarrativeEvents,
@ -139,6 +145,10 @@ export default buildConfig({
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"), scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
key: "game-tick", key: "game-tick",
}, },
{
scriptPath: path.resolve(dirname, "scripts/marketTick.ts"),
key: "market-tick",
},
], ],
plugins: [ plugins: [
// payloadCloudPlugin(), // payloadCloudPlugin(),

173
src/scripts/marketTick.ts Normal file
View file

@ -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);
};

View file

@ -5,7 +5,7 @@ interface GameEventInput {
message: string; message: string;
actor?: number; actor?: number;
structure?: 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; targetId?: number;
data?: Record<string, unknown>; data?: Record<string, unknown>;
} }

View file

@ -59,6 +59,22 @@ export const EventTypes = {
// Minigames // Minigames
MinigameFlappy: "minigame:flappy", 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; } as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes]; export type EventType = (typeof EventTypes)[keyof typeof EventTypes];

View file

@ -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<User> => {
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/,
);
});
});