Drops market's local treasury account lookup in favor of getTreasuryAccountId from the banking lib, re-exported for compatibility. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
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<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;
|
||
}
|
||
|
||
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;
|
||
}
|