1
0
Fork 0
polaris-task-force/src/lib/market/index.ts
Z8MB1E 86464b528a refactor(market): reuse banking treasury account helper
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>
2026-09-06 01:23:57 -04:00

384 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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