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";
|
} 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue