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}
/>
))}