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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
d4c8d81e2f
commit
13f2132e25
5 changed files with 134 additions and 9 deletions
|
|
@ -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<ActionRe
|
|||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReferencePrices(
|
||||
assetIds: number[],
|
||||
): Promise<ActionResult<Record<number, number | null>>> {
|
||||
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<number, number | null> = {};
|
||||
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<ActionResult<{ reset: number }>> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [referencePrices, setReferencePrices] = useState<Record<number, number | null>>({});
|
||||
|
||||
const sellable = useMemo<SellableAsset[]>(() => {
|
||||
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
|
||||
</Badge>
|
||||
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.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
{canCancel && (
|
||||
<Button size="sm" variant="ghost" onClick={handleCancel} disabled={busy !== null}>
|
||||
<XIcon className="size-4" />
|
||||
{busy === "cancel" ? "..." : "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{canResetEconomy && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleResetEconomy}
|
||||
disabled={busy !== null}
|
||||
title="Rebuild this item's supply/demand ledger from its GM baselines"
|
||||
>
|
||||
{busy === "reset" ? "..." : "Reset economy"}
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button size="sm" variant="ghost" onClick={handleCancel} disabled={busy !== null}>
|
||||
<XIcon className="size-4" />
|
||||
{busy === "cancel" ? "..." : "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ItemActions>
|
||||
)}
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -211,6 +214,7 @@ export function MarketView({
|
|||
currentUserId={currentUserId}
|
||||
currencyConfig={currencyConfig}
|
||||
canManage={isManager}
|
||||
canResetEconomy={canResetEconomy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue