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 = [
|
const TARGET_COLLECTIONS = [
|
||||||
{ label: "Game Structures", value: "game-structures" },
|
{ label: "Game Structures", value: "game-structures" },
|
||||||
{ label: "Game Vehicles", value: "game-vehicles" },
|
{ label: "Game Vehicles", value: "game-vehicles" },
|
||||||
|
{ label: "Game NPCs", value: "game-npcs" },
|
||||||
{ label: "Structures", value: "structures" },
|
{ label: "Structures", value: "structures" },
|
||||||
{ label: "Resources", value: "resources" },
|
{ label: "Resources", value: "resources" },
|
||||||
{ label: "Assets", value: "assets" },
|
{ label: "Assets", value: "assets" },
|
||||||
|
|
@ -20,6 +21,8 @@ const TARGET_COLLECTIONS = [
|
||||||
{ label: "Ledger Entries", value: "ledger-entries" },
|
{ label: "Ledger Entries", value: "ledger-entries" },
|
||||||
{ label: "Locker Storages", value: "locker-storages" },
|
{ label: "Locker Storages", value: "locker-storages" },
|
||||||
{ label: "Loadouts", value: "loadouts" },
|
{ label: "Loadouts", value: "loadouts" },
|
||||||
|
{ label: "Market Listings", value: "market-listings" },
|
||||||
|
{ label: "Market Negotiations", value: "market-negotiations" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const GameEventLogs: CollectionConfig = {
|
export const GameEventLogs: CollectionConfig = {
|
||||||
|
|
|
||||||
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",
|
title: "Banking",
|
||||||
url: "/logistics/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 { LedgerEntries } from "@/collections/banking/LedgerEntries";
|
||||||
import { LockerStorages } from "@/collections/locker/LockerStorages";
|
import { LockerStorages } from "@/collections/locker/LockerStorages";
|
||||||
import { Loadouts } from "@/collections/locker/Loadouts";
|
import { Loadouts } from "@/collections/locker/Loadouts";
|
||||||
|
import { MarketListings } from "@/collections/market/MarketListings";
|
||||||
|
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
@ -114,6 +116,10 @@ export default buildConfig({
|
||||||
LockerStorages,
|
LockerStorages,
|
||||||
Loadouts,
|
Loadouts,
|
||||||
|
|
||||||
|
// Market
|
||||||
|
MarketListings,
|
||||||
|
MarketNegotiations,
|
||||||
|
|
||||||
// World
|
// World
|
||||||
Maps,
|
Maps,
|
||||||
NarrativeEvents,
|
NarrativeEvents,
|
||||||
|
|
@ -139,6 +145,10 @@ export default buildConfig({
|
||||||
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
|
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
|
||||||
key: "game-tick",
|
key: "game-tick",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scriptPath: path.resolve(dirname, "scripts/marketTick.ts"),
|
||||||
|
key: "market-tick",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
// payloadCloudPlugin(),
|
// payloadCloudPlugin(),
|
||||||
|
|
|
||||||
173
src/scripts/marketTick.ts
Normal file
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;
|
message: string;
|
||||||
actor?: number;
|
actor?: number;
|
||||||
structure?: number;
|
structure?: number;
|
||||||
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries" | "locker-storages" | "loadouts";
|
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "game-npcs" | "shipments" | "bank-accounts" | "bank-transactions" | "ledger-entries" | "locker-storages" | "loadouts" | "market-listings" | "market-negotiations";
|
||||||
targetId?: number;
|
targetId?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,22 @@ export const EventTypes = {
|
||||||
|
|
||||||
// Minigames
|
// Minigames
|
||||||
MinigameFlappy: "minigame:flappy",
|
MinigameFlappy: "minigame:flappy",
|
||||||
|
|
||||||
|
// Market
|
||||||
|
MarketListingCreate: "market:listing-create",
|
||||||
|
MarketListingCancel: "market:listing-cancel",
|
||||||
|
MarketListingExpire: "market:listing-expire",
|
||||||
|
MarketSale: "market:sale",
|
||||||
|
MarketAutoGenerate: "market:auto-generate",
|
||||||
|
MarketOffer: "market:offer",
|
||||||
|
MarketOfferAccept: "market:offer-accept",
|
||||||
|
MarketOfferReject: "market:offer-reject",
|
||||||
|
MarketOfferCounter: "market:offer-counter",
|
||||||
|
MarketOfferCancel: "market:offer-cancel",
|
||||||
|
MarketOfferClosed: "market:offer-closed",
|
||||||
|
|
||||||
|
// NPCs
|
||||||
|
NpcGenerate: "npc:generate",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||||
|
|
|
||||||
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