From 13f2132e25bb9f5aad4591058af494224bc57d4e Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 6 Sep 2026 17:22:34 -0400 Subject: [PATCH] feat(market): add economy reference prices and per-listing reset tool Add getReferencePrices (modifier-applied NPC vendor price tip in CreateListingDialog) and resetEconomyState server action with a permission-gated 'Reset economy' button on listing cards for managers. Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent) Co-authored-by: Sisyphus --- .../(frontend)/logistics/market/actions.ts | 73 +++++++++++++++++++ src/app/(frontend)/logistics/market/page.tsx | 2 + .../frontend/market/CreateListingDialog.tsx | 20 ++++- .../frontend/market/ListingCard.tsx | 44 +++++++++-- src/components/frontend/market/MarketView.tsx | 4 + 5 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/app/(frontend)/logistics/market/actions.ts b/src/app/(frontend)/logistics/market/actions.ts index db7792a..a06ecb1 100644 --- a/src/app/(frontend)/logistics/market/actions.ts +++ b/src/app/(frontend)/logistics/market/actions.ts @@ -17,6 +17,8 @@ import { } from "@/lib/market"; import { ensureLockerStorage, resolveAssetId } from "@/lib/locker"; import { getMainCurrencyId } from "@/lib/banking"; +import { getBaseBuyPrice } from "@/lib/market"; +import { resetMarketStateDocs } from "@/lib/economy/consumption"; import { notifyUser } from "@/lib/notifications"; import { expireNegotiationsForListing, @@ -999,3 +1001,74 @@ export async function cancelMarketOffer(negotiationId: number): Promise>> { + try { + const { payload } = await authenticate(); + const ids = [...new Set(assetIds.filter((id) => Number.isInteger(id) && id > 0))]; + if (ids.length === 0) return { success: true, data: {} }; + + const assets = await payload.find({ + collection: "assets", + where: { id: { in: ids } }, + limit: 500, + depth: 0, + overrideAccess: true, + }); + + const states = await payload.find({ + collection: "market-state", + where: { and: [{ itemType: { equals: "asset" } }, { itemId: { in: ids } }] }, + limit: 500, + depth: 0, + overrideAccess: true, + }); + const modifierByItemId = new Map( + states.docs.map((doc) => [doc.itemId as number, doc.priceModifier as number]), + ); + + const references: Record = {}; + for (const asset of assets.docs) { + const base = getBaseBuyPrice(asset); + const modifier = modifierByItemId.get(asset.id); + references[asset.id] = + base == null ? null : Math.max(1, Math.round(base * (modifier ?? 1))); + } + return { success: true, data: references }; + } 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 resetEconomyState( + itemType: "resource" | "asset", + itemId: number, +): Promise> { + try { + const { payload, user } = await authenticate(); + const allowed = await hasPermission(payload, user, "market-state:update"); + if (!allowed) { + return { success: false, error: "You do not have permission to reset market state." }; + } + + const result = await resetMarketStateDocs(payload, { items: [{ itemType, itemId }] }); + await emitGameEvent(payload, { + type: EventTypes.EconomyReset, + message: `Market state reset for ${itemType} #${itemId} (${result.reset} doc(s) rebuilt from baselines).`, + targetCollection: "market-state", + targetId: itemId, + data: { itemType, itemId, reset: result.reset }, + }); + return { success: true, data: { reset: result.reset } }; + } 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 }; + } +} diff --git a/src/app/(frontend)/logistics/market/page.tsx b/src/app/(frontend)/logistics/market/page.tsx index 9b0c430..21442ab 100644 --- a/src/app/(frontend)/logistics/market/page.tsx +++ b/src/app/(frontend)/logistics/market/page.tsx @@ -77,6 +77,7 @@ export default async function MarketPage() { }); const isManager = user ? await hasPermission(payload, user, "logistics:manage") : false; + const canResetEconomy = user ? await hasPermission(payload, user, "market-state:update") : false; const currencyConfig = await getCurrencyConfig(payload); return ( @@ -99,6 +100,7 @@ export default async function MarketPage() { currentUserId={userId} currencyConfig={currencyConfig} isManager={isManager} + canResetEconomy={canResetEconomy} /> ); diff --git a/src/components/frontend/market/CreateListingDialog.tsx b/src/components/frontend/market/CreateListingDialog.tsx index 03d8528..cdf2e82 100644 --- a/src/components/frontend/market/CreateListingDialog.tsx +++ b/src/components/frontend/market/CreateListingDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import type { Asset, LockerStorage } from "@/payload-types"; import { PlusIcon } from "lucide-react"; @@ -26,6 +26,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { resolveAssetId } from "@/lib/locker"; +import { getReferencePrices } from "@/app/(frontend)/logistics/market/actions"; import { createMarketListing } from "@/app/(frontend)/logistics/market/actions"; interface CreateListingDialogProps { @@ -61,6 +62,7 @@ export function CreateListingDialog({ const [minPrice, setMinPrice] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [referencePrices, setReferencePrices] = useState>({}); const sellable = useMemo(() => { if (!locker?.items) return []; @@ -97,6 +99,19 @@ export function CreateListingDialog({ const maxQuantity = selected?.quantity ?? 0; const hasLockerItems = !!locker?.items?.length; + useEffect(() => { + if (!open || sellable.length === 0) return; + const ids = sellable.filter((s) => s.tradeable && s.isLive).map((s) => s.assetId); + if (ids.length === 0) return; + let cancelled = false; + getReferencePrices(ids).then((res) => { + if (!cancelled && res.success && res.data) setReferencePrices(res.data); + }); + return () => { + cancelled = true; + }; + }, [open, sellable]); + function reset() { setAssetId(""); setQuantity("1"); @@ -277,6 +292,9 @@ export function CreateListingDialog({ Tip Resting valuation is {selected.basePrice.toLocaleString()} per unit. + {referencePrices[selected.assetId] != null && + referencePrices[selected.assetId] !== selected.basePrice && + ` NPC vendors are around ${referencePrices[selected.assetId]?.toLocaleString()} per unit.`}

)} diff --git a/src/components/frontend/market/ListingCard.tsx b/src/components/frontend/market/ListingCard.tsx index d2d5eb8..4420726 100644 --- a/src/components/frontend/market/ListingCard.tsx +++ b/src/components/frontend/market/ListingCard.tsx @@ -20,7 +20,7 @@ import { getAssetTypeLabel } from "@/lib/locker"; import { getRarityLabel, isNegotiable } from "@/lib/market"; import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react"; -import { buyMarketListing, cancelMarketListing } from "@/app/(frontend)/logistics/market/actions"; +import { buyMarketListing, cancelMarketListing, resetEconomyState } 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"; @@ -30,6 +30,7 @@ interface ListingCardProps { currentUserId: number; currencyConfig: CurrencyConfig; canManage: boolean; + canResetEconomy?: boolean; } function npcLabel(listing: MarketListing): string { @@ -56,9 +57,10 @@ export function ListingCard({ currentUserId, currencyConfig, canManage, + canResetEconomy = false, }: ListingCardProps) { const router = useRouter(); - const [busy, setBusy] = useState<"buy" | "cancel" | null>(null); + const [busy, setBusy] = useState<"buy" | "cancel" | "reset" | null>(null); const [error, setError] = useState(null); const [qty, setQty] = useState(1); @@ -107,6 +109,19 @@ export function ListingCard({ } } + async function handleResetEconomy() { + setBusy("reset"); + setError(null); + const assetId = typeof asset === "object" && asset ? asset.id : (listing.asset as number); + const res = await resetEconomyState("asset", assetId); + if (res.success) { + router.refresh(); + } else { + setError(res.error ?? "Failed to reset market state."); + setBusy(null); + } + } + const thumbnail = typeof asset === "object" && asset && asset["Asset Metadata"]?.thumbnail ? (asset["Asset Metadata"].thumbnail as { url?: string; width?: number; height?: number }) @@ -192,12 +207,25 @@ export function ListingCard({ /> )} - {canCancel && ( - - )} +
+ {canResetEconomy && ( + + )} + {canCancel && ( + + )} +
)} diff --git a/src/components/frontend/market/MarketView.tsx b/src/components/frontend/market/MarketView.tsx index a732907..c863670 100644 --- a/src/components/frontend/market/MarketView.tsx +++ b/src/components/frontend/market/MarketView.tsx @@ -64,6 +64,7 @@ interface MarketViewProps { currentUserId: number; currencyConfig: CurrencyConfig; isManager: boolean; + canResetEconomy: boolean; } export function MarketView({ @@ -74,6 +75,7 @@ export function MarketView({ currentUserId, currencyConfig, isManager, + canResetEconomy, }: MarketViewProps) { const [search, setSearch] = useState(""); const [rarity, setRarity] = useState("all"); @@ -183,6 +185,7 @@ export function MarketView({ currentUserId={currentUserId} currencyConfig={currencyConfig} canManage={isManager} + canResetEconomy={canResetEconomy} /> ))} @@ -211,6 +214,7 @@ export function MarketView({ currentUserId={currentUserId} currencyConfig={currencyConfig} canManage={isManager} + canResetEconomy={canResetEconomy} /> ))}