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 = [ 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 = {

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.", "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", 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 { 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

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; 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>;
} }

View file

@ -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];