1
0
Fork 0

style(app): format app routes, pages, and server actions

This commit is contained in:
Jason Fraley 2026-08-16 23:27:51 -04:00
parent 05c7c83799
commit 7db1f648bf
32 changed files with 517 additions and 579 deletions

View file

@ -9,12 +9,8 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
import { plaintextToLexical } from "@/lib/tickets/lexical"; import { plaintextToLexical } from "@/lib/tickets/lexical";
import { getStaffUserIds } from "@/lib/tickets/staff"; import { getStaffUserIds } from "@/lib/tickets/staff";
import {
CLOSING_STATUSES,
STATUS_LABEL,
TICKET_STATUSES,
} from "@/lib/tickets/ticketMeta";
import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta"; import type { TicketCategory, TicketPriority, TicketStatus } from "@/lib/tickets/ticketMeta";
import { CLOSING_STATUSES, STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -36,7 +32,10 @@ async function authenticate() {
return { payload, user }; return { payload, user };
} }
function displayNameOf(user: { payloadDisplayName?: string | null; username?: string | null }): string { function displayNameOf(user: {
payloadDisplayName?: string | null;
username?: string | null;
}): string {
return user.payloadDisplayName || user.username || "A member"; return user.payloadDisplayName || user.username || "A member";
} }
@ -169,9 +168,7 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
await payload.update({ await payload.update({
collection: "tickets", collection: "tickets",
id: ticketId, id: ticketId,
data: reopen data: reopen ? { activity: newActivity, status: "open" } : { activity: newActivity },
? { activity: newActivity, status: "open" }
: { activity: newActivity },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
}); });
@ -229,7 +226,10 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
} }
} }
export async function updateTicketStatus(ticketId: number, status: TicketStatus): Promise<ActionResult> { export async function updateTicketStatus(
ticketId: number,
status: TicketStatus,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const userId = user.id as number; const userId = user.id as number;
@ -245,9 +245,7 @@ export async function updateTicketStatus(ticketId: number, status: TicketStatus)
const reporterId = reporterIdOf(ticket); const reporterId = reporterIdOf(ticket);
if (!isStaff) { if (!isStaff) {
const reporterCancel = const reporterCancel =
reporterId === userId && reporterId === userId && status === "canceled" && !CLOSING_STATUSES.includes(ticket.status);
status === "canceled" &&
!CLOSING_STATUSES.includes(ticket.status);
if (!reporterCancel) throw new Error("Only staff can change ticket status."); if (!reporterCancel) throw new Error("Only staff can change ticket status.");
} }
@ -293,7 +291,10 @@ export async function updateTicketStatus(ticketId: number, status: TicketStatus)
} }
} }
export async function assignTicket(ticketId: number, assigneeId: number | null): Promise<ActionResult> { export async function assignTicket(
ticketId: number,
assigneeId: number | null,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const userId = user.id as number; const userId = user.id as number;

View file

@ -2,13 +2,12 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Faction, Media, Mission } from "@/payload-types"; import type { Faction, Media, Mission } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CalendarDaysIcon, CalendarDaysIcon,
ChevronRightIcon, ChevronRightIcon,
CrosshairIcon,
ShieldIcon, ShieldIcon,
TargetIcon, TargetIcon,
UsersIcon, UsersIcon,

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Faction, Media, Mission, BankAccount, Resource, Campaign } from "@/payload-types"; import type { BankAccount, Campaign, Faction, Media, Mission, Resource } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { import {
@ -10,8 +10,8 @@ import {
ChevronRightIcon, ChevronRightIcon,
FlagIcon, FlagIcon,
LandmarkIcon, LandmarkIcon,
ShieldIcon,
ScaleIcon, ScaleIcon,
ShieldIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { formatAmount } from "@/lib/banking/format"; import { formatAmount } from "@/lib/banking/format";

View file

@ -37,8 +37,8 @@ export default async function FactionsPage() {
<h1 className="text-lg font-semibold">Factions</h1> <h1 className="text-lg font-semibold">Factions</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in {factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in the theater of
the theater of operations. operations.
</p> </p>
</div> </div>

View file

@ -3,35 +3,18 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { import type { Campaign, Map, Media, Mission } from "@/payload-types";
Mission,
Campaign,
Map,
User,
Faction,
Qualification,
Rank,
Media,
} from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ArrowRightIcon,
CalendarDaysIcon, CalendarDaysIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
MapPinIcon,
CrosshairIcon, CrosshairIcon,
ServerIcon,
FlagIcon, FlagIcon,
MapPinIcon,
ServerIcon,
} from "lucide-react"; } from "lucide-react";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection"; import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives"; import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions"; import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
@ -40,10 +23,10 @@ import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogi
import { MissionComms } from "@/components/frontend/intelligence/MissionComms"; import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance"; import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
import { import {
type AttendanceResponse,
getMissionAttendance, getMissionAttendance,
getUserAttendance, getUserAttendance,
isMissionPast, isMissionPast,
type AttendanceResponse,
} from "@/lib/attendance"; } from "@/lib/attendance";
interface MissionDetailPageProps { interface MissionDetailPageProps {

View file

@ -2,8 +2,8 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
import type { AttendanceResponse } from "@/lib/attendance"; import type { AttendanceResponse } from "@/lib/attendance";
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;

View file

@ -1,6 +1,6 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Mission, Campaign, Map } from "@/payload-types"; import type { Campaign, Mission } from "@/payload-types";
import { CrosshairIcon } from "lucide-react"; import { CrosshairIcon } from "lucide-react";
import { MissionsList } from "@/components/frontend/intelligence/MissionsList"; import { MissionsList } from "@/components/frontend/intelligence/MissionsList";

View file

@ -3,16 +3,10 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Campaign, Mission, Faction } from "@/payload-types"; import type { Campaign, Faction } from "@/payload-types";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { CrosshairIcon, ArrowRightIcon } from "lucide-react"; import { ArrowRightIcon, CrosshairIcon } from "lucide-react";
import { import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard"; import { CampaignCard } from "@/components/frontend/intelligence/CampaignCard";
export const metadata = { export const metadata = {
@ -55,8 +49,7 @@ export default async function IntelligenceOverviewPage() {
<h1 className="text-lg font-semibold">Intelligence</h1> <h1 className="text-lg font-semibold">Intelligence</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Campaigns, missions, and faction intelligence for the theater of Campaigns, missions, and faction intelligence for the theater of operations.
operations.
</p> </p>
</div> </div>
@ -71,9 +64,7 @@ export default async function IntelligenceOverviewPage() {
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" /> <ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<ItemDescription> <ItemDescription>Active and completed campaigns across all theaters.</ItemDescription>
Active and completed campaigns across all theaters.
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</Link> </Link>
@ -87,9 +78,7 @@ export default async function IntelligenceOverviewPage() {
<ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" /> <ArrowRightIcon className="size-4 text-muted-foreground group-hover:text-primary transition-colors" />
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<ItemDescription> <ItemDescription>Operation briefings, objectives, and schedules.</ItemDescription>
Operation briefings, objectives, and schedules.
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</Link> </Link>
@ -104,8 +93,7 @@ export default async function IntelligenceOverviewPage() {
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<ItemDescription> <ItemDescription>
{factions.length} faction{factions.length !== 1 ? "s" : ""}{" "} {factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in the AO.
tracked in the AO.
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>

View file

@ -11,9 +11,9 @@ import {
autoArrangeLockerItems, autoArrangeLockerItems,
ensureLockerStorage, ensureLockerStorage,
findLockerEmptySpot, findLockerEmptySpot,
getLoadoutSlot,
getLockerGridDimensions, getLockerGridDimensions,
getLockerItemFootprint, getLockerItemFootprint,
getLoadoutSlot,
isAttachmentCompatible, isAttachmentCompatible,
isLockerPlacementValid, isLockerPlacementValid,
isSkinAsset, isSkinAsset,
@ -23,7 +23,6 @@ import {
resolveAssetId, resolveAssetId,
skinAppliesTo, skinAppliesTo,
toLockerGridItems, toLockerGridItems,
type LockerGridItem,
} from "@/lib/locker"; } from "@/lib/locker";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
@ -219,7 +218,8 @@ export async function removeLockerItem(entryId: string): Promise<ActionResult> {
const removed = items[idx]; const removed = items[idx];
const asset = removed.asset as Asset; const asset = removed.asset as Asset;
const removedName = typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(removed.asset)}`; const removedName =
typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(removed.asset)}`;
items.splice(idx, 1); items.splice(idx, 1);
await payload.update({ await payload.update({
@ -236,7 +236,12 @@ export async function removeLockerItem(entryId: string): Promise<ActionResult> {
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,
data: { entryId, assetId: resolveAssetId(removed.asset), assetName: removedName, quantity: removed.quantity }, data: {
entryId,
assetId: resolveAssetId(removed.asset),
assetName: removedName,
quantity: removed.quantity,
},
}); });
return { success: true }; return { success: true };
@ -269,19 +274,33 @@ export async function moveLockerItem(
const effectiveRotated = rotated ?? entry.rotated ?? false; const effectiveRotated = rotated ?? entry.rotated ?? false;
const fp = getLockerItemFootprint(asset, effectiveRotated); const fp = getLockerItemFootprint(asset, effectiveRotated);
if (gridX < 0 || gridY < 0 || gridX + fp.width > grid.width || gridY + fp.height > grid.height) { if (
gridX < 0 ||
gridY < 0 ||
gridX + fp.width > grid.width ||
gridY + fp.height > grid.height
) {
return { success: false, error: "Placement is outside grid bounds." }; return { success: false, error: "Placement is outside grid bounds." };
} }
const gridItems = toLockerGridItems(items); const gridItems = toLockerGridItems(items);
if (!isLockerPlacementValid(gridItems, entryId, gridX, gridY, fp.width, fp.height, grid.width, grid.height)) { if (
!isLockerPlacementValid(
gridItems,
entryId,
gridX,
gridY,
fp.width,
fp.height,
grid.width,
grid.height,
)
) {
return { success: false, error: "Cannot move here — overlaps with another item." }; return { success: false, error: "Cannot move here — overlaps with another item." };
} }
items[idx] = items[idx] =
rotated !== undefined rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY };
? { ...entry, gridX, gridY, rotated }
: { ...entry, gridX, gridY };
await payload.update({ await payload.update({
collection: "locker-storages", collection: "locker-storages",
@ -297,7 +316,14 @@ export async function moveLockerItem(
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,
data: { entryId, assetId: asset.id, assetName: asset.name, gridX, gridY, rotated: effectiveRotated }, data: {
entryId,
assetId: asset.id,
assetName: asset.name,
gridX,
gridY,
rotated: effectiveRotated,
},
}); });
return { success: true }; return { success: true };
@ -321,7 +347,8 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
const entry = items[idx]; const entry = items[idx];
const asset = entry.asset as Asset; const asset = entry.asset as Asset;
if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." }; if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
if (!lockerIsRotatable(asset)) return { success: false, error: "This asset cannot be rotated." }; if (!lockerIsRotatable(asset))
return { success: false, error: "This asset cannot be rotated." };
const newRotated = !(entry.rotated ?? false); const newRotated = !(entry.rotated ?? false);
const fp = getLockerItemFootprint(asset, newRotated); const fp = getLockerItemFootprint(asset, newRotated);
@ -331,7 +358,18 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
} }
const gridItems = toLockerGridItems(items); const gridItems = toLockerGridItems(items);
if (!isLockerPlacementValid(gridItems, entryId, entry.gridX, entry.gridY, fp.width, fp.height, grid.width, grid.height)) { if (
!isLockerPlacementValid(
gridItems,
entryId,
entry.gridX,
entry.gridY,
fp.width,
fp.height,
grid.width,
grid.height,
)
) {
return { success: false, error: "Rotation would overlap with another item." }; return { success: false, error: "Rotation would overlap with another item." };
} }
@ -362,10 +400,7 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
} }
} }
export async function mergeLockerStacks( export async function mergeLockerStacks(sourceId: string, targetId: string): Promise<ActionResult> {
sourceId: string,
targetId: string,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number); const locker = await getLockerWithItems(payload, user.id as number);
@ -385,7 +420,8 @@ export async function mergeLockerStacks(
if (srcAsset.id !== tgtAsset.id) { if (srcAsset.id !== tgtAsset.id) {
return { success: false, error: "Cannot merge different asset types." }; return { success: false, error: "Cannot merge different asset types." };
} }
if (!lockerIsStackable(tgtAsset)) return { success: false, error: "This asset cannot be stacked." }; if (!lockerIsStackable(tgtAsset))
return { success: false, error: "This asset cannot be stacked." };
const maxStack = lockerMaxStack(tgtAsset); const maxStack = lockerMaxStack(tgtAsset);
const spaceInTarget = maxStack === Infinity ? Infinity : Math.max(0, maxStack - tgt.quantity); const spaceInTarget = maxStack === Infinity ? Infinity : Math.max(0, maxStack - tgt.quantity);
@ -426,7 +462,10 @@ export async function mergeLockerStacks(
} }
} }
export async function splitLockerStack(entryId: string, splitAmount: number): Promise<ActionResult> { export async function splitLockerStack(
entryId: string,
splitAmount: number,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!Number.isInteger(splitAmount) || splitAmount <= 0) { if (!Number.isInteger(splitAmount) || splitAmount <= 0) {
@ -445,7 +484,8 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr
} }
const asset = entry.asset as Asset; const asset = entry.asset as Asset;
if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." }; if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
if (!lockerIsStackable(asset)) return { success: false, error: "This asset cannot be stacked." }; if (!lockerIsStackable(asset))
return { success: false, error: "This asset cannot be stacked." };
const gridItems = toLockerGridItems(items); const gridItems = toLockerGridItems(items);
const fp = getLockerItemFootprint(asset, entry.rotated ?? false); const fp = getLockerItemFootprint(asset, entry.rotated ?? false);
@ -475,7 +515,13 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,
data: { entryId, assetId: asset.id, assetName: asset.name, splitAmount, remainingAmount: entry.quantity - splitAmount }, data: {
entryId,
assetId: asset.id,
assetName: asset.name,
splitAmount,
remainingAmount: entry.quantity - splitAmount,
},
}); });
return { success: true }; return { success: true };
@ -674,7 +720,7 @@ export async function setLoadoutSlot(
}); });
const equippedName = assetId const equippedName = assetId
? (await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}` ? ((await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}`)
: null; : null;
await emitGameEvent(payload, { await emitGameEvent(payload, {
@ -778,10 +824,7 @@ export async function equipLoadout(loadoutId: number, equipped = true): Promise<
// --- Equipment customization (attachments & skins) --- // --- Equipment customization (attachments & skins) ---
export async function setItemAttachment( export async function setItemAttachment(entryId: string, assetId: number): Promise<ActionResult> {
entryId: string,
assetId: number,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number); const locker = await getLockerWithItems(payload, user.id as number);
@ -954,7 +997,8 @@ export async function setItemSkin(entryId: string, assetId: number): Promise<Act
const target = items[targetIdx]; const target = items[targetIdx];
const equipment = target.asset as Asset; const equipment = target.asset as Asset;
if (!equipment || typeof equipment === "number") return { success: false, error: "Invalid asset." }; if (!equipment || typeof equipment === "number")
return { success: false, error: "Invalid asset." };
const skin = await getAssetOrThrow(payload, assetId); const skin = await getAssetOrThrow(payload, assetId);
if (!isSkinAsset(skin)) return { success: false, error: `"${skin.name}" is not a skin.` }; if (!isSkinAsset(skin)) return { success: false, error: `"${skin.name}" is not a skin.` };
@ -1056,7 +1100,13 @@ export async function removeItemSkin(entryId: string): Promise<ActionResult> {
const skin = await getAssetOrThrow(payload, skinId); const skin = await getAssetOrThrow(payload, skinId);
const fp = getLockerItemFootprint(skin, false); const fp = getLockerItemFootprint(skin, false);
const spot = findLockerEmptySpot(toLockerGridItems(items), fp.width, fp.height, grid.width, grid.height); const spot = findLockerEmptySpot(
toLockerGridItems(items),
fp.width,
fp.height,
grid.width,
grid.height,
);
if (!spot) return { success: false, error: `No space in locker to return ${skin.name}.` }; if (!spot) return { success: false, error: `No space in locker to return ${skin.name}.` };
const updatedItems = [...items]; const updatedItems = [...items];
@ -1092,10 +1142,7 @@ export async function removeItemSkin(entryId: string): Promise<ActionResult> {
} }
} }
export async function renameLockerItem( export async function renameLockerItem(entryId: string, customName: string): Promise<ActionResult> {
entryId: string,
customName: string,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number); const locker = await getLockerWithItems(payload, user.id as number);
@ -1121,9 +1168,7 @@ export async function renameLockerItem(
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.LockerItemRename, type: EventTypes.LockerItemRename,
message: name message: name ? `Renamed ${asset.name} to "${name}"` : `Cleared custom name on ${asset.name}`,
? `Renamed ${asset.name} to "${name}"`
: `Cleared custom name on ${asset.name}`,
actor: user.id, actor: user.id,
targetCollection: "locker-storages", targetCollection: "locker-storages",
targetId: locker.id, targetId: locker.id,

View file

@ -47,10 +47,7 @@ export default async function AccountPage({ params }: AccountPageProps) {
? typedAccount.ownerUser?.id ? typedAccount.ownerUser?.id
: typedAccount.ownerUser; : typedAccount.ownerUser;
const isOwner = const isOwner = typedAccount.accountType === "personal" && user !== null && ownerId === user.id;
typedAccount.accountType === "personal" &&
user !== null &&
ownerId === user.id;
if (!isManager && !isOwner) { if (!isManager && !isOwner) {
notFound(); notFound();

View file

@ -2,13 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
GameVehicle,
Vehicle,
GameStructure,
Structure,
Resource,
} from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
FuelIcon, FuelIcon,
@ -38,9 +32,7 @@ const statusColors: Record<string, string> = {
destroyed: "bg-red-500/20 text-red-400", destroyed: "bg-red-500/20 text-red-400",
}; };
export default async function GameVehicleDetailPage({ export default async function GameVehicleDetailPage({ params }: GameVehicleDetailPageProps) {
params,
}: GameVehicleDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -59,11 +51,9 @@ export default async function GameVehicleDetailPage({
const vehicleData = vehicle as unknown as GameVehicle & { const vehicleData = vehicle as unknown as GameVehicle & {
type: Vehicle; type: Vehicle;
}; };
const vehicleType = const vehicleType = typeof vehicleData.type === "object" ? vehicleData.type : null;
typeof vehicleData.type === "object" ? vehicleData.type : null;
const deployedAt = const deployedAt =
typeof vehicleData.deployedAt === "object" && typeof vehicleData.deployedAt === "object" && vehicleData.deployedAt !== null
vehicleData.deployedAt !== null
? (vehicleData.deployedAt as GameStructure & { type: Structure }) ? (vehicleData.deployedAt as GameStructure & { type: Structure })
: null; : null;
const faction = const faction =
@ -100,8 +90,7 @@ export default async function GameVehicleDetailPage({
> >
{vehicleData.status === "in_transit" {vehicleData.status === "in_transit"
? "In Transit" ? "In Transit"
: vehicleData.status.charAt(0).toUpperCase() + : vehicleData.status.charAt(0).toUpperCase() + vehicleData.status.slice(1)}
vehicleData.status.slice(1)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{vehicleType?.name ?? "Unknown type"} {vehicleType?.name ?? "Unknown type"}
@ -118,8 +107,7 @@ export default async function GameVehicleDetailPage({
<ItemContent> <ItemContent>
<ItemTitle>Fuel</ItemTitle> <ItemTitle>Fuel</ItemTitle>
<ItemDescription> <ItemDescription>
{vehicleData.currentFuel?.toFixed(1)} /{" "} {vehicleData.currentFuel?.toFixed(1)} / {vehicleType?.fuel?.fuelCapacity ?? "?"}
{vehicleType?.fuel?.fuelCapacity ?? "?"}
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
@ -129,9 +117,7 @@ export default async function GameVehicleDetailPage({
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Health</ItemTitle> <ItemTitle>Health</ItemTitle>
<ItemDescription> <ItemDescription>{vehicleData.currentHealth}</ItemDescription>
{vehicleData.currentHealth}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted" size="sm"> <Item variant="muted" size="sm">
@ -140,9 +126,7 @@ export default async function GameVehicleDetailPage({
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Location</ItemTitle> <ItemTitle>Location</ItemTitle>
<ItemDescription> <ItemDescription>{deployedAt?.name ?? "In transit"}</ItemDescription>
{deployedAt?.name ?? "In transit"}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
<Item variant="muted" size="sm"> <Item variant="muted" size="sm">
@ -151,9 +135,7 @@ export default async function GameVehicleDetailPage({
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Faction</ItemTitle> <ItemTitle>Faction</ItemTitle>
<ItemDescription> <ItemDescription>{faction?.name ?? "Unassigned"}</ItemDescription>
{faction?.name ?? "Unassigned"}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</div> </div>
@ -180,23 +162,16 @@ export default async function GameVehicleDetailPage({
}} }}
/> />
</div> </div>
<span className="text-sm font-mono font-semibold"> <span className="text-sm font-mono font-semibold">{fuelPercent.toFixed(0)}%</span>
{fuelPercent.toFixed(0)}%
</span>
</div> </div>
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span> <span>{vehicleData.currentFuel?.toFixed(1)} units remaining</span>
{vehicleData.currentFuel?.toFixed(1)} units remaining <span>Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units</span>
</span>
<span>
Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units
</span>
</div> </div>
{vehicleType?.fuel?.fuelType && {vehicleType?.fuel?.fuelType && typeof vehicleType.fuel.fuelType === "object" && (
typeof vehicleType.fuel.fuelType === "object" && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Fuel type: {vehicleType.fuel.fuelType.name} · Consumption Fuel type: {vehicleType.fuel.fuelType.name} · Consumption rate:{" "}
rate: {vehicleType.fuel.fuelConsumptionRate}/unit {vehicleType.fuel.fuelConsumptionRate}/unit
</div> </div>
)} )}
</ItemContent> </ItemContent>
@ -227,14 +202,9 @@ export default async function GameVehicleDetailPage({
<tbody> <tbody>
{storedResources.map((entry, index) => { {storedResources.map((entry, index) => {
const resource = const resource =
typeof entry.resource === "object" typeof entry.resource === "object" ? (entry.resource as Resource) : null;
? (entry.resource as Resource)
: null;
return ( return (
<tr <tr key={index} className="border-b border-border/50">
key={index}
className="border-b border-border/50"
>
<td className="px-4 py-2 font-medium"> <td className="px-4 py-2 font-medium">
{resource?.name ?? `Resource #${entry.resource}`} {resource?.name ?? `Resource #${entry.resource}`}
</td> </td>
@ -248,9 +218,7 @@ export default async function GameVehicleDetailPage({
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">Vehicle cargo is empty.</p>
Vehicle cargo is empty.
</p>
)} )}
</ItemContent> </ItemContent>
</Item> </Item>
@ -266,17 +234,14 @@ export default async function GameVehicleDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-col gap-2 text-sm"> <div className="flex flex-col gap-2 text-sm">
<div> <div>
<p className="font-semibold"> <p className="font-semibold">{vehicleType?.name ?? "Unknown"}</p>
{vehicleType?.name ?? "Unknown"}
</p>
{vehicleType?.classname && ( {vehicleType?.classname && (
<p className="text-xs text-muted-foreground font-mono"> <p className="text-xs text-muted-foreground font-mono">
{vehicleType.classname} {vehicleType.classname}
</p> </p>
)} )}
</div> </div>
{vehicleType?.category && {vehicleType?.category && vehicleType.category.length > 0 && (
vehicleType.category.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{vehicleType.category.map((cat) => ( {vehicleType.category.map((cat) => (
<span <span

View file

@ -52,17 +52,13 @@ export default async function GameVehiclesPage() {
{vehicles.length > 0 ? ( {vehicles.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{vehicles.map((vehicle) => { {vehicles.map((vehicle) => {
const vehicleType = const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
typeof vehicle.type === "object" ? vehicle.type : null;
const fuelPercent = vehicleType?.fuel?.fuelCapacity const fuelPercent = vehicleType?.fuel?.fuelCapacity
? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100 ? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100
: 0; : 0;
return ( return (
<Link <Link key={vehicle.id} href={`/logistics/game-vehicles/${vehicle.id}`}>
key={vehicle.id}
href={`/logistics/game-vehicles/${vehicle.id}`}
>
<Item <Item
variant="outline" variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]" className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
@ -84,8 +80,7 @@ export default async function GameVehiclesPage() {
> >
{vehicle.status === "in_transit" {vehicle.status === "in_transit"
? "In Transit" ? "In Transit"
: vehicle.status.charAt(0).toUpperCase() + : vehicle.status.charAt(0).toUpperCase() + vehicle.status.slice(1)}
vehicle.status.slice(1)}
</span> </span>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
@ -94,9 +89,7 @@ export default async function GameVehiclesPage() {
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden"> <div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${ className={`h-full rounded-full transition-all ${
fuelPercent > 20 fuelPercent > 20 ? "bg-green-500" : "bg-red-500"
? "bg-green-500"
: "bg-red-500"
}`} }`}
style={{ style={{
width: `${Math.min(100, fuelPercent)}%`, width: `${Math.min(100, fuelPercent)}%`,
@ -112,16 +105,12 @@ export default async function GameVehiclesPage() {
<span>HP: {vehicle.currentHealth}</span> <span>HP: {vehicle.currentHealth}</span>
{vehicleType?.fuel?.fuelType && {vehicleType?.fuel?.fuelType &&
typeof vehicleType.fuel.fuelType === "object" && ( typeof vehicleType.fuel.fuelType === "object" && (
<span> <span>{vehicleType.fuel.fuelType.name}</span>
{vehicleType.fuel.fuelType.name}
</span>
)} )}
</div> </div>
</div> </div>
</ItemContent> </ItemContent>
<ItemDescription> <ItemDescription>{vehicleType?.name ?? "Unknown type"}</ItemDescription>
{vehicleType?.name ?? "Unknown type"}
</ItemDescription>
</Item> </Item>
</Link> </Link>
); );

View file

@ -7,17 +7,22 @@ import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import {
buyListing,
countCleanQuantity, countCleanQuantity,
creditLockerQuantity, creditLockerQuantity,
deductLockerQuantity, deductLockerQuantity,
buyListing,
negotiationRange, negotiationRange,
USER_LISTING_DURATION_MS, USER_LISTING_DURATION_MS,
} 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 { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
import { expireNegotiationsForListing, npcDialogue, npcResponseToOffer, type NpcState } from "@/lib/market/negotiations"; import {
expireNegotiationsForListing,
npcDialogue,
npcResponseToOffer,
type NpcState,
} from "@/lib/market/negotiations";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -86,7 +91,9 @@ async function getListingOrThrow(
function assetNameOf(listing: MarketListing): string { function assetNameOf(listing: MarketListing): string {
const asset = listing.asset as Asset; const asset = listing.asset as Asset;
return typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`; return typeof asset === "object" && asset
? asset.name
: `asset #${resolveAssetId(listing.asset)}`;
} }
async function getNegotiationOrThrow( async function getNegotiationOrThrow(
@ -94,7 +101,12 @@ async function getNegotiationOrThrow(
negotiationId: number, negotiationId: number,
): Promise<MarketNegotiation> { ): Promise<MarketNegotiation> {
const negotiation = (await payload const negotiation = (await payload
.findByID({ collection: "market-negotiations", id: negotiationId, depth: 2, overrideAccess: true }) .findByID({
collection: "market-negotiations",
id: negotiationId,
depth: 2,
overrideAccess: true,
})
.catch(() => null)) as unknown as MarketNegotiation | null; .catch(() => null)) as unknown as MarketNegotiation | null;
if (!negotiation) throw new Error("Negotiation not found."); if (!negotiation) throw new Error("Negotiation not found.");
return negotiation; return negotiation;
@ -146,10 +158,7 @@ function enforceHigherBuyerOffer(negotiation: MarketNegotiation, amount: number)
} }
/** Role of the acting user in a negotiation: "buyer", "seller", or null. */ /** Role of the acting user in a negotiation: "buyer", "seller", or null. */
function partyRoleOf( function partyRoleOf(negotiation: MarketNegotiation, userId: number): "buyer" | "seller" | null {
negotiation: MarketNegotiation,
userId: number,
): "buyer" | "seller" | null {
if (negotiationBuyerId(negotiation) === userId) return "buyer"; if (negotiationBuyerId(negotiation) === userId) return "buyer";
const listing = negotiationListing(negotiation); const listing = negotiationListing(negotiation);
if (typeof listing === "object" && listing) { if (typeof listing === "object" && listing) {
@ -175,7 +184,7 @@ async function finalizeNegotiation(
const buyerId = buyer.id as number; const buyerId = buyer.id as number;
const sellerId = sellerIdOf(listing); const sellerId = sellerIdOf(listing);
const assetName = assetNameOf(listing); const assetName = assetNameOf(listing);
const quantity = negotiation.quantity ?? (listing.quantity ?? 1); const quantity = negotiation.quantity ?? listing.quantity ?? 1;
const total = agreedPrice * quantity; const total = agreedPrice * quantity;
const result = await buyListing(payload, listing, buyer, { unitPrice: agreedPrice, quantity }); const result = await buyListing(payload, listing, buyer, { unitPrice: agreedPrice, quantity });
@ -319,7 +328,7 @@ async function resolveNpcOffer(
targetId: negotiation.id, targetId: negotiation.id,
data: { data: {
listingId: listing.id, listingId: listing.id,
quantity: negotiation.quantity ?? (listing.quantity ?? 1), quantity: negotiation.quantity ?? listing.quantity ?? 1,
amount, amount,
counter: response.amount, counter: response.amount,
patience: response.patience, patience: response.patience,
@ -343,7 +352,7 @@ async function resolveNpcOffer(
targetId: negotiation.id, targetId: negotiation.id,
data: { data: {
listingId: listing.id, listingId: listing.id,
quantity: negotiation.quantity ?? (listing.quantity ?? 1), quantity: negotiation.quantity ?? listing.quantity ?? 1,
amount, amount,
counter: response.amount, counter: response.amount,
patience: response.patience, patience: response.patience,
@ -472,7 +481,12 @@ export async function cancelMarketListing(listingId: number): Promise<ActionResu
const assetName = const assetName =
typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`; typeof asset === "object" && asset ? asset.name : `asset #${resolveAssetId(listing.asset)}`;
await creditLockerQuantity(payload, sellerId, resolveAssetId(listing.asset) as number, listing.quantity); await creditLockerQuantity(
payload,
sellerId,
resolveAssetId(listing.asset) as number,
listing.quantity,
);
await payload.update({ await payload.update({
collection: "market-listings", collection: "market-listings",
@ -517,7 +531,7 @@ export async function buyMarketListing(
return { success: false, error: "This listing is no longer active." }; return { success: false, error: "This listing is no longer active." };
} }
const qty = quantity ?? (listing.quantity ?? 1); const qty = quantity ?? listing.quantity ?? 1;
if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) { if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) {
return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` }; return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` };
} }
@ -580,7 +594,7 @@ export async function makeMarketOffer(
return { success: false, error: "Offer must be greater than zero." }; return { success: false, error: "Offer must be greater than zero." };
} }
const qty = quantity ?? (listing.quantity ?? 1); const qty = quantity ?? listing.quantity ?? 1;
if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) { if (!Number.isInteger(qty) || qty <= 0 || qty > (listing.quantity ?? 1)) {
return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` }; return { success: false, error: `Quantity must be between 1 and ${listing.quantity}.` };
} }
@ -642,7 +656,9 @@ export async function makeMarketOffer(
amount, amount,
proposedBy: "buyer", proposedBy: "buyer",
quantity: qty, quantity: qty,
history: [{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() }], history: [
{ amount, from: "buyer", note: "Initial offer", at: new Date().toISOString() },
],
}, },
overrideAccess: true, overrideAccess: true,
depth: 1, depth: 1,
@ -728,7 +744,9 @@ export async function makeMarketOffer(
* buyer accepts the seller's counteroffer). Completes the sale at the * buyer accepts the seller's counteroffer). Completes the sale at the
* agreed price. * agreed price.
*/ */
export async function acceptMarketOffer(negotiationId: number): Promise<ActionResult<MarketListing>> { export async function acceptMarketOffer(
negotiationId: number,
): Promise<ActionResult<MarketListing>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
const userId = user.id as number; const userId = user.id as number;
@ -789,8 +807,12 @@ export async function rejectMarketOffer(negotiationId: number): Promise<ActionRe
} }
const listing = negotiationListing(negotiation); const listing = negotiationListing(negotiation);
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number); const listingId =
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing"; typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
const assetName =
typeof listing === "object" && listing
? assetNameOf(listing as MarketListing)
: "the listing";
await payload.update({ await payload.update({
collection: "market-negotiations", collection: "market-negotiations",
@ -868,9 +890,7 @@ export async function counterMarketOffer(
return { success: false, error: "This listing is no longer active." }; return { success: false, error: "This listing is no longer active." };
} }
const history = [ const history = [...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>)];
...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>),
];
history.push({ amount, from: role, note: "Counteroffer", at: new Date().toISOString() }); history.push({ amount, from: role, note: "Counteroffer", at: new Date().toISOString() });
const updated = (await payload.update({ const updated = (await payload.update({
@ -900,7 +920,8 @@ export async function counterMarketOffer(
return { success: true, data: refreshed }; return { success: true, data: refreshed };
} }
const counterpartyId = role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number); const counterpartyId =
role === "buyer" ? sellerId : (negotiationBuyerId(negotiation) as number);
await notifyUser(payload, { await notifyUser(payload, {
userId: counterpartyId, userId: counterpartyId,
type: "market:counter", type: "market:counter",
@ -933,9 +954,14 @@ export async function cancelMarketOffer(negotiationId: number): Promise<ActionRe
if (!role) return { success: false, error: "You are not part of this negotiation." }; if (!role) return { success: false, error: "You are not part of this negotiation." };
const listing = negotiationListing(negotiation); const listing = negotiationListing(negotiation);
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number); const listingId =
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing"; typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
const sellerId = typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null; const assetName =
typeof listing === "object" && listing
? assetNameOf(listing as MarketListing)
: "the listing";
const sellerId =
typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null;
await payload.update({ await payload.update({
collection: "market-negotiations", collection: "market-negotiations",

View file

@ -28,10 +28,7 @@ export default async function MarketPage() {
and: [ and: [
{ status: { equals: "active" } }, { status: { equals: "active" } },
{ {
or: [ or: [{ expiresAt: { exists: false } }, { expiresAt: { greater_than: dayAgo } }],
{ expiresAt: { exists: false } },
{ expiresAt: { greater_than: dayAgo } },
],
}, },
], ],
}, },
@ -50,7 +47,9 @@ export default async function MarketPage() {
const myListings = listings.filter((l) => { const myListings = listings.filter((l) => {
const sellerId = const sellerId =
typeof l.seller === "object" && l.seller ? (l.seller as { id: number }).id : (l.seller as number | null); typeof l.seller === "object" && l.seller
? (l.seller as { id: number }).id
: (l.seller as number | null);
return sellerId === userId; return sellerId === userId;
}); });
@ -62,7 +61,9 @@ export default async function MarketPage() {
}); });
const negotiations = (negotiationsRes.docs as unknown as MarketNegotiation[]).filter((n) => { const negotiations = (negotiationsRes.docs as unknown as MarketNegotiation[]).filter((n) => {
const buyerId = const buyerId =
typeof n.buyer === "object" && n.buyer ? (n.buyer as { id: number }).id : (n.buyer as number | null); typeof n.buyer === "object" && n.buyer
? (n.buyer as { id: number }).id
: (n.buyer as number | null);
if (buyerId === userId) return true; if (buyerId === userId) return true;
const listing = n.listing as unknown as MarketListing | number | null; const listing = n.listing as unknown as MarketListing | number | null;
if (typeof listing === "object" && listing) { if (typeof listing === "object" && listing) {

View file

@ -1,6 +1,6 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { GameStructure, Faction, Structure } from "@/payload-types"; import { Faction, GameStructure, Structure } from "@/payload-types";
import { StorageGrid } from "@/components/frontend/storage/StorageGrid"; import { StorageGrid } from "@/components/frontend/storage/StorageGrid";
import { Building2Icon } from "lucide-react"; import { Building2Icon } from "lucide-react";
@ -40,11 +40,7 @@ export default async function LogisticsPage() {
Overview of all structures and their stored resources across the theater of operations. Overview of all structures and their stored resources across the theater of operations.
</p> </p>
</div> </div>
<StorageGrid <StorageGrid structures={structures} categories={categories} factions={factions} />
structures={structures}
categories={categories}
factions={factions}
/>
</div> </div>
); );
} }

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Resource, Structure, Asset } from "@/payload-types"; import type { Asset, Structure } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { import {
@ -12,7 +12,6 @@ import {
CoinsIcon, CoinsIcon,
Grid3x3Icon, Grid3x3Icon,
LayersIcon, LayersIcon,
MoveIcon,
RotateCcwIcon, RotateCcwIcon,
ScaleIcon, ScaleIcon,
TagIcon, TagIcon,
@ -48,9 +47,7 @@ const typeColors: Record<string, string> = {
abstract: "bg-purple-500/20 text-purple-400", abstract: "bg-purple-500/20 text-purple-400",
}; };
export default async function ResourceDetailPage({ export default async function ResourceDetailPage({ params }: ResourceDetailPageProps) {
params,
}: ResourceDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -105,9 +102,7 @@ export default async function ResourceDetailPage({
const allRelatedStructures = [ const allRelatedStructures = [
...producers, ...producers,
...autoGenStructures.filter( ...autoGenStructures.filter((s) => !producers.some((p) => p.id === s.id)),
(s) => !producers.some((p) => p.id === s.id),
),
]; ];
return ( return (
@ -124,10 +119,7 @@ export default async function ResourceDetailPage({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{resource.color ? ( {resource.color ? (
<div <div className="size-5 rounded-sm" style={{ backgroundColor: resource.color }} />
className="size-5 rounded-sm"
style={{ backgroundColor: resource.color }}
/>
) : ( ) : (
<BoxIcon className="size-5 text-muted-foreground" /> <BoxIcon className="size-5 text-muted-foreground" />
)} )}
@ -243,10 +235,7 @@ export default async function ResourceDetailPage({
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{allRelatedStructures.map((structure) => ( {allRelatedStructures.map((structure) => (
<Link <Link key={structure.id} href={`/logistics/structures/${structure.id}`}>
key={structure.id}
href={`/logistics/structures/${structure.id}`}
>
<Item <Item
variant="outline" variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]" className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"

View file

@ -38,9 +38,7 @@ export default async function ResourcesPage() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No resources available.</p>
No resources available.
</p>
)} )}
</div> </div>
); );

View file

@ -2,30 +2,16 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { import type { GameStructure, GameVehicle, Vehicle } from "@/payload-types";
Shipment,
GameStructure,
GameVehicle,
Vehicle,
Resource,
} from "@/payload-types";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ArrowRightIcon, ArrowRightIcon,
ClockIcon, ClockIcon,
FuelIcon, FuelIcon,
MapPinIcon,
PackageIcon, PackageIcon,
TruckIcon, TruckIcon,
} from "lucide-react"; } from "lucide-react";
import { import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions"; import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar"; import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
@ -44,9 +30,7 @@ const statusColors: Record<string, string> = {
stranded: "bg-red-500/20 text-red-400", stranded: "bg-red-500/20 text-red-400",
}; };
export default async function ShipmentDetailPage({ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
params,
}: ShipmentDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -71,31 +55,20 @@ export default async function ShipmentDetailPage({
? (shipment.destination as GameStructure) ? (shipment.destination as GameStructure)
: null; : null;
const vehicle = const vehicle =
typeof shipment.transportVehicle === "object" && typeof shipment.transportVehicle === "object" && shipment.transportVehicle !== null
shipment.transportVehicle !== null
? (shipment.transportVehicle as GameVehicle & { type: Vehicle }) ? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
: null; : null;
const vehicleType = const vehicleType = vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
const dispatchedAt = shipment.dispatchedAt const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
? new Date(shipment.dispatchedAt) const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null;
: null; const completedAt = shipment.completedAt ? new Date(shipment.completedAt) : null;
const estimatedArrival = shipment.estimatedArrival
? new Date(shipment.estimatedArrival)
: null;
const completedAt = shipment.completedAt
? new Date(shipment.completedAt)
: null;
const now = new Date(); const now = new Date();
const isActive = const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
shipment.status === "dispatched" || shipment.status === "in_transit";
const fuelPercent = const fuelPercent =
shipment.fuelCost > 0 shipment.fuelCost > 0 ? ((shipment.fuelConsumed ?? 0) / shipment.fuelCost) * 100 : 0;
? ((shipment.fuelConsumed ?? 0) / shipment.fuelCost) * 100
: 0;
return ( return (
<div className="p-5 flex flex-col gap-6 max-w-7xl"> <div className="p-5 flex flex-col gap-6 max-w-7xl">
@ -111,9 +84,7 @@ export default async function ShipmentDetailPage({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TruckIcon className="size-5 text-muted-foreground" /> <TruckIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">{shipment.name ?? `SHP-${shipment.id}`}</h1>
{shipment.name ?? `SHP-${shipment.id}`}
</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
@ -121,8 +92,7 @@ export default async function ShipmentDetailPage({
> >
{shipment.status === "in_transit" {shipment.status === "in_transit"
? "In Transit" ? "In Transit"
: shipment.status.charAt(0).toUpperCase() + : shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)}
shipment.status.slice(1)}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{shipment.distance?.toFixed(1)} units distance {shipment.distance?.toFixed(1)} units distance
@ -143,9 +113,7 @@ export default async function ShipmentDetailPage({
<ItemContent> <ItemContent>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">{origin?.name ?? "Unknown"}</p>
{origin?.name ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">Origin</p> <p className="text-xs text-muted-foreground">Origin</p>
</div> </div>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
@ -155,9 +123,7 @@ export default async function ShipmentDetailPage({
</span> </span>
</div> </div>
<div className="flex-1 text-center"> <div className="flex-1 text-center">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">{destination?.name ?? "Unknown"}</p>
{destination?.name ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">Destination</p> <p className="text-xs text-muted-foreground">Destination</p>
</div> </div>
</div> </div>
@ -220,23 +186,14 @@ export default async function ShipmentDetailPage({
value: number | { name?: string }; value: number | { name?: string };
}; };
itemType = polymorphic.relationTo; itemType = polymorphic.relationTo;
if ( if (typeof polymorphic.value === "object" && polymorphic.value !== null) {
typeof polymorphic.value === "object" && itemName = polymorphic.value.name ?? "Unknown";
polymorphic.value !== null
) {
itemName =
polymorphic.value.name ?? "Unknown";
} }
} }
return ( return (
<tr <tr key={index} className="border-b border-border/50">
key={index} <td className="px-4 py-2 font-medium">{itemName}</td>
className="border-b border-border/50"
>
<td className="px-4 py-2 font-medium">
{itemName}
</td>
<td className="px-4 py-2 text-muted-foreground capitalize"> <td className="px-4 py-2 text-muted-foreground capitalize">
{itemType} {itemType}
</td> </td>
@ -250,9 +207,7 @@ export default async function ShipmentDetailPage({
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No cargo on this shipment.</p>
No cargo on this shipment.
</p>
)} )}
</ItemContent> </ItemContent>
</Item> </Item>
@ -271,9 +226,7 @@ export default async function ShipmentDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">{vehicle?.name ?? "Unknown"}</p>
{vehicle?.name ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{vehicleType?.name ?? "Unknown type"} {vehicleType?.name ?? "Unknown type"}
</p> </p>
@ -283,24 +236,19 @@ export default async function ShipmentDetailPage({
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fuel</span> <span className="text-muted-foreground">Fuel</span>
<span className="font-mono"> <span className="font-mono">
{vehicle.currentFuel?.toFixed(1)} /{" "} {vehicle.currentFuel?.toFixed(1)} / {vehicleType?.fuel?.fuelCapacity ?? "?"}
{vehicleType?.fuel?.fuelCapacity ?? "?"}
</span> </span>
</div> </div>
<div className="h-2 bg-border rounded-full overflow-hidden"> <div className="h-2 bg-border rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${ className={`h-full rounded-full transition-all ${
vehicle.currentFuel > 0 vehicle.currentFuel > 0 ? "bg-green-500" : "bg-red-500"
? "bg-green-500"
: "bg-red-500"
}`} }`}
style={{ style={{
width: `${Math.min( width: `${Math.min(
100, 100,
vehicleType?.fuel?.fuelCapacity vehicleType?.fuel?.fuelCapacity
? (vehicle.currentFuel / ? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100
vehicleType.fuel.fuelCapacity) *
100
: 0, : 0,
)}%`, )}%`,
}} }}
@ -308,9 +256,7 @@ export default async function ShipmentDetailPage({
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Health</span> <span className="text-muted-foreground">Health</span>
<span className="font-mono"> <span className="font-mono">{vehicle.currentHealth}</span>
{vehicle.currentHealth}
</span>
</div> </div>
</> </>
)} )}
@ -334,9 +280,7 @@ export default async function ShipmentDetailPage({
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Consumed</span> <span className="text-muted-foreground">Consumed</span>
<span className="font-mono"> <span className="font-mono">{(shipment.fuelConsumed ?? 0).toFixed(1)}</span>
{(shipment.fuelConsumed ?? 0).toFixed(1)}
</span>
</div> </div>
<div className="h-2 bg-border rounded-full overflow-hidden"> <div className="h-2 bg-border rounded-full overflow-hidden">
<div <div
@ -375,9 +319,7 @@ export default async function ShipmentDetailPage({
{estimatedArrival && ( {estimatedArrival && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{shipment.status === "completed" {shipment.status === "completed" ? "Arrived" : "Est. Arrival"}
? "Arrived"
: "Est. Arrival"}
</span> </span>
<span> <span>
{estimatedArrival.toLocaleDateString()}{" "} {estimatedArrival.toLocaleDateString()}{" "}
@ -411,9 +353,7 @@ export default async function ShipmentDetailPage({
<ItemTitle>Failure Reason</ItemTitle> <ItemTitle>Failure Reason</ItemTitle>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<p className="text-sm text-red-400"> <p className="text-sm text-red-400">{shipment.failureReason}</p>
{shipment.failureReason}
</p>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}

View file

@ -2,27 +2,13 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
GameStructure,
GameVehicle,
Structure,
Vehicle,
Resource,
} from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { calculateDistance } from "@/lib/distance"; import { calculateDistance } from "@/lib/distance";
import { import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
calculateFuelCost, import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
calculateTransitTime,
getVehicleEffectiveSpeed,
} from "@/lib/shipping";
import { processShipmentTick } from "@/scripts/processShipmentTick";
import {
checkStorageDeposit,
storageViolationMessage,
} from "@/lib/storageRules";
interface ActionResult { interface ActionResult {
success: boolean; success: boolean;
@ -105,8 +91,7 @@ export async function createShipment(params: {
if (!originCoords || !destCoords) { if (!originCoords || !destCoords) {
return { return {
success: false, success: false,
error: error: "Both origin and destination must have coordinates set.",
"Both origin and destination must have coordinates set.",
}; };
} }
@ -146,9 +131,7 @@ export async function createShipment(params: {
} }
const vehicleAtId = const vehicleAtId =
typeof vehicle.deployedAt === "object" typeof vehicle.deployedAt === "object" ? vehicle.deployedAt?.id : vehicle.deployedAt;
? vehicle.deployedAt?.id
: vehicle.deployedAt;
if (vehicleAtId !== originId) { if (vehicleAtId !== originId) {
return { return {
@ -158,8 +141,7 @@ export async function createShipment(params: {
} }
// Get vehicle type details // Get vehicle type details
const vehicleType = const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
typeof vehicle.type === "object" ? vehicle.type : null;
if (!vehicleType) { if (!vehicleType) {
return { success: false, error: "Vehicle type not found." }; return { success: false, error: "Vehicle type not found." };
} }
@ -182,8 +164,7 @@ export async function createShipment(params: {
if (cargoItem.relationTo === "resources") { if (cargoItem.relationTo === "resources") {
const available = storedResources const available = storedResources
.filter((r) => { .filter((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === cargoItem.value; return resId === cargoItem.value;
}) })
.reduce((sum, r) => sum + r.amount, 0); .reduce((sum, r) => sum + r.amount, 0);
@ -191,18 +172,12 @@ export async function createShipment(params: {
if (available < cargoItem.amount) { if (available < cargoItem.amount) {
const resName = const resName =
typeof storedResources.find((r) => { typeof storedResources.find((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object"
? r.resource.id
: r.resource;
return resId === cargoItem.value; return resId === cargoItem.value;
})?.resource === "object" })?.resource === "object"
? ( ? (
storedResources.find((r) => { storedResources.find((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object"
? r.resource.id
: r.resource;
return resId === cargoItem.value; return resId === cargoItem.value;
})?.resource as Resource })?.resource as Resource
).name ).name
@ -217,8 +192,7 @@ export async function createShipment(params: {
} }
// Validate destination can accept the cargo (storage rules + mass capacity) // Validate destination can accept the cargo (storage rules + mass capacity)
const destType = const destType = typeof destination.type === "object" ? destination.type : null;
typeof destination.type === "object" ? destination.type : null;
if (destType) { if (destType) {
const destStored = destination.storedResources ?? []; const destStored = destination.storedResources ?? [];
const destVoid = (destination as GameStructure).voidStorage ?? []; const destVoid = (destination as GameStructure).voidStorage ?? [];
@ -233,8 +207,7 @@ export async function createShipment(params: {
itemId: number, itemId: number,
) => ) =>
entries.reduce((sum, r) => { entries.reduce((sum, r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
return sum + (resId === itemId ? r.amount : 0); return sum + (resId === itemId ? r.amount : 0);
}, 0); }, 0);
@ -257,8 +230,7 @@ export async function createShipment(params: {
} }
const currentStored = const currentStored =
storedAmountIn(destStored, cargoItem.value) + storedAmountIn(destStored, cargoItem.value) + storedAmountIn(destVoid, cargoItem.value);
storedAmountIn(destVoid, cargoItem.value);
const ruleCheck = checkStorageDeposit( const ruleCheck = checkStorageDeposit(
destType, destType,
@ -278,10 +250,7 @@ export async function createShipment(params: {
} }
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount; destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
if ( if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) {
destType.maxCapacityMass &&
destMass > destType.maxCapacityMass
) {
return { return {
success: false, success: false,
error: `Destination "${destination.name}" would exceed its mass capacity of ${destType.maxCapacityMass.toLocaleString()} kg.`, error: `Destination "${destination.name}" would exceed its mass capacity of ${destType.maxCapacityMass.toLocaleString()} kg.`,
@ -327,8 +296,7 @@ export async function createShipment(params: {
let remaining = cargoItem.amount; let remaining = cargoItem.amount;
updatedResources = updatedResources updatedResources = updatedResources
.map((r) => { .map((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
if (resId === cargoItem.value && remaining > 0) { if (resId === cargoItem.value && remaining > 0) {
const deduct = Math.min(r.amount, remaining); const deduct = Math.min(r.amount, remaining);
remaining -= deduct; remaining -= deduct;
@ -377,9 +345,7 @@ export async function createShipment(params: {
fuelConsumed: 0, fuelConsumed: 0,
status: "dispatched", status: "dispatched",
dispatchedAt: now.toISOString(), dispatchedAt: now.toISOString(),
estimatedArrival: new Date( estimatedArrival: new Date(now.getTime() + transitTimeMs).toISOString(),
now.getTime() + transitTimeMs,
).toISOString(),
autoReturn: false, autoReturn: false,
createdBy: user.id, createdBy: user.id,
}, },
@ -402,8 +368,7 @@ export async function createShipment(params: {
return { success: true, data: shipment }; return { success: true, data: shipment };
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : "Unknown error";
error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") { if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." }; return { success: false, error: "You must be logged in." };
} }
@ -411,9 +376,7 @@ export async function createShipment(params: {
} }
} }
export async function cancelShipment( export async function cancelShipment(shipmentId: number): Promise<ActionResult> {
shipmentId: number,
): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
@ -433,10 +396,7 @@ export async function cancelShipment(
return { success: false, error: "Shipment not found." }; return { success: false, error: "Shipment not found." };
} }
if ( if (shipment.status !== "dispatched" && shipment.status !== "in_transit") {
shipment.status !== "dispatched" &&
shipment.status !== "in_transit"
) {
return { return {
success: false, success: false,
error: `Cannot cancel shipment with status "${shipment.status}".`, error: `Cannot cancel shipment with status "${shipment.status}".`,
@ -444,9 +404,7 @@ export async function cancelShipment(
} }
const originId = const originId =
typeof shipment.origin === "object" typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number);
? shipment.origin.id
: (shipment.origin as number);
const vehicleId = const vehicleId =
typeof shipment.transportVehicle === "object" typeof shipment.transportVehicle === "object"
? shipment.transportVehicle.id ? shipment.transportVehicle.id
@ -464,9 +422,7 @@ export async function cancelShipment(
if (polymorphic.relationTo !== "resources") continue; if (polymorphic.relationTo !== "resources") continue;
const itemId = const itemId =
typeof polymorphic.value === "object" typeof polymorphic.value === "object" ? polymorphic.value.id : polymorphic.value;
? polymorphic.value.id
: polymorphic.value;
const origin = await payload.findByID({ const origin = await payload.findByID({
collection: "game-structures", collection: "game-structures",
@ -476,8 +432,7 @@ export async function cancelShipment(
const existingResources = origin.storedResources ?? []; const existingResources = origin.storedResources ?? [];
const existingEntry = existingResources.find((r) => { const existingEntry = existingResources.find((r) => {
const resId = const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === itemId; return resId === itemId;
}); });
@ -531,8 +486,7 @@ export async function cancelShipment(
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : "Unknown error";
error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") { if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." }; return { success: false, error: "You must be logged in." };
} }
@ -574,8 +528,7 @@ export async function toggleAutoReturn(
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : "Unknown error";
error instanceof Error ? error.message : "Unknown error";
return { success: false, error: message }; return { success: false, error: message };
} }
} }

View file

@ -1,6 +1,6 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types"; import type { Shipment } from "@/payload-types";
import { TruckIcon } from "lucide-react"; import { TruckIcon } from "lucide-react";
import { ShipmentsList } from "@/components/frontend/logistics/ShipmentsList"; import { ShipmentsList } from "@/components/frontend/logistics/ShipmentsList";

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { GameStructure, Structure, Faction, Resource } from "@/payload-types"; import { Faction, GameStructure, Resource, Structure } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { CapacityBar } from "@/components/frontend/storage/CapacityBar"; import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
import { StorageRules } from "@/components/frontend/storage/StorageRules"; import { StorageRules } from "@/components/frontend/storage/StorageRules";
@ -45,11 +45,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const headers = await nextHeaders(); const headers = await nextHeaders();
const { user } = await payload.auth({ headers, canSetHeaders: false }); const { user } = await payload.auth({ headers, canSetHeaders: false });
const structureRes = await payload.findByID({ const structureRes = await payload
.findByID({
collection: "game-structures", collection: "game-structures",
id: Number(id), id: Number(id),
depth: 2, depth: 2,
}).catch(() => null); })
.catch(() => null);
if (!structureRes) { if (!structureRes) {
notFound(); notFound();
@ -116,9 +118,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
</div> </div>
</div> </div>
{description && ( {description && <p className="text-sm text-muted-foreground">{description}</p>}
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Item variant="outline"> <Item variant="outline">
@ -187,7 +187,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Max Capacity</ItemTitle> <ItemTitle>Max Capacity</ItemTitle>
<ItemDescription>{structureType.maxCapacityMass.toLocaleString()} kg (mass)</ItemDescription> <ItemDescription>
{structureType.maxCapacityMass.toLocaleString()} kg (mass)
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}
@ -204,9 +206,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
)} )}
</div> </div>
<StructureStorageView <StructureStorageView structure={structure} />
structure={structure}
/>
<StorageRules <StorageRules
allowedStorage={structureType.allowedStorage} allowedStorage={structureType.allowedStorage}
@ -229,8 +229,12 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const resource = upkeep.resource as Resource; const resource = upkeep.resource as Resource;
if (!resource || typeof resource === "number") return null; if (!resource || typeof resource === "number") return null;
return ( return (
<span key={upkeep.id ?? index} className="text-sm bg-muted px-2.5 py-1 rounded-md"> <span
{resource.name}: {upkeep.amount?.toLocaleString()} {pluralize(upkeep.amount ?? 0, resource.unitOfMeasure)}/tick key={upkeep.id ?? index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{resource.name}: {upkeep.amount?.toLocaleString()}{" "}
{pluralize(upkeep.amount ?? 0, resource.unitOfMeasure)}/tick
</span> </span>
); );
})} })}

View file

@ -6,10 +6,7 @@ import { GameStructure, Resource, Structure } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
checkStorageDeposit,
storageViolationMessage,
} from "@/lib/storageRules";
interface ActionResult { interface ActionResult {
success: boolean; success: boolean;
@ -32,11 +29,13 @@ async function authenticate() {
} }
async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id: number) { async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id: number) {
const structure = await payload.findByID({ const structure = await payload
.findByID({
collection: "game-structures", collection: "game-structures",
id, id,
depth: 2, depth: 2,
}).catch(() => null); })
.catch(() => null);
if (!structure) { if (!structure) {
throw new Error("Structure not found"); throw new Error("Structure not found");
@ -45,7 +44,10 @@ async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id:
return structure as unknown as GameStructure & { type: Structure }; return structure as unknown as GameStructure & { type: Structure };
} }
function getStoredAmount(storedResources: GameStructure["storedResources"], resourceId: number): number { function getStoredAmount(
storedResources: GameStructure["storedResources"],
resourceId: number,
): number {
if (!storedResources) return 0; if (!storedResources) return 0;
const entry = storedResources.find((e) => { const entry = storedResources.find((e) => {
const res = e.resource; const res = e.resource;
@ -73,7 +75,10 @@ export async function addResource(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!hasRoles(["admin"], user)) {
return { success: false, error: "Insufficient permissions. Admin or Developer role required for deposit." }; return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for deposit.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -84,11 +89,13 @@ export async function addResource(
const structureType = structure.type; const structureType = structure.type;
// Fetch the resource to check rules + mass // Fetch the resource to check rules + mass
const resource = await payload.findByID({ const resource = await payload
.findByID({
collection: "resources", collection: "resources",
id: resourceId, id: resourceId,
depth: 0, depth: 0,
}).catch(() => null); })
.catch(() => null);
if (!resource) { if (!resource) {
return { success: false, error: "Resource not found." }; return { success: false, error: "Resource not found." };
@ -99,12 +106,7 @@ export async function addResource(
getStoredAmount(structure.storedResources, resourceId) + getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId); getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit( const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored);
structureType,
resourceId,
amount,
currentStored,
);
if (!ruleCheck.allowed) { if (!ruleCheck.allowed) {
return { return {
@ -136,9 +138,7 @@ export async function addResource(
const resH = resource.gridHeight ?? 1; const resH = resource.gridHeight ?? 1;
const ew = resW; const ew = resW;
const eh = resH; const eh = resH;
const maxStack = resource.stackable === false const maxStack = resource.stackable === false ? 1 : (resource.maxStackSize ?? Infinity);
? 1
: resource.maxStackSize ?? Infinity;
// 1. Fill existing stacks of the same resource that have space // 1. Fill existing stacks of the same resource that have space
let remaining = amount; let remaining = amount;
@ -151,9 +151,8 @@ export async function addResource(
for (const entry of sameType) { for (const entry of sameType) {
if (remaining <= 0) break; if (remaining <= 0) break;
const space = maxStack === Infinity const space =
? remaining maxStack === Infinity ? remaining : Math.min(maxStack - entry.amount, remaining);
: Math.min(maxStack - entry.amount, remaining);
if (space <= 0) continue; if (space <= 0) continue;
const idx = existing.findIndex((e) => e.id === entry.id); const idx = existing.findIndex((e) => e.id === entry.id);
if (idx >= 0) { if (idx >= 0) {
@ -204,7 +203,13 @@ export async function addResource(
} }
const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack); const stackAmount = Math.min(remaining, maxStack === Infinity ? remaining : maxStack);
existing.push({ resource: resourceId, amount: stackAmount, gridX: spot.x, gridY: spot.y, rotated: false }); existing.push({
resource: resourceId,
amount: stackAmount,
gridX: spot.x,
gridY: spot.y,
rotated: false,
});
remaining -= stackAmount; remaining -= stackAmount;
} }
} else { } else {
@ -260,7 +265,10 @@ export async function removeResource(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!hasRoles(["admin"], user)) {
return { success: false, error: "Insufficient permissions. Admin or Developer role required for withdrawal." }; return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -331,7 +339,12 @@ export async function removeResource(
data: { storedResources, voidStorage }, data: { storedResources, voidStorage },
}); });
const resName = (await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null))?.name ?? `resource #${resourceId}`; const resName =
(
await payload
.findByID({ collection: "resources", id: resourceId, depth: 0 })
.catch(() => null)
)?.name ?? `resource #${resourceId}`;
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.StorageWithdraw, type: EventTypes.StorageWithdraw,
message: `Withdrew ${amount.toLocaleString()}× ${resName} from "${structure.name}"`, message: `Withdrew ${amount.toLocaleString()}× ${resName} from "${structure.name}"`,
@ -361,7 +374,10 @@ export async function transferResource(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required for transfer." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
};
} }
if (amount <= 0) { if (amount <= 0) {
@ -405,11 +421,13 @@ export async function transferResource(
} }
// Fetch the resource for rule + capacity checks // Fetch the resource for rule + capacity checks
const resource = await payload.findByID({ const resource = await payload
.findByID({
collection: "resources", collection: "resources",
id: resourceId, id: resourceId,
depth: 0, depth: 0,
}).catch(() => null); })
.catch(() => null);
if (!resource) { if (!resource) {
return { success: false, error: "Resource not found." }; return { success: false, error: "Resource not found." };
@ -468,7 +486,15 @@ export async function transferResource(
structure: toStructureId, structure: toStructureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: toStructureId, targetId: toStructureId,
data: { resourceId, amount, resourceName: resName, fromStructureId, toStructureId, fromStructureName: fromStructure.name, toStructureName: toStructure.name }, data: {
resourceId,
amount,
resourceName: resName,
fromStructureId,
toStructureId,
fromStructureName: fromStructure.name,
toStructureName: toStructure.name,
},
}); });
return { success: true, data: addResult.data }; return { success: true, data: addResult.data };
@ -499,7 +525,10 @@ export async function placeResourceOnGrid(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!hasRoles(["admin"], user)) {
return { success: false, error: "Insufficient permissions. Admin or Developer role required for placement." }; return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for placement.",
};
} }
if (amount <= 0) return { success: false, error: "Amount must be greater than zero." }; if (amount <= 0) return { success: false, error: "Amount must be greater than zero." };
@ -507,7 +536,9 @@ export async function placeResourceOnGrid(
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const structureType = structure.type; const structureType = structure.type;
const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null); const resource = await payload
.findByID({ collection: "resources", id: resourceId, depth: 0 })
.catch(() => null);
if (!resource) return { success: false, error: "Resource not found." }; if (!resource) return { success: false, error: "Resource not found." };
// Check storage rules (prohibited → whitelist → per-item cap) // Check storage rules (prohibited → whitelist → per-item cap)
@ -515,12 +546,7 @@ export async function placeResourceOnGrid(
getStoredAmount(structure.storedResources, resourceId) + getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId); getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit( const ruleCheck = checkStorageDeposit(structureType, resourceId, amount, currentStored);
structureType,
resourceId,
amount,
currentStored,
);
if (!ruleCheck.allowed) { if (!ruleCheck.allowed) {
return { success: false, error: storageViolationMessage(resource.name, ruleCheck) }; return { success: false, error: storageViolationMessage(resource.name, ruleCheck) };
@ -534,7 +560,10 @@ export async function placeResourceOnGrid(
const addedMass = (resource.massPerUnit ?? 0) * amount; const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass; const maxMass = structureType.maxCapacityMass;
if (maxMass && currentMass + addedMass > maxMass) { if (maxMass && currentMass + addedMass > maxMass) {
return { success: false, error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.` }; return {
success: false,
error: `Exceeds mass capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg.`,
};
} }
const gridW = structureType.xSize ?? 0; const gridW = structureType.xSize ?? 0;
@ -543,8 +572,8 @@ export async function placeResourceOnGrid(
return { success: false, error: "This structure does not have grid dimensions configured." }; return { success: false, error: "This structure does not have grid dimensions configured." };
} }
const resW = (resource.gridWidth ?? 1); const resW = resource.gridWidth ?? 1;
const resH = (resource.gridHeight ?? 1); const resH = resource.gridHeight ?? 1;
const effectiveW = rotated ? resH : resW; const effectiveW = rotated ? resH : resW;
const effectiveH = rotated ? resW : resH; const effectiveH = rotated ? resW : resH;
@ -578,7 +607,11 @@ export async function placeResourceOnGrid(
existing.push({ resource: resourceId, amount, gridX, gridY, rotated }); existing.push({ resource: resourceId, amount, gridX, gridY, rotated });
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.GridPlace, type: EventTypes.GridPlace,
@ -609,7 +642,10 @@ export async function moveGridItem(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])]; const existing = [...(structure.storedResources ?? [])];
@ -653,10 +689,13 @@ export async function moveGridItem(
} }
} }
existing[idx] = rotated !== undefined existing[idx] =
? { ...entry, gridX, gridY, rotated } rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY };
: { ...entry, gridX, gridY }; await payload.update({
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.GridMove, type: EventTypes.GridMove,
@ -665,7 +704,14 @@ export async function moveGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { entryId, resourceId: (typeof res === "object" ? res.id : res), resourceName: res.name, gridX, gridY, rotated }, data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: res.name,
gridX,
gridY,
rotated,
},
}); });
return { success: true }; return { success: true };
@ -685,7 +731,10 @@ export async function mergeGridStacks(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])]; const existing = [...(structure.storedResources ?? [])];
@ -705,9 +754,11 @@ export async function mergeGridStacks(
const srcResId = typeof srcRes === "object" ? srcRes.id : srcRes; const srcResId = typeof srcRes === "object" ? srcRes.id : srcRes;
const tgtResId = typeof tgtRes === "object" ? tgtRes.id : tgtRes; const tgtResId = typeof tgtRes === "object" ? tgtRes.id : tgtRes;
if (srcResId !== tgtResId) return { success: false, error: "Cannot merge different resource types." }; if (srcResId !== tgtResId)
return { success: false, error: "Cannot merge different resource types." };
if (tgtRes.stackable === false) return { success: false, error: "This resource cannot be stacked." }; if (tgtRes.stackable === false)
return { success: false, error: "This resource cannot be stacked." };
const maxStack = tgtRes.maxStackSize; const maxStack = tgtRes.maxStackSize;
const spaceInTarget = maxStack ? maxStack - tgt.amount : Infinity; const spaceInTarget = maxStack ? maxStack - tgt.amount : Infinity;
@ -723,7 +774,11 @@ export async function mergeGridStacks(
existing[srcIdx] = { ...src, amount: src.amount - transferAmount }; existing[srcIdx] = { ...src, amount: src.amount - transferAmount };
} }
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const mergeResName = typeof srcRes === "object" ? srcRes.name : `resource #${srcResId}`; const mergeResName = typeof srcRes === "object" ? srcRes.name : `resource #${srcResId}`;
await emitGameEvent(payload, { await emitGameEvent(payload, {
@ -733,7 +788,13 @@ export async function mergeGridStacks(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { sourceId, targetId, resourceId: srcResId, resourceName: mergeResName, transferAmount }, data: {
sourceId,
targetId,
resourceId: srcResId,
resourceName: mergeResName,
transferAmount,
},
}); });
return { success: true }; return { success: true };
@ -752,7 +813,10 @@ export async function rotateGridItem(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])]; const existing = [...(structure.storedResources ?? [])];
@ -762,7 +826,8 @@ export async function rotateGridItem(
const entry = existing[idx]; const entry = existing[idx];
const res = entry.resource as Resource; const res = entry.resource as Resource;
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." }; if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
if (res.rotatable === false) return { success: false, error: "This resource cannot be rotated." }; if (res.rotatable === false)
return { success: false, error: "This resource cannot be rotated." };
const newRotated = !(entry.rotated ?? false); const newRotated = !(entry.rotated ?? false);
const ew = newRotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1); const ew = newRotated ? (res.gridHeight ?? 1) : (res.gridWidth ?? 1);
@ -798,7 +863,11 @@ export async function rotateGridItem(
} }
existing[idx] = { ...entry, rotated: newRotated }; existing[idx] = { ...entry, rotated: newRotated };
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.GridRotate, type: EventTypes.GridRotate,
@ -807,7 +876,12 @@ export async function rotateGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { entryId, resourceId: (typeof res === "object" ? res.id : res), resourceName: res.name, rotated: newRotated }, data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: res.name,
rotated: newRotated,
},
}); });
return { success: true }; return { success: true };
@ -826,7 +900,10 @@ export async function removeGridItem(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user)) { if (!hasRoles(["admin"], user)) {
return { success: false, error: "Insufficient permissions. Admin or Developer role required for removal." }; return {
success: false,
error: "Insufficient permissions. Admin or Developer role required for removal.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const existing = [...(structure.storedResources ?? [])]; const existing = [...(structure.storedResources ?? [])];
@ -836,9 +913,15 @@ export async function removeGridItem(
const removedEntry = existing[idx]; const removedEntry = existing[idx];
const removedRes = removedEntry.resource as Resource; const removedRes = removedEntry.resource as Resource;
existing.splice(idx, 1); existing.splice(idx, 1);
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const removedName = removedRes?.name ?? `resource #${typeof removedRes === "object" ? removedRes.id : removedRes}`; const removedName =
removedRes?.name ??
`resource #${typeof removedRes === "object" ? removedRes.id : removedRes}`;
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.StorageWithdraw, type: EventTypes.StorageWithdraw,
message: `Removed ${removedEntry.amount}× ${removedName} from "${structure.name}"`, message: `Removed ${removedEntry.amount}× ${removedName} from "${structure.name}"`,
@ -846,7 +929,12 @@ export async function removeGridItem(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { entryId, resourceId: typeof removedRes === "object" ? removedRes.id : removedRes, resourceName: removedName, amount: removedEntry.amount }, data: {
entryId,
resourceId: typeof removedRes === "object" ? removedRes.id : removedRes,
resourceName: removedName,
amount: removedEntry.amount,
},
}); });
return { success: true }; return { success: true };
@ -866,7 +954,10 @@ export async function splitGridStack(
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
if (splitAmount <= 0) return { success: false, error: "Split amount must be positive." }; if (splitAmount <= 0) return { success: false, error: "Split amount must be positive." };
@ -877,7 +968,8 @@ export async function splitGridStack(
if (idx < 0) return { success: false, error: "Item not found in grid." }; if (idx < 0) return { success: false, error: "Item not found in grid." };
const entry = existing[idx]; const entry = existing[idx];
if (splitAmount >= entry.amount) return { success: false, error: "Split amount must be less than the stack size." }; if (splitAmount >= entry.amount)
return { success: false, error: "Split amount must be less than the stack size." };
const res = entry.resource as Resource; const res = entry.resource as Resource;
if (!res || typeof res === "number") return { success: false, error: "Invalid resource." }; if (!res || typeof res === "number") return { success: false, error: "Invalid resource." };
@ -934,7 +1026,11 @@ export async function splitGridStack(
if (!placed) return { success: false, error: "No empty spot available for the split stack." }; if (!placed) return { success: false, error: "No empty spot available for the split stack." };
await payload.update({ collection: "game-structures", id: structureId, data: { storedResources: existing } }); await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
const splitResName = typeof res === "object" ? res.name : `resource #${res}`; const splitResName = typeof res === "object" ? res.name : `resource #${res}`;
await emitGameEvent(payload, { await emitGameEvent(payload, {
@ -944,7 +1040,13 @@ export async function splitGridStack(
structure: structureId, structure: structureId,
targetCollection: "game-structures", targetCollection: "game-structures",
targetId: structureId, targetId: structureId,
data: { entryId, resourceId: typeof res === "object" ? res.id : res, resourceName: splitResName, splitAmount, remainingAmount: entry.amount - splitAmount }, data: {
entryId,
resourceId: typeof res === "object" ? res.id : res,
resourceName: splitResName,
splitAmount,
remainingAmount: entry.amount - splitAmount,
},
}); });
return { success: true }; return { success: true };
@ -1029,7 +1131,8 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
const recovered = voidStorage.length - remainingVoid.length; const recovered = voidStorage.length - remainingVoid.length;
await emitGameEvent(payload, { await emitGameEvent(payload, {
type: EventTypes.StorageRecover, type: EventTypes.StorageRecover,
message: recovered > 0 message:
recovered > 0
? `Retrieved ${recovered} item(s) from void in "${structure.name}"` ? `Retrieved ${recovered} item(s) from void in "${structure.name}"`
: `No void items could be recovered in "${structure.name}" — grid full`, : `No void items could be recovered in "${structure.name}" — grid full`,
actor: user.id, actor: user.id,
@ -1040,7 +1143,8 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
}); });
return { return {
success: true, success: true,
error: recovered > 0 error:
recovered > 0
? `${recovered} item(s) retrieved from void storage.` ? `${recovered} item(s) retrieved from void storage.`
: "No items could be retrieved — grid is full.", : "No items could be retrieved — grid is full.",
}; };
@ -1056,7 +1160,10 @@ export async function autoArrangeGrid(structureId: number): Promise<GridActionRe
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) { if (!hasRoles(["admin"], user) && !hasRoles(["user"], user)) {
return { success: false, error: "Insufficient permissions. User, Admin, or Developer role required." }; return {
success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.",
};
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const gridW = structure.type.xSize ?? 0; const gridW = structure.type.xSize ?? 0;

View file

@ -1,34 +1,25 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useMemo, useState } from "react";
import type { Vehicle } from "@/payload-types"; import type { Vehicle } from "@/payload-types";
import { VehicleCard } from "@/components/frontend/logistics/VehicleCard"; import { VehicleCard } from "@/components/frontend/logistics/VehicleCard";
import { VehicleFilters } from "@/components/frontend/logistics/VehicleFilters"; import { VehicleFilters } from "@/components/frontend/logistics/VehicleFilters";
const categories = [ const categories = ["civilian", "utility", "transport", "weaponized", "military"];
"civilian",
"utility",
"transport",
"weaponized",
"military",
];
export function VehiclesList({ vehicles }: { vehicles: Vehicle[] }) { export function VehiclesList({ vehicles }: { vehicles: Vehicle[] }) {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!selectedCategory) return vehicles; if (!selectedCategory) return vehicles;
return vehicles.filter( return vehicles.filter((v) =>
(v) => v.category?.includes(selectedCategory as NonNullable<Vehicle["category"]>[number]), v.category?.includes(selectedCategory as NonNullable<Vehicle["category"]>[number]),
); );
}, [vehicles, selectedCategory]); }, [vehicles, selectedCategory]);
return ( return (
<> <>
<VehicleFilters <VehicleFilters categories={categories} onFilterChange={setSelectedCategory} />
categories={categories}
onFilterChange={setSelectedCategory}
/>
{filtered.length > 0 ? ( {filtered.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map((vehicle) => ( {filtered.map((vehicle) => (

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Vehicle, Resource, Technology, Structure } from "@/payload-types"; import type { Resource, Structure, Technology } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { import {
@ -10,7 +10,6 @@ import {
BoxIcon, BoxIcon,
Building2Icon, Building2Icon,
ClockIcon, ClockIcon,
CrosshairIcon,
GaugeIcon, GaugeIcon,
HeartIcon, HeartIcon,
ShieldIcon, ShieldIcon,
@ -46,9 +45,7 @@ const armorColors: Record<string, string> = {
composite: "bg-purple-500/20 text-purple-400", composite: "bg-purple-500/20 text-purple-400",
}; };
export default async function VehicleDetailPage({ export default async function VehicleDetailPage({ params }: VehicleDetailPageProps) {
params,
}: VehicleDetailPageProps) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
@ -144,10 +141,7 @@ export default async function VehicleDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{materials.map((mat, index) => ( {materials.map((mat, index) => (
<span <span key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{mat.resource.name}: {mat.amount.toLocaleString()} {mat.resource.name}: {mat.amount.toLocaleString()}
</span> </span>
))} ))}
@ -203,9 +197,7 @@ export default async function VehicleDetailPage({
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>Armor Rating</ItemTitle> <ItemTitle>Armor Rating</ItemTitle>
<ItemDescription className="capitalize"> <ItemDescription className="capitalize">{vehicle.armorRating}</ItemDescription>
{vehicle.armorRating}
</ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
)} )}
@ -319,15 +311,10 @@ export default async function VehicleDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{scrapReturns.map((scrap, index) => ( {scrapReturns.map((scrap, index) => (
<span <span key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{scrap.resource.name}: {scrap.amount.toLocaleString()} {scrap.resource.name}: {scrap.amount.toLocaleString()}
{scrap.chance < 100 && ( {scrap.chance < 100 && (
<span className="text-muted-foreground ml-1"> <span className="text-muted-foreground ml-1">({scrap.chance}%)</span>
({scrap.chance}%)
</span>
)} )}
</span> </span>
))} ))}
@ -348,10 +335,7 @@ export default async function VehicleDetailPage({
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{upkeepCosts.map((upkeep, index) => ( {upkeepCosts.map((upkeep, index) => (
<span <span key={index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{upkeep.resource.name}: {upkeep.amount.toLocaleString()}/tick {upkeep.resource.name}: {upkeep.amount.toLocaleString()}/tick
</span> </span>
))} ))}
@ -369,10 +353,7 @@ export default async function VehicleDetailPage({
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{producingStructures.map((structure) => ( {producingStructures.map((structure) => (
<Link <Link key={structure.id} href={`/logistics/structures/${structure.id}`}>
key={structure.id}
href={`/logistics/structures/${structure.id}`}
>
<Item <Item
variant="outline" variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]" className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"

View file

@ -1,7 +1,7 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import type { User, Rank, Media, Assignment, Campaign, Map, GameStructure } from "@/payload-types"; import type { Assignment, Campaign, GameStructure, Map, Media, Rank, User } from "@/payload-types";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification"; import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary"; import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";

View file

@ -2,27 +2,27 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { User, Rank, Award, Media, Assignment, Qualification } from "@/payload-types"; import type { Assignment, Award, Media, Qualification, Rank, User } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { resolveLevel } from "@/utils/xp/resolveLevel"; import { resolveLevel } from "@/utils/xp/resolveLevel";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ShieldIcon,
SwordsIcon,
UserIcon,
FileTextIcon,
CrosshairIcon,
AwardIcon, AwardIcon,
SparkleIcon,
CalendarDaysIcon,
HashIcon,
SkullIcon,
TruckIcon,
PlaneIcon,
TargetIcon,
BadgeCheckIcon, BadgeCheckIcon,
CalendarDaysIcon,
CrosshairIcon,
FileTextIcon,
HashIcon,
PlaneIcon,
ShieldCheck, ShieldCheck,
ShieldIcon,
SkullIcon,
SparkleIcon,
SwordsIcon,
TargetIcon,
TruckIcon,
UserIcon,
} from "lucide-react"; } from "lucide-react";
import { import {
Item, Item,

View file

@ -1,6 +1,6 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { User, Rank, Media } from "@/payload-types"; import type { Media, Rank, User } from "@/payload-types";
import { UsersIcon } from "lucide-react"; import { UsersIcon } from "lucide-react";
import { RosterView } from "@/components/frontend/roster/RosterView"; import { RosterView } from "@/components/frontend/roster/RosterView";
@ -64,9 +64,7 @@ export default async function RosterPage() {
const user = profile.user as User; const user = profile.user as User;
const rank = profile.rank as Rank; const rank = profile.rank as Rank;
const insigniaUrl = const insigniaUrl =
rank.insignia && typeof rank.insignia === "object" rank.insignia && typeof rank.insignia === "object" ? (rank.insignia as Media).url : null;
? (rank.insignia as Media).url
: null;
let assignmentName: string | undefined; let assignmentName: string | undefined;
let assignmentType: string | undefined; let assignmentType: string | undefined;
@ -125,15 +123,12 @@ export default async function RosterPage() {
<h1 className="text-lg font-semibold">Unit Roster</h1> <h1 className="text-lg font-semibold">Unit Roster</h1>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{members.length} operator{members.length !== 1 ? "s" : ""} across{" "} {members.length} operator{members.length !== 1 ? "s" : ""} across {assignments.length}{" "}
{assignments.length} assignment{assignments.length !== 1 ? "s" : ""} assignment{assignments.length !== 1 ? "s" : ""}
</p> </p>
</div> </div>
<RosterView <RosterView members={members} assignments={assignments} />
members={members}
assignments={assignments}
/>
</div> </div>
); );
} }

View file

@ -1,7 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from "@payload-config";
import '@payloadcms/next/css' import "@payloadcms/next/css";
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
export const GET = GRAPHQL_PLAYGROUND_GET(config); export const GET = GRAPHQL_PLAYGROUND_GET(config);

View file

@ -1,7 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from "@payload-config";
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
export const POST = GRAPHQL_POST(config); export const POST = GRAPHQL_POST(config);

View file

@ -1,9 +1,5 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus";
subscribe,
unsubscribe,
type SSEEventClient,
} from "@/lib/realtime/bus";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";

View file

@ -5,12 +5,7 @@ import type { VersionInfo } from "@/lib/versionInfo";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
const VERSION_INFO_PATH = path.join( const VERSION_INFO_PATH = path.join(process.cwd(), "src", "generated", "versionInfo.json");
process.cwd(),
"src",
"generated",
"versionInfo.json",
);
const EMPTY: VersionInfo = { const EMPTY: VersionInfo = {
version: null, version: null,

View file

@ -24,8 +24,7 @@ export default async function LoginPage() {
<AppLogo /> <AppLogo />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Log in to access the operations dashboard, intelligence, and Log in to access the operations dashboard, intelligence, and logistics.
logistics.
</p> </p>
</div> </div>
<LoginForm /> <LoginForm />