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:
parent
3a46d4e5be
commit
89b8474e23
26 changed files with 5188 additions and 1 deletions
974
src/app/(frontend)/logistics/market/actions.ts
Normal file
974
src/app/(frontend)/logistics/market/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
104
src/app/(frontend)/logistics/market/page.tsx
Normal file
104
src/app/(frontend)/logistics/market/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { isDeveloper } from "@/utils/access-control/isRole";
|
|||
const TARGET_COLLECTIONS = [
|
||||
{ label: "Game Structures", value: "game-structures" },
|
||||
{ label: "Game Vehicles", value: "game-vehicles" },
|
||||
{ label: "Game NPCs", value: "game-npcs" },
|
||||
{ label: "Structures", value: "structures" },
|
||||
{ label: "Resources", value: "resources" },
|
||||
{ label: "Assets", value: "assets" },
|
||||
|
|
@ -20,6 +21,8 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Ledger Entries", value: "ledger-entries" },
|
||||
{ label: "Locker Storages", value: "locker-storages" },
|
||||
{ label: "Loadouts", value: "loadouts" },
|
||||
{ label: "Market Listings", value: "market-listings" },
|
||||
{ label: "Market Negotiations", value: "market-negotiations" },
|
||||
] as const;
|
||||
|
||||
export const GameEventLogs: CollectionConfig = {
|
||||
|
|
|
|||
149
src/collections/market/MarketListings.ts
Normal file
149
src/collections/market/MarketListings.ts
Normal 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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
155
src/collections/market/MarketNegotiations.ts
Normal file
155
src/collections/market/MarketNegotiations.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -84,6 +84,10 @@ const data = {
|
|||
title: "Banking",
|
||||
url: "/logistics/banking",
|
||||
},
|
||||
{
|
||||
title: "Market",
|
||||
url: "/logistics/market",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
56
src/components/frontend/market/ChatBubbleList.tsx
Normal file
56
src/components/frontend/market/ChatBubbleList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/components/frontend/market/Countdown.tsx
Normal file
50
src/components/frontend/market/Countdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
src/components/frontend/market/CreateListingDialog.tsx
Normal file
276
src/components/frontend/market/CreateListingDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/components/frontend/market/ListingCard.tsx
Normal file
186
src/components/frontend/market/ListingCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
398
src/components/frontend/market/MakeOfferDialog.tsx
Normal file
398
src/components/frontend/market/MakeOfferDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
src/components/frontend/market/MarketView.tsx
Normal file
203
src/components/frontend/market/MarketView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/components/frontend/market/NegotiationHistoryDialog.tsx
Normal file
187
src/components/frontend/market/NegotiationHistoryDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
src/components/frontend/market/NegotiationPanel.tsx
Normal file
291
src/components/frontend/market/NegotiationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/frontend/market/NpcChat.tsx
Normal file
29
src/components/frontend/market/NpcChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/frontend/market/PatienceMeter.tsx
Normal file
37
src/components/frontend/market/PatienceMeter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/lib/market/chatBubbles.ts
Normal file
83
src/lib/market/chatBubbles.ts
Normal 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
379
src/lib/market/index.ts
Normal 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;
|
||||
}
|
||||
200
src/lib/market/negotiations.ts
Normal file
200
src/lib/market/negotiations.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/lib/market/npcDialogue.ts
Normal file
75
src/lib/market/npcDialogue.ts
Normal 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
213
src/lib/market/npcs.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ import { BankTransactions } from "@/collections/banking/BankTransactions";
|
|||
import { LedgerEntries } from "@/collections/banking/LedgerEntries";
|
||||
import { LockerStorages } from "@/collections/locker/LockerStorages";
|
||||
import { Loadouts } from "@/collections/locker/Loadouts";
|
||||
import { MarketListings } from "@/collections/market/MarketListings";
|
||||
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
|
|
@ -114,6 +116,10 @@ export default buildConfig({
|
|||
LockerStorages,
|
||||
Loadouts,
|
||||
|
||||
// Market
|
||||
MarketListings,
|
||||
MarketNegotiations,
|
||||
|
||||
// World
|
||||
Maps,
|
||||
NarrativeEvents,
|
||||
|
|
@ -139,6 +145,10 @@ export default buildConfig({
|
|||
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
|
||||
key: "game-tick",
|
||||
},
|
||||
{
|
||||
scriptPath: path.resolve(dirname, "scripts/marketTick.ts"),
|
||||
key: "market-tick",
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
// payloadCloudPlugin(),
|
||||
|
|
|
|||
173
src/scripts/marketTick.ts
Normal file
173
src/scripts/marketTick.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -5,7 +5,7 @@ interface GameEventInput {
|
|||
message: string;
|
||||
actor?: number;
|
||||
structure?: number;
|
||||
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries" | "locker-storages" | "loadouts";
|
||||
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "game-npcs" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries" | "locker-storages" | "loadouts" | "market-listings" | "market-negotiations";
|
||||
targetId?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,22 @@ export const EventTypes = {
|
|||
|
||||
// Minigames
|
||||
MinigameFlappy: "minigame:flappy",
|
||||
|
||||
// Market
|
||||
MarketListingCreate: "market:listing-create",
|
||||
MarketListingCancel: "market:listing-cancel",
|
||||
MarketListingExpire: "market:listing-expire",
|
||||
MarketSale: "market:sale",
|
||||
MarketAutoGenerate: "market:auto-generate",
|
||||
MarketOffer: "market:offer",
|
||||
MarketOfferAccept: "market:offer-accept",
|
||||
MarketOfferReject: "market:offer-reject",
|
||||
MarketOfferCounter: "market:offer-counter",
|
||||
MarketOfferCancel: "market:offer-cancel",
|
||||
MarketOfferClosed: "market:offer-closed",
|
||||
|
||||
// NPCs
|
||||
NpcGenerate: "npc:generate",
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||
|
|
|
|||
936
tests/int/market.int.spec.ts
Normal file
936
tests/int/market.int.spec.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue