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 { plaintextToLexical } from "@/lib/tickets/lexical";
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 { CLOSING_STATUSES, STATUS_LABEL, TICKET_STATUSES } from "@/lib/tickets/ticketMeta";
export interface ActionResult<T = undefined> {
success: boolean;
@ -36,7 +32,10 @@ async function authenticate() {
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";
}
@ -169,9 +168,7 @@ export async function replyToTicket(ticketId: number, content: string): Promise<
await payload.update({
collection: "tickets",
id: ticketId,
data: reopen
? { activity: newActivity, status: "open" }
: { activity: newActivity },
data: reopen ? { activity: newActivity, status: "open" } : { activity: newActivity },
overrideAccess: true,
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 {
const { payload, user } = await authenticate();
const userId = user.id as number;
@ -245,9 +245,7 @@ export async function updateTicketStatus(ticketId: number, status: TicketStatus)
const reporterId = reporterIdOf(ticket);
if (!isStaff) {
const reporterCancel =
reporterId === userId &&
status === "canceled" &&
!CLOSING_STATUSES.includes(ticket.status);
reporterId === userId && status === "canceled" && !CLOSING_STATUSES.includes(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 {
const { payload, user } = await authenticate();
const userId = user.id as number;

View file

@ -2,13 +2,12 @@ import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
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 {
ArrowLeftIcon,
CalendarDaysIcon,
ChevronRightIcon,
CrosshairIcon,
ShieldIcon,
TargetIcon,
UsersIcon,

View file

@ -2,7 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
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 { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import {
@ -10,8 +10,8 @@ import {
ChevronRightIcon,
FlagIcon,
LandmarkIcon,
ShieldIcon,
ScaleIcon,
ShieldIcon,
} from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
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>
</div>
<p className="text-sm text-muted-foreground">
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in
the theater of operations.
{factions.length} faction{factions.length !== 1 ? "s" : ""} tracked in the theater of
operations.
</p>
</div>

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import config from "@payload-config";
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 { MissionsList } from "@/components/frontend/intelligence/MissionsList";

View file

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

View file

@ -11,9 +11,9 @@ import {
autoArrangeLockerItems,
ensureLockerStorage,
findLockerEmptySpot,
getLoadoutSlot,
getLockerGridDimensions,
getLockerItemFootprint,
getLoadoutSlot,
isAttachmentCompatible,
isLockerPlacementValid,
isSkinAsset,
@ -23,7 +23,6 @@ import {
resolveAssetId,
skinAppliesTo,
toLockerGridItems,
type LockerGridItem,
} from "@/lib/locker";
export interface ActionResult<T = undefined> {
@ -219,7 +218,8 @@ export async function removeLockerItem(entryId: string): Promise<ActionResult> {
const removed = items[idx];
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);
await payload.update({
@ -236,7 +236,12 @@ export async function removeLockerItem(entryId: string): Promise<ActionResult> {
actor: user.id,
targetCollection: "locker-storages",
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 };
@ -269,19 +274,33 @@ export async function moveLockerItem(
const effectiveRotated = rotated ?? entry.rotated ?? false;
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." };
}
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." };
}
items[idx] =
rotated !== undefined
? { ...entry, gridX, gridY, rotated }
: { ...entry, gridX, gridY };
rotated !== undefined ? { ...entry, gridX, gridY, rotated } : { ...entry, gridX, gridY };
await payload.update({
collection: "locker-storages",
@ -297,7 +316,14 @@ export async function moveLockerItem(
actor: user.id,
targetCollection: "locker-storages",
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 };
@ -321,7 +347,8 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
const entry = items[idx];
const asset = entry.asset as 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 fp = getLockerItemFootprint(asset, newRotated);
@ -331,7 +358,18 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
}
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." };
}
@ -362,10 +400,7 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
}
}
export async function mergeLockerStacks(
sourceId: string,
targetId: string,
): Promise<ActionResult> {
export async function mergeLockerStacks(sourceId: string, targetId: string): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number);
@ -385,7 +420,8 @@ export async function mergeLockerStacks(
if (srcAsset.id !== tgtAsset.id) {
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 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 {
const { payload, user } = await authenticate();
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;
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 fp = getLockerItemFootprint(asset, entry.rotated ?? false);
@ -475,7 +515,13 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr
actor: user.id,
targetCollection: "locker-storages",
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 };
@ -674,7 +720,7 @@ export async function setLoadoutSlot(
});
const equippedName = assetId
? (await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}`
? ((await getAssetOrThrow(payload, assetId).catch(() => null))?.name ?? `asset #${assetId}`)
: null;
await emitGameEvent(payload, {
@ -778,10 +824,7 @@ export async function equipLoadout(loadoutId: number, equipped = true): Promise<
// --- Equipment customization (attachments & skins) ---
export async function setItemAttachment(
entryId: string,
assetId: number,
): Promise<ActionResult> {
export async function setItemAttachment(entryId: string, assetId: number): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
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 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);
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 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}.` };
const updatedItems = [...items];
@ -1092,10 +1142,7 @@ export async function removeItemSkin(entryId: string): Promise<ActionResult> {
}
}
export async function renameLockerItem(
entryId: string,
customName: string,
): Promise<ActionResult> {
export async function renameLockerItem(entryId: string, customName: string): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number);
@ -1121,9 +1168,7 @@ export async function renameLockerItem(
await emitGameEvent(payload, {
type: EventTypes.LockerItemRename,
message: name
? `Renamed ${asset.name} to "${name}"`
: `Cleared custom name on ${asset.name}`,
message: name ? `Renamed ${asset.name} to "${name}"` : `Cleared custom name on ${asset.name}`,
actor: user.id,
targetCollection: "locker-storages",
targetId: locker.id,

View file

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

View file

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

View file

@ -52,17 +52,13 @@ export default async function GameVehiclesPage() {
{vehicles.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{vehicles.map((vehicle) => {
const vehicleType =
typeof vehicle.type === "object" ? vehicle.type : null;
const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
const fuelPercent = vehicleType?.fuel?.fuelCapacity
? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100
: 0;
return (
<Link
key={vehicle.id}
href={`/logistics/game-vehicles/${vehicle.id}`}
>
<Link key={vehicle.id} href={`/logistics/game-vehicles/${vehicle.id}`}>
<Item
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)]"
@ -84,8 +80,7 @@ export default async function GameVehiclesPage() {
>
{vehicle.status === "in_transit"
? "In Transit"
: vehicle.status.charAt(0).toUpperCase() +
vehicle.status.slice(1)}
: vehicle.status.charAt(0).toUpperCase() + vehicle.status.slice(1)}
</span>
</ItemHeader>
<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={`h-full rounded-full transition-all ${
fuelPercent > 20
? "bg-green-500"
: "bg-red-500"
fuelPercent > 20 ? "bg-green-500" : "bg-red-500"
}`}
style={{
width: `${Math.min(100, fuelPercent)}%`,
@ -112,16 +105,12 @@ export default async function GameVehiclesPage() {
<span>HP: {vehicle.currentHealth}</span>
{vehicleType?.fuel?.fuelType &&
typeof vehicleType.fuel.fuelType === "object" && (
<span>
{vehicleType.fuel.fuelType.name}
</span>
<span>{vehicleType.fuel.fuelType.name}</span>
)}
</div>
</div>
</ItemContent>
<ItemDescription>
{vehicleType?.name ?? "Unknown type"}
</ItemDescription>
<ItemDescription>{vehicleType?.name ?? "Unknown type"}</ItemDescription>
</Item>
</Link>
);

View file

@ -7,17 +7,22 @@ import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
buyListing,
countCleanQuantity,
creditLockerQuantity,
deductLockerQuantity,
buyListing,
negotiationRange,
USER_LISTING_DURATION_MS,
} from "@/lib/market";
import { ensureLockerStorage, resolveAssetId } from "@/lib/locker";
import { getMainCurrencyId } from "@/lib/banking";
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> {
success: boolean;
@ -86,7 +91,9 @@ async function getListingOrThrow(
function assetNameOf(listing: MarketListing): string {
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(
@ -94,7 +101,12 @@ async function getNegotiationOrThrow(
negotiationId: number,
): Promise<MarketNegotiation> {
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;
if (!negotiation) throw new Error("Negotiation not found.");
return negotiation;
@ -146,10 +158,7 @@ function enforceHigherBuyerOffer(negotiation: MarketNegotiation, amount: number)
}
/** Role of the acting user in a negotiation: "buyer", "seller", or null. */
function partyRoleOf(
negotiation: MarketNegotiation,
userId: number,
): "buyer" | "seller" | null {
function partyRoleOf(negotiation: MarketNegotiation, userId: number): "buyer" | "seller" | null {
if (negotiationBuyerId(negotiation) === userId) return "buyer";
const listing = negotiationListing(negotiation);
if (typeof listing === "object" && listing) {
@ -175,7 +184,7 @@ async function finalizeNegotiation(
const buyerId = buyer.id as number;
const sellerId = sellerIdOf(listing);
const assetName = assetNameOf(listing);
const quantity = negotiation.quantity ?? (listing.quantity ?? 1);
const quantity = negotiation.quantity ?? listing.quantity ?? 1;
const total = agreedPrice * quantity;
const result = await buyListing(payload, listing, buyer, { unitPrice: agreedPrice, quantity });
@ -319,7 +328,7 @@ async function resolveNpcOffer(
targetId: negotiation.id,
data: {
listingId: listing.id,
quantity: negotiation.quantity ?? (listing.quantity ?? 1),
quantity: negotiation.quantity ?? listing.quantity ?? 1,
amount,
counter: response.amount,
patience: response.patience,
@ -343,7 +352,7 @@ async function resolveNpcOffer(
targetId: negotiation.id,
data: {
listingId: listing.id,
quantity: negotiation.quantity ?? (listing.quantity ?? 1),
quantity: negotiation.quantity ?? listing.quantity ?? 1,
amount,
counter: response.amount,
patience: response.patience,
@ -472,7 +481,12 @@ export async function cancelMarketListing(listingId: number): Promise<ActionResu
const assetName =
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({
collection: "market-listings",
@ -517,7 +531,7 @@ export async function buyMarketListing(
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)) {
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." };
}
const qty = quantity ?? (listing.quantity ?? 1);
const qty = quantity ?? 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}.` };
}
@ -642,7 +656,9 @@ export async function makeMarketOffer(
amount,
proposedBy: "buyer",
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,
depth: 1,
@ -728,7 +744,9 @@ export async function makeMarketOffer(
* buyer accepts the seller's counteroffer). Completes the sale at the
* agreed price.
*/
export async function acceptMarketOffer(negotiationId: number): Promise<ActionResult<MarketListing>> {
export async function acceptMarketOffer(
negotiationId: number,
): Promise<ActionResult<MarketListing>> {
try {
const { payload, user } = await authenticate();
const userId = user.id as number;
@ -789,8 +807,12 @@ export async function rejectMarketOffer(negotiationId: number): Promise<ActionRe
}
const listing = negotiationListing(negotiation);
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing";
const listingId =
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({
collection: "market-negotiations",
@ -868,9 +890,7 @@ export async function counterMarketOffer(
return { success: false, error: "This listing is no longer active." };
}
const history = [
...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>),
];
const history = [...((negotiation.history ?? []) as NonNullable<MarketNegotiation["history"]>)];
history.push({ amount, from: role, note: "Counteroffer", at: new Date().toISOString() });
const updated = (await payload.update({
@ -900,7 +920,8 @@ export async function counterMarketOffer(
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, {
userId: counterpartyId,
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." };
const listing = negotiationListing(negotiation);
const listingId = typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
const assetName = typeof listing === "object" && listing ? assetNameOf(listing as MarketListing) : "the listing";
const sellerId = typeof listing === "object" && listing ? sellerIdOf(listing as MarketListing) : null;
const listingId =
typeof listing === "object" && listing ? (listing as MarketListing).id : (listing as number);
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({
collection: "market-negotiations",

View file

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

View file

@ -1,6 +1,6 @@
import config from "@payload-config";
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 { 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.
</p>
</div>
<StorageGrid
structures={structures}
categories={categories}
factions={factions}
/>
<StorageGrid structures={structures} categories={categories} factions={factions} />
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import config from "@payload-config";
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 { ShipmentsList } from "@/components/frontend/logistics/ShipmentsList";

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers";
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 { CapacityBar } from "@/components/frontend/storage/CapacityBar";
import { StorageRules } from "@/components/frontend/storage/StorageRules";
@ -45,11 +45,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const headers = await nextHeaders();
const { user } = await payload.auth({ headers, canSetHeaders: false });
const structureRes = await payload.findByID({
const structureRes = await payload
.findByID({
collection: "game-structures",
id: Number(id),
depth: 2,
}).catch(() => null);
})
.catch(() => null);
if (!structureRes) {
notFound();
@ -116,9 +118,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
</div>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Item variant="outline">
@ -187,7 +187,9 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
</ItemMedia>
<ItemContent>
<ItemTitle>Max Capacity</ItemTitle>
<ItemDescription>{structureType.maxCapacityMass.toLocaleString()} kg (mass)</ItemDescription>
<ItemDescription>
{structureType.maxCapacityMass.toLocaleString()} kg (mass)
</ItemDescription>
</ItemContent>
</Item>
)}
@ -204,9 +206,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
)}
</div>
<StructureStorageView
structure={structure}
/>
<StructureStorageView structure={structure} />
<StorageRules
allowedStorage={structureType.allowedStorage}
@ -229,8 +229,12 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const resource = upkeep.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<span 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
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>
);
})}

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import config from "@payload-config";
import { getPayload } from "payload";
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 { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { ProfileSummary } from "@/components/frontend/dashboard/ProfileSummary";

View file

@ -2,27 +2,27 @@ import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
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 { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { resolveLevel } from "@/utils/xp/resolveLevel";
import {
ArrowLeftIcon,
ShieldIcon,
SwordsIcon,
UserIcon,
FileTextIcon,
CrosshairIcon,
AwardIcon,
SparkleIcon,
CalendarDaysIcon,
HashIcon,
SkullIcon,
TruckIcon,
PlaneIcon,
TargetIcon,
BadgeCheckIcon,
CalendarDaysIcon,
CrosshairIcon,
FileTextIcon,
HashIcon,
PlaneIcon,
ShieldCheck,
ShieldIcon,
SkullIcon,
SparkleIcon,
SwordsIcon,
TargetIcon,
TruckIcon,
UserIcon,
} from "lucide-react";
import {
Item,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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