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 = [
|
||||
{ label: "Game Structures", value: "game-structures" },
|
||||
{ label: "Game Vehicles", value: "game-vehicles" },
|
||||
{ label: "Structures", value: "structures" },
|
||||
{ label: "Resources", value: "resources" },
|
||||
{ label: "Assets", value: "assets" },
|
||||
|
|
@ -13,6 +14,7 @@ const TARGET_COLLECTIONS = [
|
|||
{ label: "Users", value: "users" },
|
||||
{ label: "Technologies", value: "technologies" },
|
||||
{ label: "Maps", value: "maps" },
|
||||
{ label: "Shipments", value: "shipments" },
|
||||
] as const;
|
||||
|
||||
export const GameEventLogs: CollectionConfig = {
|
||||
|
|
|
|||
|
|
@ -38,5 +38,23 @@ export const GameRules: GlobalConfig = {
|
|||
"Assignment(s) considered the intelligence division. Members and leaders of these assignments get elevated access to draft missions and intelligence content.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proximityThreshold",
|
||||
type: "number",
|
||||
defaultValue: 10,
|
||||
admin: {
|
||||
description:
|
||||
"Distance threshold for instant transfers. Structures within this distance can transfer resources instantly. Beyond this, shipments are required.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gameTickIntervalMinutes",
|
||||
type: "number",
|
||||
defaultValue: 10,
|
||||
admin: {
|
||||
description:
|
||||
"How often the game tick runs, in minutes. This processes shipment movement, fuel consumption, and arrivals.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
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",
|
||||
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 { Vehicles } from "@/collections/logistics/Vehicles";
|
||||
import { Structures } from "@/collections/logistics/Structures";
|
||||
import { Shipments } from "@/collections/logistics/Shipments";
|
||||
import { Assignments } from "@/collections/users/Assignments";
|
||||
import { Technologies } from "@/collections/intelligence/Technologies";
|
||||
import { GameRules } from "@/collections/game/GameRules";
|
||||
import { GameStructures } from "@/collections/game/GameStructures";
|
||||
import { GameHardResources } from "@/collections/game/GameHardResources";
|
||||
import { GameEventLogs } from "@/collections/game/GameEventLogs";
|
||||
import { GameVehicles } from "@/collections/game/GameVehicles";
|
||||
import { Experience } from "@/collections/users/Experience";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
|
|
@ -69,6 +71,7 @@ export default buildConfig({
|
|||
// Game
|
||||
GameHardResources,
|
||||
GameStructures,
|
||||
GameVehicles,
|
||||
GameEventLogs,
|
||||
|
||||
// Users
|
||||
|
|
@ -91,6 +94,7 @@ export default buildConfig({
|
|||
Resources,
|
||||
Vehicles,
|
||||
Structures,
|
||||
Shipments,
|
||||
|
||||
// World
|
||||
Maps,
|
||||
|
|
@ -112,6 +116,12 @@ export default buildConfig({
|
|||
push: process.env.NODE_ENV === "development",
|
||||
}),
|
||||
sharp,
|
||||
bin: [
|
||||
{
|
||||
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
|
||||
key: "game-tick",
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
// payloadCloudPlugin(),
|
||||
// storage-adapter-placeholder
|
||||
|
|
|
|||
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;
|
||||
actor?: number;
|
||||
structure?: number;
|
||||
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures";
|
||||
targetCollection?: "users" | "missions" | "maps" | "campaigns" | "factions" | "technologies" | "assets" | "resources" | "structures" | "vehicles" | "game-structures" | "game-vehicles" | "shipments";
|
||||
targetId?: number;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,18 @@ export const EventTypes = {
|
|||
// Finance
|
||||
FinanceDeposit: "finance:deposit",
|
||||
FinanceWithdraw: "finance:withdraw",
|
||||
|
||||
// Shipping
|
||||
ShipmentCreate: "shipment:create",
|
||||
ShipmentDispatch: "shipment:dispatch",
|
||||
ShipmentArrive: "shipment:arrive",
|
||||
ShipmentComplete: "shipment:complete",
|
||||
ShipmentCancel: "shipment:cancel",
|
||||
ShipmentFail: "shipment:fail",
|
||||
ShipmentAutoReturn: "shipment:auto-return",
|
||||
|
||||
// Game Tick
|
||||
GameTick: "game:tick",
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||
|
|
|
|||
Loading…
Reference in a new issue