From 75a1c8ed9a5025adb5c844da40ec30548e4862bc Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 2 Aug 2026 02:03:50 -0400 Subject: [PATCH] feat(logistics): add shipments collection with dispatch, transit, and arrival processing --- .../logistics/shipments/[id]/page.tsx | 424 ++++++++++++++++++ .../(frontend)/logistics/shipments/page.tsx | 38 ++ src/collections/game/GameEventLogs.ts | 2 + src/collections/game/GameRules.ts | 18 + src/collections/logistics/Shipments.ts | 169 +++++++ src/components/frontend/blocks/AppSidebar.tsx | 6 +- .../frontend/logistics/ShipmentCard.tsx | 133 ++++++ .../logistics/ShipmentProgressBar.tsx | 71 +++ .../frontend/logistics/ShipmentsList.tsx | 62 +++ src/lib/distance.ts | 23 + src/lib/shipping.ts | 71 +++ src/payload.config.ts | 10 + src/scripts/processShipmentTick.ts | 385 ++++++++++++++++ src/utils/event-log/emit.ts | 2 +- src/utils/event-log/eventTypes.ts | 12 + 15 files changed, 1424 insertions(+), 2 deletions(-) create mode 100644 src/app/(frontend)/logistics/shipments/[id]/page.tsx create mode 100644 src/app/(frontend)/logistics/shipments/page.tsx create mode 100644 src/collections/logistics/Shipments.ts create mode 100644 src/components/frontend/logistics/ShipmentCard.tsx create mode 100644 src/components/frontend/logistics/ShipmentProgressBar.tsx create mode 100644 src/components/frontend/logistics/ShipmentsList.tsx create mode 100644 src/lib/distance.ts create mode 100644 src/lib/shipping.ts create mode 100644 src/scripts/processShipmentTick.ts diff --git a/src/app/(frontend)/logistics/shipments/[id]/page.tsx b/src/app/(frontend)/logistics/shipments/[id]/page.tsx new file mode 100644 index 0000000..0a1f584 --- /dev/null +++ b/src/app/(frontend)/logistics/shipments/[id]/page.tsx @@ -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 = { + 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 ( +
+ + + Back to Shipments + + +
+
+
+ +

+ {shipment.name ?? `SHP-${shipment.id}`} +

+
+
+ + {shipment.status === "in_transit" + ? "In Transit" + : shipment.status.charAt(0).toUpperCase() + + shipment.status.slice(1)} + + + {shipment.distance?.toFixed(1)} units distance + +
+
+ +
+ +
+ {/* Main Content */} +
+ {/* Route */} + + + Route + + +
+
+

+ {origin?.name ?? "Unknown"} +

+

Origin

+
+
+ + + {shipment.distance?.toFixed(1)} units + +
+
+

+ {destination?.name ?? "Unknown"} +

+

Destination

+
+
+
+
+ + {/* Progress */} + {isActive && dispatchedAt && estimatedArrival && ( + + + Transit Progress + + + + + + )} + + {/* Cargo Manifest */} + + +
+ + Cargo Manifest +
+ + {shipment.cargo?.length ?? 0} item type + {(shipment.cargo?.length ?? 0) !== 1 ? "s" : ""} + +
+ + {shipment.cargo && shipment.cargo.length > 0 ? ( +
+ + + + + + + + + + {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 ( + + + + + + ); + })} + +
+ Item + + Type + + Amount +
+ {itemName} + + {itemType} + + {entry.amount?.toLocaleString()} +
+
+ ) : ( +

+ No cargo on this shipment. +

+ )} +
+
+
+ + {/* Sidebar */} +
+ {/* Vehicle Info */} + + +
+ + Vehicle +
+
+ +
+
+

+ {vehicle?.name ?? "Unknown"} +

+

+ {vehicleType?.name ?? "Unknown type"} +

+
+ {vehicle && ( + <> +
+ Fuel + + {vehicle.currentFuel?.toFixed(1)} /{" "} + {vehicleType?.fuel?.fuelCapacity ?? "?"} + +
+
+
0 + ? "bg-green-500" + : "bg-red-500" + }`} + style={{ + width: `${Math.min( + 100, + vehicleType?.fuel?.fuelCapacity + ? (vehicle.currentFuel / + vehicleType.fuel.fuelCapacity) * + 100 + : 0, + )}%`, + }} + /> +
+
+ Health + + {vehicle.currentHealth} + +
+ + )} +
+ + + + {/* Fuel Details */} + + +
+ + Fuel +
+
+ +
+
+ Total Cost + {shipment.fuelCost} +
+
+ Consumed + + {(shipment.fuelConsumed ?? 0).toFixed(1)} + +
+
+
+
+
+ + + + {/* Timeline */} + + +
+ + Timeline +
+
+ +
+ {dispatchedAt && ( +
+ Dispatched + + {dispatchedAt.toLocaleDateString()}{" "} + {dispatchedAt.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + +
+ )} + {estimatedArrival && ( +
+ + {shipment.status === "completed" + ? "Arrived" + : "Est. Arrival"} + + + {estimatedArrival.toLocaleDateString()}{" "} + {estimatedArrival.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + +
+ )} + {completedAt && ( +
+ Completed + + {completedAt.toLocaleDateString()}{" "} + {completedAt.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + +
+ )} +
+
+
+ + {/* Failure Reason */} + {shipment.failureReason && ( + + + Failure Reason + + +

+ {shipment.failureReason} +

+
+
+ )} +
+
+
+ ); +} diff --git a/src/app/(frontend)/logistics/shipments/page.tsx b/src/app/(frontend)/logistics/shipments/page.tsx new file mode 100644 index 0000000..a478420 --- /dev/null +++ b/src/app/(frontend)/logistics/shipments/page.tsx @@ -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 ( +
+
+
+ +

Shipments

+
+

+ {shipments.length} shipment{shipments.length !== 1 ? "s" : ""} tracked. +

+
+ + +
+ ); +} diff --git a/src/collections/game/GameEventLogs.ts b/src/collections/game/GameEventLogs.ts index 9bfae88..423cd8f 100644 --- a/src/collections/game/GameEventLogs.ts +++ b/src/collections/game/GameEventLogs.ts @@ -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 = { diff --git a/src/collections/game/GameRules.ts b/src/collections/game/GameRules.ts index f5217a5..4f5ba76 100644 --- a/src/collections/game/GameRules.ts +++ b/src/collections/game/GameRules.ts @@ -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.", + }, + }, ], }; diff --git a/src/collections/logistics/Shipments.ts b/src/collections/logistics/Shipments.ts new file mode 100644 index 0000000..50cbb42 --- /dev/null +++ b/src/collections/logistics/Shipments.ts @@ -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.", + }, + }, + ], +}; diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx index 9ec7b1c..7040e67 100644 --- a/src/components/frontend/blocks/AppSidebar.tsx +++ b/src/components/frontend/blocks/AppSidebar.tsx @@ -101,7 +101,11 @@ const data = { }, { title: "Shipments", - url: "#", + url: "/logistics/shipments", + }, + { + title: "Deployed Vehicles", + url: "/logistics/game-vehicles", }, ], }, diff --git a/src/components/frontend/logistics/ShipmentCard.tsx b/src/components/frontend/logistics/ShipmentCard.tsx new file mode 100644 index 0000000..adbc1af --- /dev/null +++ b/src/components/frontend/logistics/ShipmentCard.tsx @@ -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 = { + 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 ( + + + +
+ + + + + {shipment.name ?? `SHP-${shipment.id}`} + + {vehicleType?.name ?? "Unknown vehicle"} + + +
+ + {shipment.status === "in_transit" + ? "In Transit" + : shipment.status.charAt(0).toUpperCase() + + shipment.status.slice(1)} + +
+ +
+
+ + {origin?.name ?? `Structure #${shipment.origin}`} + + + + {destination?.name ?? `Structure #${shipment.destination}`} + +
+ + {isActive && dispatchedAt && estimatedArrival && ( + + )} + +
+ + {cargoCount} item type{cargoCount !== 1 ? "s" : ""} ({totalItems}{" "} + total) + + + {shipment.distance?.toFixed(1)} units · {shipment.fuelCost} fuel + +
+
+
+ + {shipment.status === "completed" + ? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}` + : shipment.status === "cancelled" + ? "Cancelled" + : shipment.failureReason ?? (isActive ? "In transit" : shipment.status)} + + + View details + +
+ + ); +} diff --git a/src/components/frontend/logistics/ShipmentProgressBar.tsx b/src/components/frontend/logistics/ShipmentProgressBar.tsx new file mode 100644 index 0000000..0afa289 --- /dev/null +++ b/src/components/frontend/logistics/ShipmentProgressBar.tsx @@ -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 ( +
+
+
+
+
+ + {Math.floor(progress)}% + +
+
+ {countdown} remaining + + ETA {arrivalDate.toLocaleDateString()}{" "} + {arrivalDate.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + +
+
+ ); +} diff --git a/src/components/frontend/logistics/ShipmentsList.tsx b/src/components/frontend/logistics/ShipmentsList.tsx new file mode 100644 index 0000000..ec088e6 --- /dev/null +++ b/src/components/frontend/logistics/ShipmentsList.tsx @@ -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(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 ( +
+
+ {statusFilters.map((f) => ( + + ))} +
+ + {filtered.length === 0 ? ( +
+

No shipments found.

+
+ ) : ( +
+ {filtered.map((shipment) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/lib/distance.ts b/src/lib/distance.ts new file mode 100644 index 0000000..587bd10 --- /dev/null +++ b/src/lib/distance.ts @@ -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; +} diff --git a/src/lib/shipping.ts b/src/lib/shipping.ts new file mode 100644 index 0000000..064d5dc --- /dev/null +++ b/src/lib/shipping.ts @@ -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 + ); +} diff --git a/src/payload.config.ts b/src/payload.config.ts index eaaf339..b87512b 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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 diff --git a/src/scripts/processShipmentTick.ts b/src/scripts/processShipmentTick.ts new file mode 100644 index 0000000..db9af30 --- /dev/null +++ b/src/scripts/processShipmentTick.ts @@ -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 { + 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 { + 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 { + 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 }, + }); +} diff --git a/src/utils/event-log/emit.ts b/src/utils/event-log/emit.ts index ec0bb98..fdb7395 100644 --- a/src/utils/event-log/emit.ts +++ b/src/utils/event-log/emit.ts @@ -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; } diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index faac7b1..215a1f8 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -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];