feat(logistics): add shipments collection with dispatch, transit, and arrival processing
This commit is contained in:
parent
d6ba4ccc39
commit
75a1c8ed9a
15 changed files with 1424 additions and 2 deletions
424
src/app/(frontend)/logistics/shipments/[id]/page.tsx
Normal file
424
src/app/(frontend)/logistics/shipments/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/app/(frontend)/logistics/shipments/page.tsx
Normal file
38
src/app/(frontend)/logistics/shipments/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { isDeveloper } from "@/utils/access-control/isRole";
|
||||||
|
|
||||||
const TARGET_COLLECTIONS = [
|
const TARGET_COLLECTIONS = [
|
||||||
{ label: "Game Structures", value: "game-structures" },
|
{ label: "Game Structures", value: "game-structures" },
|
||||||
|
{ label: "Game Vehicles", value: "game-vehicles" },
|
||||||
{ label: "Structures", value: "structures" },
|
{ label: "Structures", value: "structures" },
|
||||||
{ label: "Resources", value: "resources" },
|
{ label: "Resources", value: "resources" },
|
||||||
{ label: "Assets", value: "assets" },
|
{ label: "Assets", value: "assets" },
|
||||||
|
|
@ -13,6 +14,7 @@ const TARGET_COLLECTIONS = [
|
||||||
{ label: "Users", value: "users" },
|
{ label: "Users", value: "users" },
|
||||||
{ label: "Technologies", value: "technologies" },
|
{ label: "Technologies", value: "technologies" },
|
||||||
{ label: "Maps", value: "maps" },
|
{ label: "Maps", value: "maps" },
|
||||||
|
{ label: "Shipments", value: "shipments" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const GameEventLogs: CollectionConfig = {
|
export const GameEventLogs: CollectionConfig = {
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
169
src/collections/logistics/Shipments.ts
Normal file
169
src/collections/logistics/Shipments.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -101,7 +101,11 @@ const data = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Shipments",
|
title: "Shipments",
|
||||||
url: "#",
|
url: "/logistics/shipments",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Deployed Vehicles",
|
||||||
|
url: "/logistics/game-vehicles",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
133
src/components/frontend/logistics/ShipmentCard.tsx
Normal file
133
src/components/frontend/logistics/ShipmentCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/frontend/logistics/ShipmentProgressBar.tsx
Normal file
71
src/components/frontend/logistics/ShipmentProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/frontend/logistics/ShipmentsList.tsx
Normal file
62
src/components/frontend/logistics/ShipmentsList.tsx
Normal 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
23
src/lib/distance.ts
Normal 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
71
src/lib/shipping.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,12 +23,14 @@ import { NarrativeEvents } from "@/collections/world/NarrativeEvents";
|
||||||
import { Resources } from "@/collections/logistics/Resources";
|
import { Resources } from "@/collections/logistics/Resources";
|
||||||
import { Vehicles } from "@/collections/logistics/Vehicles";
|
import { Vehicles } from "@/collections/logistics/Vehicles";
|
||||||
import { Structures } from "@/collections/logistics/Structures";
|
import { Structures } from "@/collections/logistics/Structures";
|
||||||
|
import { Shipments } from "@/collections/logistics/Shipments";
|
||||||
import { Assignments } from "@/collections/users/Assignments";
|
import { Assignments } from "@/collections/users/Assignments";
|
||||||
import { Technologies } from "@/collections/intelligence/Technologies";
|
import { Technologies } from "@/collections/intelligence/Technologies";
|
||||||
import { GameRules } from "@/collections/game/GameRules";
|
import { GameRules } from "@/collections/game/GameRules";
|
||||||
import { GameStructures } from "@/collections/game/GameStructures";
|
import { GameStructures } from "@/collections/game/GameStructures";
|
||||||
import { GameHardResources } from "@/collections/game/GameHardResources";
|
import { GameHardResources } from "@/collections/game/GameHardResources";
|
||||||
import { GameEventLogs } from "@/collections/game/GameEventLogs";
|
import { GameEventLogs } from "@/collections/game/GameEventLogs";
|
||||||
|
import { GameVehicles } from "@/collections/game/GameVehicles";
|
||||||
import { Experience } from "@/collections/users/Experience";
|
import { Experience } from "@/collections/users/Experience";
|
||||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
@ -69,6 +71,7 @@ export default buildConfig({
|
||||||
// Game
|
// Game
|
||||||
GameHardResources,
|
GameHardResources,
|
||||||
GameStructures,
|
GameStructures,
|
||||||
|
GameVehicles,
|
||||||
GameEventLogs,
|
GameEventLogs,
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
|
@ -91,6 +94,7 @@ export default buildConfig({
|
||||||
Resources,
|
Resources,
|
||||||
Vehicles,
|
Vehicles,
|
||||||
Structures,
|
Structures,
|
||||||
|
Shipments,
|
||||||
|
|
||||||
// World
|
// World
|
||||||
Maps,
|
Maps,
|
||||||
|
|
@ -112,6 +116,12 @@ export default buildConfig({
|
||||||
push: process.env.NODE_ENV === "development",
|
push: process.env.NODE_ENV === "development",
|
||||||
}),
|
}),
|
||||||
sharp,
|
sharp,
|
||||||
|
bin: [
|
||||||
|
{
|
||||||
|
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
|
||||||
|
key: "game-tick",
|
||||||
|
},
|
||||||
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
// payloadCloudPlugin(),
|
// payloadCloudPlugin(),
|
||||||
// storage-adapter-placeholder
|
// storage-adapter-placeholder
|
||||||
|
|
|
||||||
385
src/scripts/processShipmentTick.ts
Normal file
385
src/scripts/processShipmentTick.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ interface GameEventInput {
|
||||||
message: string;
|
message: string;
|
||||||
actor?: number;
|
actor?: number;
|
||||||
structure?: 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;
|
targetId?: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,18 @@ export const EventTypes = {
|
||||||
// Finance
|
// Finance
|
||||||
FinanceDeposit: "finance:deposit",
|
FinanceDeposit: "finance:deposit",
|
||||||
FinanceWithdraw: "finance:withdraw",
|
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;
|
} as const;
|
||||||
|
|
||||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue