1
0
Fork 0

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:
Jason Fraley 2026-09-06 17:22:34 -04:00
parent d4c8d81e2f
commit 13f2132e25
5 changed files with 134 additions and 9 deletions

View file

@ -17,6 +17,8 @@ import {
} from "@/lib/market"; } from "@/lib/market";
import { ensureLockerStorage, resolveAssetId } from "@/lib/locker"; import { ensureLockerStorage, resolveAssetId } from "@/lib/locker";
import { getMainCurrencyId } from "@/lib/banking"; import { getMainCurrencyId } from "@/lib/banking";
import { getBaseBuyPrice } from "@/lib/market";
import { resetMarketStateDocs } from "@/lib/economy/consumption";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
import { import {
expireNegotiationsForListing, expireNegotiationsForListing,
@ -999,3 +1001,74 @@ export async function cancelMarketOffer(negotiationId: number): Promise<ActionRe
: { success: false, error: message }; : { 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 };
}
}

View file

@ -77,6 +77,7 @@ export default async function MarketPage() {
}); });
const isManager = user ? await hasPermission(payload, user, "logistics:manage") : false; 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); const currencyConfig = await getCurrencyConfig(payload);
return ( return (
@ -99,6 +100,7 @@ export default async function MarketPage() {
currentUserId={userId} currentUserId={userId}
currencyConfig={currencyConfig} currencyConfig={currencyConfig}
isManager={isManager} isManager={isManager}
canResetEconomy={canResetEconomy}
/> />
</div> </div>
); );

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Asset, LockerStorage } from "@/payload-types"; import type { Asset, LockerStorage } from "@/payload-types";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
@ -26,6 +26,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { resolveAssetId } from "@/lib/locker"; import { resolveAssetId } from "@/lib/locker";
import { getReferencePrices } from "@/app/(frontend)/logistics/market/actions";
import { createMarketListing } from "@/app/(frontend)/logistics/market/actions"; import { createMarketListing } from "@/app/(frontend)/logistics/market/actions";
interface CreateListingDialogProps { interface CreateListingDialogProps {
@ -61,6 +62,7 @@ export function CreateListingDialog({
const [minPrice, setMinPrice] = useState(""); const [minPrice, setMinPrice] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [referencePrices, setReferencePrices] = useState<Record<number, number | null>>({});
const sellable = useMemo<SellableAsset[]>(() => { const sellable = useMemo<SellableAsset[]>(() => {
if (!locker?.items) return []; if (!locker?.items) return [];
@ -97,6 +99,19 @@ export function CreateListingDialog({
const maxQuantity = selected?.quantity ?? 0; const maxQuantity = selected?.quantity ?? 0;
const hasLockerItems = !!locker?.items?.length; 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() { function reset() {
setAssetId(""); setAssetId("");
setQuantity("1"); setQuantity("1");
@ -277,6 +292,9 @@ export function CreateListingDialog({
Tip Tip
</Badge> </Badge>
Resting valuation is {selected.basePrice.toLocaleString()} per unit. 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> </p>
)} )}

View file

@ -20,7 +20,7 @@ import { getAssetTypeLabel } from "@/lib/locker";
import { getRarityLabel, isNegotiable } from "@/lib/market"; import { getRarityLabel, isNegotiable } from "@/lib/market";
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format"; import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react"; 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 { Countdown } from "@/components/frontend/market/Countdown";
import { MakeOfferDialog } from "@/components/frontend/market/MakeOfferDialog"; import { MakeOfferDialog } from "@/components/frontend/market/MakeOfferDialog";
import { useNow } from "@/hooks/useNow"; import { useNow } from "@/hooks/useNow";
@ -30,6 +30,7 @@ interface ListingCardProps {
currentUserId: number; currentUserId: number;
currencyConfig: CurrencyConfig; currencyConfig: CurrencyConfig;
canManage: boolean; canManage: boolean;
canResetEconomy?: boolean;
} }
function npcLabel(listing: MarketListing): string { function npcLabel(listing: MarketListing): string {
@ -56,9 +57,10 @@ export function ListingCard({
currentUserId, currentUserId,
currencyConfig, currencyConfig,
canManage, canManage,
canResetEconomy = false,
}: ListingCardProps) { }: ListingCardProps) {
const router = useRouter(); 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 [error, setError] = useState<string | null>(null);
const [qty, setQty] = useState(1); 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 = const thumbnail =
typeof asset === "object" && asset && asset["Asset Metadata"]?.thumbnail typeof asset === "object" && asset && asset["Asset Metadata"]?.thumbnail
? (asset["Asset Metadata"].thumbnail as { url?: string; width?: number; height?: number }) ? (asset["Asset Metadata"].thumbnail as { url?: string; width?: number; height?: number })
@ -192,12 +207,25 @@ export function ListingCard({
/> />
)} )}
</div> </div>
<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 && ( {canCancel && (
<Button size="sm" variant="ghost" onClick={handleCancel} disabled={busy !== null}> <Button size="sm" variant="ghost" onClick={handleCancel} disabled={busy !== null}>
<XIcon className="size-4" /> <XIcon className="size-4" />
{busy === "cancel" ? "..." : "Cancel"} {busy === "cancel" ? "..." : "Cancel"}
</Button> </Button>
)} )}
</div>
</ItemActions> </ItemActions>
)} )}
</Item> </Item>

View file

@ -64,6 +64,7 @@ interface MarketViewProps {
currentUserId: number; currentUserId: number;
currencyConfig: CurrencyConfig; currencyConfig: CurrencyConfig;
isManager: boolean; isManager: boolean;
canResetEconomy: boolean;
} }
export function MarketView({ export function MarketView({
@ -74,6 +75,7 @@ export function MarketView({
currentUserId, currentUserId,
currencyConfig, currencyConfig,
isManager, isManager,
canResetEconomy,
}: MarketViewProps) { }: MarketViewProps) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [rarity, setRarity] = useState("all"); const [rarity, setRarity] = useState("all");
@ -183,6 +185,7 @@ export function MarketView({
currentUserId={currentUserId} currentUserId={currentUserId}
currencyConfig={currencyConfig} currencyConfig={currencyConfig}
canManage={isManager} canManage={isManager}
canResetEconomy={canResetEconomy}
/> />
))} ))}
</div> </div>
@ -211,6 +214,7 @@ export function MarketView({
currentUserId={currentUserId} currentUserId={currentUserId}
currencyConfig={currencyConfig} currencyConfig={currencyConfig}
canManage={isManager} canManage={isManager}
canResetEconomy={canResetEconomy}
/> />
))} ))}
</div> </div>