1
0
Fork 0

feat(logistics): add shipments collection with dispatch, transit, and arrival processing

This commit is contained in:
Jason Fraley 2026-08-02 02:03:50 -04:00
parent d6ba4ccc39
commit 75a1c8ed9a
15 changed files with 1424 additions and 2 deletions

View file

@ -0,0 +1,424 @@
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 {
ArrowLeftIcon,
ArrowRightIcon,
ClockIcon,
FuelIcon,
MapPinIcon,
PackageIcon,
TruckIcon,
} from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
interface ShipmentDetailPageProps {
params: Promise<{ id: string }>;
}
const statusColors: Record<string, string> = {
pending: "bg-muted text-muted-foreground",
dispatched: "bg-blue-500/20 text-blue-400",
in_transit: "bg-amber-500/20 text-amber-400",
arrived: "bg-green-500/20 text-green-400",
completed: "bg-emerald-500/20 text-emerald-400",
cancelled: "bg-muted text-muted-foreground",
failed: "bg-red-500/20 text-red-400",
stranded: "bg-red-500/20 text-red-400",
};
export default async function ShipmentDetailPage({
params,
}: ShipmentDetailPageProps) {
const { id } = await params;
const payload = await getPayload({ config });
const shipment = await payload
.findByID({
collection: "shipments",
id: Number(id),
depth: 2,
})
.catch(() => null);
if (!shipment) {
notFound();
}
const origin =
typeof shipment.origin === "object" && shipment.origin !== null
? (shipment.origin as GameStructure)
: null;
const destination =
typeof shipment.destination === "object" && shipment.destination !== null
? (shipment.destination as GameStructure)
: null;
const vehicle =
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 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 fuelPercent =
shipment.fuelCost > 0
? ((shipment.fuelConsumed ?? 0) / shipment.fuelCost) * 100
: 0;
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<Link
href="/logistics/shipments"
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
>
<ArrowLeftIcon className="size-3" />
Back to Shipments
</Link>
<div className="flex items-start justify-between gap-4">
<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>
</div>
<div className="flex items-center gap-2">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[shipment.status] ?? "bg-muted text-muted-foreground"}`}
>
{shipment.status === "in_transit"
? "In Transit"
: shipment.status.charAt(0).toUpperCase() +
shipment.status.slice(1)}
</span>
<span className="text-xs text-muted-foreground">
{shipment.distance?.toFixed(1)} units distance
</span>
</div>
</div>
<ShipmentActions shipment={shipment} />
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Main Content */}
<div className="xl:col-span-2 flex flex-col gap-6">
{/* Route */}
<Item variant="outline">
<ItemHeader>
<ItemTitle>Route</ItemTitle>
</ItemHeader>
<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-xs text-muted-foreground">Origin</p>
</div>
<div className="flex flex-col items-center gap-1">
<ArrowRightIcon className="size-5 text-muted-foreground" />
<span className="text-xs font-mono text-muted-foreground">
{shipment.distance?.toFixed(1)} units
</span>
</div>
<div className="flex-1 text-center">
<p className="text-sm font-semibold">
{destination?.name ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">Destination</p>
</div>
</div>
</ItemContent>
</Item>
{/* Progress */}
{isActive && dispatchedAt && estimatedArrival && (
<Item variant="outline">
<ItemHeader>
<ItemTitle>Transit Progress</ItemTitle>
</ItemHeader>
<ItemContent>
<ShipmentProgressBar
dispatchedAt={shipment.dispatchedAt!}
estimatedArrival={shipment.estimatedArrival!}
/>
</ItemContent>
</Item>
)}
{/* Cargo Manifest */}
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<PackageIcon className="size-4 text-muted-foreground" />
<ItemTitle>Cargo Manifest</ItemTitle>
</div>
<span className="text-xs text-muted-foreground">
{shipment.cargo?.length ?? 0} item type
{(shipment.cargo?.length ?? 0) !== 1 ? "s" : ""}
</span>
</ItemHeader>
<ItemContent>
{shipment.cargo && shipment.cargo.length > 0 ? (
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Item
</th>
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Type
</th>
<th className="text-right px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Amount
</th>
</tr>
</thead>
<tbody>
{shipment.cargo.map((entry, index) => {
const itemRef = entry.item;
let itemName = "Unknown";
let itemType = "";
if (itemRef && typeof itemRef === "object") {
const polymorphic = itemRef as {
relationTo: string;
value: number | { name?: string };
};
itemType = polymorphic.relationTo;
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>
<td className="px-4 py-2 text-muted-foreground capitalize">
{itemType}
</td>
<td className="px-4 py-2 text-right font-mono">
{entry.amount?.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-muted-foreground italic">
No cargo on this shipment.
</p>
)}
</ItemContent>
</Item>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-4">
{/* Vehicle Info */}
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<TruckIcon className="size-4 text-muted-foreground" />
<ItemTitle>Vehicle</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
<div>
<p className="text-sm font-semibold">
{vehicle?.name ?? "Unknown"}
</p>
<p className="text-xs text-muted-foreground">
{vehicleType?.name ?? "Unknown type"}
</p>
</div>
{vehicle && (
<>
<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 ?? "?"}
</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"
}`}
style={{
width: `${Math.min(
100,
vehicleType?.fuel?.fuelCapacity
? (vehicle.currentFuel /
vehicleType.fuel.fuelCapacity) *
100
: 0,
)}%`,
}}
/>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Health</span>
<span className="font-mono">
{vehicle.currentHealth}
</span>
</div>
</>
)}
</div>
</ItemContent>
</Item>
{/* Fuel Details */}
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<FuelIcon className="size-4 text-muted-foreground" />
<ItemTitle>Fuel</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Cost</span>
<span className="font-mono">{shipment.fuelCost}</span>
</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>
</div>
<div className="h-2 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all"
style={{
width: `${Math.min(100, fuelPercent)}%`,
}}
/>
</div>
</div>
</ItemContent>
</Item>
{/* Timeline */}
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<ItemTitle>Timeline</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2 text-sm">
{dispatchedAt && (
<div className="flex justify-between">
<span className="text-muted-foreground">Dispatched</span>
<span>
{dispatchedAt.toLocaleDateString()}{" "}
{dispatchedAt.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
{estimatedArrival && (
<div className="flex justify-between">
<span className="text-muted-foreground">
{shipment.status === "completed"
? "Arrived"
: "Est. Arrival"}
</span>
<span>
{estimatedArrival.toLocaleDateString()}{" "}
{estimatedArrival.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
{completedAt && (
<div className="flex justify-between">
<span className="text-muted-foreground">Completed</span>
<span>
{completedAt.toLocaleDateString()}{" "}
{completedAt.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
</div>
</ItemContent>
</Item>
{/* Failure Reason */}
{shipment.failureReason && (
<Item variant="outline">
<ItemHeader>
<ItemTitle>Failure Reason</ItemTitle>
</ItemHeader>
<ItemContent>
<p className="text-sm text-red-400">
{shipment.failureReason}
</p>
</ItemContent>
</Item>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
import config from "@payload-config";
import { getPayload } from "payload";
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
import { TruckIcon } from "lucide-react";
import { ShipmentsList } from "@/components/frontend/logistics/ShipmentsList";
export const metadata = {
title: "Shipments — Polaris Task Force",
};
export default async function ShipmentsPage() {
const payload = await getPayload({ config });
const shipmentsRes = await payload.find({
collection: "shipments",
sort: "-dispatchedAt",
limit: 100,
depth: 2,
});
const shipments = shipmentsRes.docs as unknown as Shipment[];
return (
<div className="p-5 flex flex-col gap-6">
<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">Shipments</h1>
</div>
<p className="text-sm text-muted-foreground">
{shipments.length} shipment{shipments.length !== 1 ? "s" : ""} tracked.
</p>
</div>
<ShipmentsList shipments={shipments} />
</div>
);
}

View file

@ -3,6 +3,7 @@ import { isDeveloper } from "@/utils/access-control/isRole";
const TARGET_COLLECTIONS = [
{ label: "Game Structures", value: "game-structures" },
{ label: "Game Vehicles", value: "game-vehicles" },
{ label: "Structures", value: "structures" },
{ label: "Resources", value: "resources" },
{ label: "Assets", value: "assets" },
@ -13,6 +14,7 @@ const TARGET_COLLECTIONS = [
{ label: "Users", value: "users" },
{ label: "Technologies", value: "technologies" },
{ label: "Maps", value: "maps" },
{ label: "Shipments", value: "shipments" },
] as const;
export const GameEventLogs: CollectionConfig = {

View file

@ -38,5 +38,23 @@ export const GameRules: GlobalConfig = {
"Assignment(s) considered the intelligence division. Members and leaders of these assignments get elevated access to draft missions and intelligence content.",
},
},
{
name: "proximityThreshold",
type: "number",
defaultValue: 10,
admin: {
description:
"Distance threshold for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.",
},
},
{
name: "gameTickIntervalMinutes",
type: "number",
defaultValue: 10,
admin: {
description:
"How often the game tick runs, in minutes. This processes shipment movement, fuel consumption, and arrivals.",
},
},
],
};

View file

@ -0,0 +1,169 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles";
export const Shipments: CollectionConfig = {
slug: "shipments",
admin: {
group: "Logistics",
useAsTitle: "name",
defaultColumns: ["name", "status", "origin", "destination", "dispatchedAt", "estimatedArrival"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => {
if (!req.user) return false;
if (hasRoles(["developer", "admin"], req.user)) return true;
return hasRoles(["user"], req.user);
},
update: ({ req }) => {
if (!req.user) return false;
if (hasRoles(["developer", "admin"], req.user)) return true;
return hasRoles(["user"], req.user);
},
delete: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
admin: {
description: "Shipment label. Auto-generated if left empty.",
},
},
// Route
{
name: "origin",
type: "relationship",
relationTo: "game-structures",
required: true,
},
{
name: "destination",
type: "relationship",
relationTo: "game-structures",
required: true,
},
// Transport
{
name: "transportVehicle",
type: "relationship",
relationTo: "game-vehicles",
required: true,
admin: {
description: "The vehicle instance transporting this shipment.",
},
},
{
name: "distance",
type: "number",
required: true,
admin: {
description: "Euclidean distance between origin and destination.",
},
},
// Cargo
{
name: "cargo",
type: "array",
required: true,
fields: [
{
name: "item",
type: "relationship",
relationTo: ["resources", "assets", "vehicles"],
required: true,
},
{
name: "amount",
type: "number",
required: true,
min: 1,
},
],
},
// Fuel
{
name: "fuelCost",
type: "number",
required: true,
admin: {
description: "Total fuel required for the entire trip.",
},
},
{
name: "fuelConsumed",
type: "number",
defaultValue: 0,
admin: {
description: "Fuel consumed so far during transit.",
},
},
// Timing & Status
{
name: "status",
type: "select",
options: [
{ label: "Pending", value: "pending" },
{ label: "Dispatched", value: "dispatched" },
{ label: "In Transit", value: "in_transit" },
{ label: "Arrived", value: "arrived" },
{ label: "Completed", value: "completed" },
{ label: "Cancelled", value: "cancelled" },
{ label: "Failed", value: "failed" },
{ label: "Stranded", value: "stranded" },
],
defaultValue: "pending",
required: true,
},
{
name: "dispatchedAt",
type: "date",
admin: {
date: {
pickerAppearance: "dayAndTime",
},
},
},
{
name: "estimatedArrival",
type: "date",
admin: {
date: {
pickerAppearance: "dayAndTime",
},
},
},
{
name: "completedAt",
type: "date",
admin: {
date: {
pickerAppearance: "dayAndTime",
},
},
},
// Auto-return
{
name: "autoReturn",
type: "checkbox",
defaultValue: false,
admin: {
description: "If true, the vehicle will automatically return to its origin after completing this shipment.",
},
},
// Metadata
{
name: "createdBy",
type: "relationship",
relationTo: "users",
},
{
name: "failureReason",
type: "text",
admin: {
description: "Reason for failure or stranding.",
},
},
],
};

View file

@ -101,7 +101,11 @@ const data = {
},
{
title: "Shipments",
url: "#",
url: "/logistics/shipments",
},
{
title: "Deployed Vehicles",
url: "/logistics/game-vehicles",
},
],
},

View file

@ -0,0 +1,133 @@
"use client";
import Link from "next/link";
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
import { ArrowRightIcon, ChevronRightIcon, TruckIcon } from "lucide-react";
interface ShipmentCardProps {
shipment: Shipment;
}
const statusColors: Record<string, string> = {
pending: "bg-muted text-muted-foreground",
dispatched: "bg-blue-500/20 text-blue-400",
in_transit: "bg-amber-500/20 text-amber-400",
arrived: "bg-green-500/20 text-green-400",
completed: "bg-emerald-500/20 text-emerald-400",
cancelled: "bg-muted text-muted-foreground",
failed: "bg-red-500/20 text-red-400",
stranded: "bg-red-500/20 text-red-400",
};
export function ShipmentCard({ shipment }: ShipmentCardProps) {
const origin =
typeof shipment.origin === "object" && shipment.origin !== null
? (shipment.origin as GameStructure)
: null;
const destination =
typeof shipment.destination === "object" && shipment.destination !== null
? (shipment.destination as GameStructure)
: null;
const vehicle =
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 dispatchedAt = shipment.dispatchedAt
? new Date(shipment.dispatchedAt)
: null;
const estimatedArrival = shipment.estimatedArrival
? new Date(shipment.estimatedArrival)
: null;
const isActive =
shipment.status === "dispatched" || shipment.status === "in_transit";
const cargoCount = shipment.cargo?.length ?? 0;
const totalItems =
shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
return (
<Link href={`/logistics/shipments/${shipment.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)]"
>
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<TruckIcon />
</ItemMedia>
<ItemTitle className="flex flex-col">
<span>{shipment.name ?? `SHP-${shipment.id}`}</span>
<span className="text-xs font-normal text-muted-foreground">
{vehicleType?.name ?? "Unknown vehicle"}
</span>
</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[shipment.status] ?? "bg-muted text-muted-foreground"}`}
>
{shipment.status === "in_transit"
? "In Transit"
: shipment.status.charAt(0).toUpperCase() +
shipment.status.slice(1)}
</span>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3 w-full">
<div className="flex items-center gap-2 text-sm">
<span className="truncate">
{origin?.name ?? `Structure #${shipment.origin}`}
</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{destination?.name ?? `Structure #${shipment.destination}`}
</span>
</div>
{isActive && dispatchedAt && estimatedArrival && (
<ShipmentProgressBar
dispatchedAt={dispatchedAt.toISOString()}
estimatedArrival={estimatedArrival.toISOString()}
/>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{cargoCount} item type{cargoCount !== 1 ? "s" : ""} ({totalItems}{" "}
total)
</span>
<span className="font-mono">
{shipment.distance?.toFixed(1)} units · {shipment.fuelCost} fuel
</span>
</div>
</div>
</ItemContent>
<ItemDescription>
{shipment.status === "completed"
? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}`
: shipment.status === "cancelled"
? "Cancelled"
: shipment.failureReason ?? (isActive ? "In transit" : shipment.status)}
</ItemDescription>
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
View details <ChevronRightIcon className="size-3" />
</span>
</Item>
</Link>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { useState, useEffect } from "react";
interface ShipmentProgressBarProps {
dispatchedAt: string;
estimatedArrival: string;
}
export function ShipmentProgressBar({
dispatchedAt,
estimatedArrival,
}: ShipmentProgressBarProps) {
const [progress, setProgress] = useState(0);
const [countdown, setCountdown] = useState("");
useEffect(() => {
const dispatched = new Date(dispatchedAt).getTime();
const arrival = new Date(estimatedArrival).getTime();
const update = () => {
const now = Date.now();
const total = arrival - dispatched;
const elapsed = now - dispatched;
setProgress(total > 0 ? Math.min(100, (elapsed / total) * 100) : 0);
const remaining = Math.max(0, arrival - now);
const hours = Math.floor(remaining / 3600000);
const minutes = Math.floor((remaining % 3600000) / 60000);
const seconds = Math.floor((remaining % 60000) / 1000);
if (hours > 0) {
setCountdown(`${hours}h ${minutes}m`);
} else if (minutes > 0) {
setCountdown(`${minutes}m ${seconds}s`);
} else {
setCountdown(`${seconds}s`);
}
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [dispatchedAt, estimatedArrival]);
const arrivalDate = new Date(estimatedArrival);
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
<span className="text-xs font-mono text-muted-foreground">
{Math.floor(progress)}%
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-mono">{countdown} remaining</span>
<span>
ETA {arrivalDate.toLocaleDateString()}{" "}
{arrivalDate.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { useState, useMemo } from "react";
import type { Shipment } from "@/payload-types";
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
import { Button } from "@/components/ui/button";
const statusFilters = [
{ label: "All", value: null },
{ label: "Active", value: "active" },
{ label: "Completed", value: "completed" },
{ label: "Cancelled", value: "cancelled" },
{ label: "Stranded", value: "stranded" },
];
const activeStatuses = ["dispatched", "in_transit", "pending"];
interface ShipmentsListProps {
shipments: Shipment[];
}
export function ShipmentsList({ shipments }: ShipmentsListProps) {
const [filter, setFilter] = useState<string | null>(null);
const filtered = useMemo(() => {
if (!filter) return shipments;
if (filter === "active") {
return shipments.filter((s) => activeStatuses.includes(s.status));
}
return shipments.filter((s) => s.status === filter);
}, [shipments, filter]);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-1.5">
{statusFilters.map((f) => (
<Button
key={f.value ?? "all"}
variant={filter === f.value ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => setFilter(f.value)}
>
{f.label}
</Button>
))}
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No shipments found.</p>
</div>
) : (
<div className="flex flex-col gap-2">
{filtered.map((shipment) => (
<ShipmentCard key={shipment.id} shipment={shipment} />
))}
</div>
)}
</div>
);
}

23
src/lib/distance.ts Normal file
View file

@ -0,0 +1,23 @@
/**
* Calculates the Euclidean distance between two coordinate points.
* Coordinates are [x, y] pairs (map units).
*/
export function calculateDistance(
coord1: [number, number],
coord2: [number, number],
): number {
const [x1, y1] = coord1;
const [x2, y2] = coord2;
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
/**
* Checks if two coordinates are within a given proximity threshold.
*/
export function isWithinProximity(
coord1: [number, number],
coord2: [number, number],
threshold: number,
): boolean {
return calculateDistance(coord1, coord2) <= threshold;
}

71
src/lib/shipping.ts Normal file
View file

@ -0,0 +1,71 @@
/**
* Calculates the transit time in milliseconds for a shipment.
* @param distance - Euclidean distance in map units
* @param vehicleSpeed - Vehicle speed in km/h (from maxSpeedOnRoad, maxAirspeed, etc.)
*/
export function calculateTransitTime(
distance: number,
vehicleSpeed: number,
): number {
const hours = distance / vehicleSpeed;
return hours * 60 * 60 * 1000;
}
/**
* Calculates the total fuel cost for a shipment.
* @param distance - Euclidean distance in map units
* @param consumptionRate - Fuel consumed per unit of distance (from vehicle's fuelConsumptionRate)
*/
export function calculateFuelCost(
distance: number,
consumptionRate: number,
): number {
return Math.ceil(distance * consumptionRate);
}
/**
* Calculates the fuel consumed per game tick.
* @param totalFuelCost - Total fuel for the trip
* @param totalTicks - Total number of ticks for the trip
*/
export function calculateFuelPerTick(
totalFuelCost: number,
totalTicks: number,
): number {
if (totalTicks <= 0) return totalFuelCost;
return totalFuelCost / totalTicks;
}
/**
* Calculates the number of game ticks for a given duration.
* @param durationMs - Duration in milliseconds
* @param tickIntervalMinutes - Game tick interval in minutes
*/
export function calculateTicksForDuration(
durationMs: number,
tickIntervalMinutes: number,
): number {
const tickMs = tickIntervalMinutes * 60 * 1000;
return Math.ceil(durationMs / tickMs);
}
/**
* Gets the effective speed of a vehicle, preferring road speed.
* Falls back to off-road, air, or sea speed.
*/
export function getVehicleEffectiveSpeed(vehicle: {
combat?: {
maxSpeedOnRoad?: number | null;
maxSpeedOffRoad?: number | null;
maxAirspeed?: number | null;
maxKnots?: number | null;
} | null;
}): number {
return (
vehicle.combat?.maxSpeedOnRoad ??
vehicle.combat?.maxSpeedOffRoad ??
vehicle.combat?.maxAirspeed ??
vehicle.combat?.maxKnots ??
50 // default fallback speed
);
}

View file

@ -23,12 +23,14 @@ import { NarrativeEvents } from "@/collections/world/NarrativeEvents";
import { Resources } from "@/collections/logistics/Resources";
import { Vehicles } from "@/collections/logistics/Vehicles";
import { Structures } from "@/collections/logistics/Structures";
import { Shipments } from "@/collections/logistics/Shipments";
import { Assignments } from "@/collections/users/Assignments";
import { Technologies } from "@/collections/intelligence/Technologies";
import { GameRules } from "@/collections/game/GameRules";
import { GameStructures } from "@/collections/game/GameStructures";
import { GameHardResources } from "@/collections/game/GameHardResources";
import { GameEventLogs } from "@/collections/game/GameEventLogs";
import { GameVehicles } from "@/collections/game/GameVehicles";
import { Experience } from "@/collections/users/Experience";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer";
@ -69,6 +71,7 @@ export default buildConfig({
// Game
GameHardResources,
GameStructures,
GameVehicles,
GameEventLogs,
// Users
@ -91,6 +94,7 @@ export default buildConfig({
Resources,
Vehicles,
Structures,
Shipments,
// World
Maps,
@ -112,6 +116,12 @@ export default buildConfig({
push: process.env.NODE_ENV === "development",
}),
sharp,
bin: [
{
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
key: "game-tick",
},
],
plugins: [
// payloadCloudPlugin(),
// storage-adapter-placeholder

View file

@ -0,0 +1,385 @@
import type { Payload } from "payload";
import type { Shipment, GameVehicle, GameStructure, Vehicle } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
checkStorageDeposit,
storageViolationMessage,
} from "@/lib/storageRules";
type ShipmentDoc = Shipment & {
transportVehicle: GameVehicle & { type: Vehicle };
origin: GameStructure;
destination: GameStructure;
};
export async function processShipmentTick(
payload: Payload,
shipment: ShipmentDoc,
): Promise<void> {
const now = new Date();
const vehicle = shipment.transportVehicle;
if (!vehicle || typeof vehicle === "number") return;
const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
if (!vehicleType) return;
const dispatchedAt = new Date(shipment.dispatchedAt!);
const estimatedArrival = new Date(shipment.estimatedArrival!);
const gameRules = await payload.findGlobal({ slug: "game-rules" });
const tickIntervalMinutes =
(gameRules.gameTickIntervalMinutes as number) || 10;
const totalDurationMs =
estimatedArrival.getTime() - dispatchedAt.getTime();
const totalTicks = Math.max(
1,
Math.ceil(totalDurationMs / (tickIntervalMinutes * 60 * 1000)),
);
const fuelPerTick = shipment.fuelCost / totalTicks;
const currentFuelConsumed = shipment.fuelConsumed ?? 0;
if (vehicle.currentFuel < fuelPerTick) {
await payload.update({
collection: "shipments",
id: shipment.id,
data: {
status: "stranded",
failureReason: "Vehicle ran out of fuel during transit.",
},
});
await payload.update({
collection: "game-vehicles",
id: vehicle.id,
data: { status: "damaged" },
});
const originId =
typeof shipment.origin === "object"
? shipment.origin.id
: (shipment.origin as number);
await emitGameEvent(payload, {
type: EventTypes.ShipmentFail,
message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" stranded — vehicle out of fuel`,
structure: originId,
targetCollection: "shipments",
targetId: shipment.id,
data: { reason: "out_of_fuel", vehicleId: vehicle.id },
});
return;
}
const newFuel = Math.max(0, vehicle.currentFuel - fuelPerTick);
await payload.update({
collection: "game-vehicles",
id: vehicle.id,
data: { currentFuel: newFuel },
});
const newFuelConsumed = Math.min(
currentFuelConsumed + fuelPerTick,
shipment.fuelCost,
);
if (now >= estimatedArrival) {
await completeShipment(payload, shipment, newFuelConsumed);
return;
}
await payload.update({
collection: "shipments",
id: shipment.id,
data: {
fuelConsumed: newFuelConsumed,
status: "in_transit",
},
});
}
async function completeShipment(
payload: Payload,
shipment: ShipmentDoc,
finalFuelConsumed: number,
): Promise<void> {
const vehicle = shipment.transportVehicle;
const destinationId =
typeof shipment.destination === "object"
? shipment.destination.id
: (shipment.destination as number);
const originId =
typeof shipment.origin === "object"
? shipment.origin.id
: (shipment.origin as number);
const bounced: { itemId: number; amount: number; resourceName: string }[] = [];
for (const cargoEntry of shipment.cargo ?? []) {
const itemRef = cargoEntry.item;
if (!itemRef || typeof itemRef === "number") continue;
const polymorphic = itemRef as { relationTo: string; value: number | { id: number } };
if (polymorphic.relationTo !== "resources") continue;
const itemId =
typeof polymorphic.value === "object"
? polymorphic.value.id
: polymorphic.value;
try {
const destination = await payload.findByID({
collection: "game-structures",
id: destinationId,
depth: 2,
});
const destType = typeof destination.type === "object" ? destination.type : null;
if (destType) {
const destStored = destination.storedResources ?? [];
const destVoid = (destination as GameStructure).voidStorage ?? [];
const currentStored = [...destStored, ...destVoid].reduce(
(sum, r) => {
const resId =
typeof r.resource === "object" ? r.resource.id : r.resource;
return sum + (resId === itemId ? r.amount : 0);
},
0,
);
const ruleCheck = checkStorageDeposit(
destType,
itemId,
cargoEntry.amount,
currentStored,
);
if (!ruleCheck.allowed) {
const resName =
(await payload
.findByID({ collection: "resources", id: itemId, depth: 0 })
.catch(() => null))?.name ?? `resource #${itemId}`;
bounced.push({
itemId,
amount: cargoEntry.amount,
resourceName: resName,
});
console.error(
`Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`,
);
continue;
}
}
const existingResources = destination.storedResources ?? [];
const existingEntry = existingResources.find((r) => {
const resId =
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === itemId;
});
if (existingEntry) {
existingEntry.amount += cargoEntry.amount;
} else {
existingResources.push({
resource: itemId,
amount: cargoEntry.amount,
gridX: 0,
gridY: existingResources.length,
rotated: false,
});
}
await payload.update({
collection: "game-structures",
id: destinationId,
data: { storedResources: existingResources },
});
} catch (error) {
console.error(`Failed to transfer cargo item ${itemId}:`, error);
}
}
await payload.update({
collection: "game-vehicles",
id: vehicle.id,
data: {
deployedAt: destinationId,
status: "idle",
},
});
if (bounced.length > 0) {
// Return rejected cargo to the origin structure
const origin = await payload
.findByID({
collection: "game-structures",
id: originId,
depth: 1,
})
.catch(() => null);
if (origin) {
const existingResources = [...(origin.storedResources ?? [])];
for (const b of bounced) {
const existingEntry = existingResources.find((r) => {
const resId =
typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === b.itemId;
});
if (existingEntry) {
existingEntry.amount += b.amount;
} else {
existingResources.push({
resource: b.itemId,
amount: b.amount,
gridX: 0,
gridY: existingResources.length,
rotated: false,
});
}
}
await payload.update({
collection: "game-structures",
id: originId,
data: { storedResources: existingResources },
});
}
await payload.update({
collection: "shipments",
id: shipment.id,
data: {
status: "failed",
failureReason: `Destination storage rules prevented delivery: ${bounced
.map((b) => `${b.resourceName} ×${b.amount.toLocaleString()}`)
.join(", ")} — returned to origin.`,
completedAt: new Date().toISOString(),
},
});
await emitGameEvent(payload, {
type: EventTypes.ShipmentFail,
message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected by destination storage rules`,
structure: destinationId,
targetCollection: "shipments",
targetId: shipment.id,
data: {
reason: "storage_rules",
bounced: bounced.map((b) => ({ itemId: b.itemId, amount: b.amount, resourceName: b.resourceName })),
},
});
return;
}
await payload.update({
collection: "shipments",
id: shipment.id,
data: {
status: "completed",
completedAt: new Date().toISOString(),
fuelConsumed: finalFuelConsumed,
},
});
await emitGameEvent(payload, {
type: EventTypes.ShipmentComplete,
message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" arrived at destination`,
structure: destinationId,
targetCollection: "shipments",
targetId: shipment.id,
data: { vehicleId: vehicle.id, originId, destinationId },
});
if (shipment.autoReturn) {
await createAutoReturnShipment(
payload,
shipment,
vehicle as GameVehicle & { type: Vehicle },
);
}
}
async function createAutoReturnShipment(
payload: Payload,
originalShipment: ShipmentDoc,
vehicle: GameVehicle & { type: Vehicle },
): Promise<void> {
const originId =
typeof originalShipment.origin === "object"
? originalShipment.origin.id
: (originalShipment.origin as number);
const destinationId =
typeof originalShipment.destination === "object"
? originalShipment.destination.id
: (originalShipment.destination as number);
const vehicleType =
typeof vehicle.type === "object" ? vehicle.type : null;
if (!vehicleType) return;
const speed =
vehicleType.maxSpeedOnRoad ??
vehicleType.maxSpeedOffRoad ??
vehicleType.maxAirspeed ??
50;
const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
const returnFuelCost = Math.ceil(originalShipment.distance * rate);
const returnDurationMs =
(originalShipment.distance / speed) * 60 * 60 * 1000;
const now = new Date();
if (vehicle.currentFuel < returnFuelCost) {
await emitGameEvent(payload, {
type: EventTypes.ShipmentFail,
message: `Auto-return failed — insufficient fuel`,
structure: destinationId,
targetCollection: "game-vehicles",
targetId: vehicle.id,
data: { reason: "insufficient_fuel_for_return" },
});
return;
}
await payload.update({
collection: "game-vehicles",
id: vehicle.id,
data: {
currentFuel: vehicle.currentFuel - returnFuelCost,
status: "in_transit",
deployedAt: null,
},
});
const returnShipment = await payload.create({
collection: "shipments",
data: {
name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`,
origin: destinationId,
destination: originId,
transportVehicle: vehicle.id,
distance: originalShipment.distance,
cargo: [],
fuelCost: returnFuelCost,
fuelConsumed: 0,
status: "dispatched",
dispatchedAt: now.toISOString(),
estimatedArrival: new Date(
now.getTime() + returnDurationMs,
).toISOString(),
autoReturn: false,
createdBy: originalShipment.createdBy,
},
});
await emitGameEvent(payload, {
type: EventTypes.ShipmentAutoReturn,
message: `Vehicle auto-returning to origin`,
structure: destinationId,
targetCollection: "shipments",
targetId: returnShipment.id,
data: { vehicleId: vehicle.id, originalShipmentId: originalShipment.id },
});
}

View file

@ -5,7 +5,7 @@ interface GameEventInput {
message: string;
actor?: number;
structure?: number;
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures";
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "shipments";
targetId?: number;
data?: Record<string, unknown>;
}

View file

@ -20,6 +20,18 @@ export const EventTypes = {
// Finance
FinanceDeposit: "finance:deposit",
FinanceWithdraw: "finance:withdraw",
// Shipping
ShipmentCreate: "shipment:create",
ShipmentDispatch: "shipment:dispatch",
ShipmentArrive: "shipment:arrive",
ShipmentComplete: "shipment:complete",
ShipmentCancel: "shipment:cancel",
ShipmentFail: "shipment:fail",
ShipmentAutoReturn: "shipment:auto-return",
// Game Tick
GameTick: "game:tick",
} as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];