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, getTreasuryAccountId, } from "@/lib/banking"; export { getTreasuryAccountId } from "@/lib/banking"; type PayloadType = Awaited>; export type ListingStatus = "active" | "sold" | "cancelled" | "expired"; export type LockerItemEntry = NonNullable[number]; /** User listings last 30 days before stock is returned to the seller. */ export const USER_LISTING_DURATION_MS = 30 * 24 * 60 * 60 * 1000; /** Auto-generated NPC entries rotate weekly. */ export const AUTO_LISTING_DURATION_MS = 7 * 24 * 60 * 60 * 1000; export const RARITY_LABELS: Record = { basic: "Basic", common: "Common", uncommon: "Uncommon", rare: "Rare", veryRare: "Very Rare", epic: "Epic", legendary: "Legendary", unique: "Unique", }; export function getRarityLabel(asset: Asset): string | null { const rarity = asset.marketTrading?.Rarity; return rarity ? (RARITY_LABELS[rarity] ?? rarity) : null; } export function getBaseBuyPrice(asset: Asset): number | null { const value = asset.marketTrading?.perceivedValue?.baseBuyPrice; return typeof value === "number" && value > 0 ? value : null; } export function getBaseSellPrice(asset: Asset): number | null { const value = asset.marketTrading?.perceivedValue?.baseSellPrice; return typeof value === "number" && value > 0 ? value : null; } export function listingTotal(listing: Pick): number { return (listing.price ?? 0) * (listing.quantity ?? 1); } export interface NegotiationRange { /** Lowest per-unit price the seller will accept. */ min: number; /** Price the seller is hoping to achieve (defaults to the asking price). */ desired: number; } /** Resolve a listing's negotiation range. Non-negotiable listings have min === desired. */ export function negotiationRange( listing: Pick, ): NegotiationRange { const desired = listing.desiredPrice ?? listing.price ?? 0; const min = listing.minPrice ?? desired; return { min, desired }; } /** A listing is negotiable when the seller has set a floor below the asking price. */ export function isNegotiable( listing: Pick, ): boolean { const { min, desired } = negotiationRange(listing); return min < desired; } /** An entry is sellable if it carries no attachments or applied skin (those would be lost on sale). */ export function isEntryClean(entry: LockerItemEntry): boolean { const attachments = entry.equipment?.attachments ?? []; const skinId = resolveAssetId(entry.equipment?.skin); return attachments.length === 0 && skinId == null; } /** Total clean (sellable) quantity of an asset across a locker. */ export function countCleanQuantity(locker: LockerStorage, assetId: number): number { return (locker.items ?? []).reduce((sum, entry) => { if (resolveAssetId(entry.asset) !== assetId) return sum; if (!isEntryClean(entry)) return sum; return sum + (entry.quantity ?? 0); }, 0); } /** * Remove `quantity` of `assetId` from the locker, consuming clean entries * only. Throws if the locker does not hold enough clean stock. */ export function deductLockerQuantity( locker: LockerStorage, assetId: number, quantity: number, ): LockerItemEntry[] { const items = [...(locker.items ?? [])] as LockerItemEntry[]; let remaining = quantity; const result: LockerItemEntry[] = []; for (const entry of items) { if (resolveAssetId(entry.asset) !== assetId || !isEntryClean(entry) || remaining <= 0) { result.push(entry); continue; } const take = Math.min(entry.quantity, remaining); remaining -= take; if (entry.quantity - take > 0) { result.push({ ...entry, quantity: entry.quantity - take }); } } if (remaining > 0) { throw new Error("Not enough sellable stock in locker for this listing."); } return result; } /** * Add `quantity` of `assetId` to a user's locker, merging into existing * stacks and filling empty grid spots. Throws if there is no space. */ export async function creditLockerQuantity( payload: PayloadType, userId: number, assetId: number, quantity: number, ): Promise { const locker = await ensureLockerStorage(payload, userId); const lockerFull = (await payload.findByID({ collection: "locker-storages", id: locker.id, depth: 1, overrideAccess: true, })) as unknown as LockerStorage; const asset = (await payload.findByID({ collection: "assets", id: assetId, depth: 0, overrideAccess: true, })) as unknown as Asset; if (!asset) throw new Error("Asset not found."); const grid = await getLockerGridDimensions(payload); const items = [...(lockerFull.items ?? [])]; const gridItems = toLockerGridItems(items); const stackable = lockerIsStackable(asset); const maxStack = lockerMaxStack(asset); const footprint = getLockerItemFootprint(asset, false); let remaining = quantity; if (stackable) { for (const entry of gridItems) { if (remaining <= 0) break; if (entry.assetId !== assetId) continue; const space = maxStack === Infinity ? remaining : Math.max(0, Math.min(maxStack - entry.quantity, remaining)); if (space <= 0) continue; const idx = items.findIndex((e) => e.id === entry.id); if (idx >= 0) { items[idx] = { ...items[idx], quantity: (items[idx].quantity ?? 0) + space }; remaining -= space; } } } while (remaining > 0) { const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack); const spot = findLockerEmptySpot( gridItems, footprint.width, footprint.height, grid.width, grid.height, ); if (!spot) { throw new Error( `No space in buyer's locker for ${asset.name}. Needs ${footprint.width}×${footprint.height} free cells.`, ); } items.push({ asset: assetId, quantity: stackAmount, gridX: spot.x, gridY: spot.y, rotated: false, }); gridItems.push({ id: `new-${items.length}`, assetId, quantity: stackAmount, gridX: spot.x, gridY: spot.y, rotated: false, asset, }); remaining -= stackAmount; } return (await payload.update({ collection: "locker-storages", id: locker.id, data: { items }, overrideAccess: true, depth: 1, })) as unknown as LockerStorage; } export interface BuyResult { transactionId: number; total: number; unitPrice: number; quantity: number; currencyId: number | null; listing: MarketListing; } /** * Execute a purchase of an active listing: * 1. Ensures buyer + seller personal accounts (and treasury for NPC stock). * 2. Moves funds via `applyTransaction` (`payment` type). * 3. Credits the buyer's locker with `quantity` units. * 4. Decrements the listing's stock; the listing is marked sold only when the * last unit is bought (partial purchases leave it active). * * Pass `unitPrice` to buy at an agreed negotiation price instead of the * listing's asking price, and `quantity` to buy a subset of the stock * (defaults to the whole listing). */ export async function buyListing( payload: PayloadType, listing: MarketListing, buyer: User, options?: { unitPrice?: number; quantity?: number }, ): Promise { if (listing.status !== "active") { throw new Error("This listing is no longer active."); } const asset = listing.asset as Asset; if (!asset || typeof asset === "number") { throw new Error("Listing asset is not available."); } const sellerId = typeof listing.seller === "object" && listing.seller ? listing.seller.id : (listing.seller as number | null); if (sellerId != null && sellerId === buyer.id) { throw new Error("You cannot buy your own listing."); } const stock = listing.quantity ?? 1; const quantity = options?.quantity ?? stock; if (!Number.isInteger(quantity) || quantity <= 0 || quantity > stock) { throw new Error(`Quantity must be between 1 and ${stock}.`); } const currencyId = listing.currency ? typeof listing.currency === "object" && "id" in listing.currency ? (listing.currency.id as number) : (listing.currency as number) : await getMainCurrencyId(payload); const unitPrice = options?.unitPrice ?? listing.price; if (typeof unitPrice !== "number" || unitPrice <= 0) { throw new Error("Listing price must be greater than zero."); } const total = unitPrice * quantity; const buyerAccount = await ensurePersonalAccount(payload, buyer.id as number); let sellerAccountId: number | undefined; if (sellerId != null) { const sellerAccount = await ensurePersonalAccount(payload, sellerId); sellerAccountId = sellerAccount.id; } else { const treasuryId = await getTreasuryAccountId(payload); if (treasuryId) sellerAccountId = treasuryId; } const transaction = await applyTransaction(payload, { type: "payment", fromAccountId: buyerAccount.id, toAccountId: sellerAccountId, amount: total, memo: `Market purchase: ${quantity}× ${asset.name} @ ${unitPrice}`, actorId: buyer.id as number, }); await creditLockerQuantity(payload, buyer.id as number, asset.id, quantity); const remaining = stock - quantity; const updated = (await payload.update({ collection: "market-listings", id: listing.id, data: remaining > 0 ? { quantity: remaining } : { status: "sold", buyer: buyer.id as number, soldAt: new Date().toISOString() }, overrideAccess: true, depth: 1, })) as unknown as MarketListing; return { transactionId: transaction.id, total, unitPrice, quantity, currencyId, listing: updated, }; } /** * Expire a listing. User listings have their stock returned to the seller's * locker; auto-generated entries simply close. Throws if stock cannot be * returned (e.g. no locker space). */ export async function expireListing( payload: PayloadType, listing: MarketListing, ): Promise { const sellerId = typeof listing.seller === "object" && listing.seller ? listing.seller.id : (listing.seller as number | null); if (sellerId != null && !listing.isAutoGenerated) { await creditLockerQuantity( payload, sellerId, resolveAssetId(listing.asset) as number, listing.quantity, ); } return (await payload.update({ collection: "market-listings", id: listing.id, data: { status: "expired" }, overrideAccess: true, depth: 0, })) as unknown as MarketListing; } /** * Price an auto-generated entry as a deviation around the asset's base buy * price. `kind` mirrors the resting valuation: `buy` prices NPC stock near * the base buy price, `sell` prices it near the base sell price. */ export function autoPrice(asset: Asset, kind: "buy" | "sell" = "buy"): number | null { const base = kind === "buy" ? getBaseBuyPrice(asset) : getBaseSellPrice(asset); if (base == null || base <= 0) return null; const deviation = (Math.random() * 2 - 1) * 0.15; return Math.max(1, Math.round(base * (1 + deviation))); } /** Suggested auto-generated quantity: a few units for stackables, else 1. */ export function autoQuantity(asset: Asset): number { if (lockerIsStackable(asset)) { return Math.max(1, Math.floor(Math.random() * 3) + 1); } return 1; }