"use server"; import config from "@payload-config"; import { getPayload } from "payload"; import type { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types"; import hasRoles from "@/utils/access-control/hasRoles"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { calculateDistance } from "@/lib/distance"; import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; interface ActionResult { success: boolean; error?: string; data?: unknown; } async function authenticate() { const payloadConfig = await config; const payload = await getPayload({ config: payloadConfig }); const { headers } = await import("next/headers"); const hdrs = await headers(); const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false, }); if (!user) { throw new Error("Unauthorized"); } return { payload, user }; } export async function createShipment(params: { originId: number; destinationId: number; vehicleId: number; cargo: { relationTo: string; value: number; amount: number }[]; }): Promise { try { const { payload, user } = await authenticate(); if (!hasRoles(["user"], user)) { return { success: false, error: "Insufficient permissions." }; } const { originId, destinationId, vehicleId, cargo } = params; if (originId === destinationId) { return { success: false, error: "Origin and destination cannot be the same.", }; } if (!cargo || cargo.length === 0) { return { success: false, error: "Cargo cannot be empty." }; } // Fetch structures const origin = (await payload .findByID({ collection: "game-structures", id: originId, depth: 2, }) .catch(() => null)) as (GameStructure & { type: Structure }) | null; if (!origin) { return { success: false, error: "Origin structure not found." }; } const destination = (await payload .findByID({ collection: "game-structures", id: destinationId, depth: 2, }) .catch(() => null)) as (GameStructure & { type: Structure }) | null; if (!destination) { return { success: false, error: "Destination structure not found." }; } // Validate coordinates const originCoords = origin.coordinates as [number, number] | null; const destCoords = destination.coordinates as [number, number] | null; if (!originCoords || !destCoords) { return { success: false, error: "Both origin and destination must have coordinates set.", }; } // Calculate distance const distance = calculateDistance(originCoords, destCoords); // Check proximity threshold const gameRules = await payload.findGlobal({ slug: "game-rules" }); const threshold = (gameRules.proximityThreshold as number) || 10; if (distance <= threshold) { return { success: false, error: `Structures are within proximity (${distance.toFixed(1)} ≤ ${threshold}). Use instant transfer instead.`, }; } // Fetch vehicle const vehicle = (await payload .findByID({ collection: "game-vehicles", id: vehicleId, depth: 2, }) .catch(() => null)) as (GameVehicle & { type: Vehicle }) | null; if (!vehicle) { return { success: false, error: "Vehicle not found." }; } // Validate vehicle is idle and at origin if (vehicle.status !== "idle") { return { success: false, error: `Vehicle is not idle (status: ${vehicle.status}).`, }; } const vehicleAtId = typeof vehicle.deployedAt === "object" ? vehicle.deployedAt?.id : vehicle.deployedAt; if (vehicleAtId !== originId) { return { success: false, error: "Vehicle is not stationed at the origin structure.", }; } // Get vehicle type details const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null; if (!vehicleType) { return { success: false, error: "Vehicle type not found." }; } // Calculate fuel cost const consumptionRate = vehicleType.fuel?.fuelConsumptionRate ?? 1; const fuelCost = calculateFuelCost(distance, consumptionRate); // Validate vehicle has enough fuel if (vehicle.currentFuel < fuelCost) { return { success: false, error: `Insufficient fuel. Need ${fuelCost} units, vehicle has ${vehicle.currentFuel.toFixed(1)}.`, }; } // Validate cargo exists at origin const storedResources = origin.storedResources ?? []; for (const cargoItem of cargo) { if (cargoItem.relationTo === "resources") { const available = storedResources .filter((r) => { const resId = typeof r.resource === "object" ? r.resource.id : r.resource; return resId === cargoItem.value; }) .reduce((sum, r) => sum + r.amount, 0); if (available < cargoItem.amount) { const resName = typeof storedResources.find((r) => { const resId = typeof r.resource === "object" ? r.resource.id : r.resource; return resId === cargoItem.value; })?.resource === "object" ? ( storedResources.find((r) => { const resId = typeof r.resource === "object" ? r.resource.id : r.resource; return resId === cargoItem.value; })?.resource as Resource ).name : "Resource"; return { success: false, error: `Insufficient ${resName} at origin. Need ${cargoItem.amount}, have ${available}.`, }; } } } // Validate destination can accept the cargo (storage rules + mass capacity) const destType = typeof destination.type === "object" ? destination.type : null; if (destType) { const destStored = destination.storedResources ?? []; const destVoid = (destination as GameStructure).voidStorage ?? []; let destMass = destStored.reduce((total, entry) => { const res = entry.resource as Resource; if (!res || typeof res === "number") return total; return total + (res.massPerUnit ?? 0) * entry.amount; }, 0); const storedAmountIn = ( entries: NonNullable, itemId: number, ) => entries.reduce((sum, r) => { const resId = typeof r.resource === "object" ? r.resource.id : r.resource; return sum + (resId === itemId ? r.amount : 0); }, 0); for (const cargoItem of cargo) { if (cargoItem.relationTo !== "resources") continue; const resource = await payload .findByID({ collection: "resources", id: cargoItem.value, depth: 0, }) .catch(() => null); if (!resource) { return { success: false, error: `Cargo resource #${cargoItem.value} not found.`, }; } const currentStored = storedAmountIn(destStored, cargoItem.value) + storedAmountIn(destVoid, cargoItem.value); const ruleCheck = checkStorageDeposit( destType, cargoItem.value, cargoItem.amount, currentStored, ); if (!ruleCheck.allowed) { return { success: false, error: `Destination "${destination.name}" cannot accept cargo: ${storageViolationMessage( resource.name, ruleCheck, )}`, }; } destMass += (resource.massPerUnit ?? 0) * cargoItem.amount; if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) { return { success: false, error: `Destination "${destination.name}" would exceed its mass capacity of ${destType.maxCapacityMass.toLocaleString()} kg.`, }; } } } // Validate vehicles being shipped are empty for (const cargoItem of cargo) { if (cargoItem.relationTo === "vehicles") { const gameVehicle = await payload .findByID({ collection: "game-vehicles", id: cargoItem.value, depth: 0, }) .catch(() => null); if (gameVehicle) { const resources = (gameVehicle as GameVehicle).storedResources; if (resources && resources.length > 0) { return { success: false, error: "Vehicles being shipped must have empty cargo.", }; } } } } // Calculate transit time const speed = getVehicleEffectiveSpeed( vehicleType as unknown as Parameters[0], ); const transitTimeMs = calculateTransitTime(distance, speed); const now = new Date(); // Remove cargo from origin let updatedResources = [...storedResources]; for (const cargoItem of cargo) { if (cargoItem.relationTo === "resources") { let remaining = cargoItem.amount; updatedResources = updatedResources .map((r) => { const resId = typeof r.resource === "object" ? r.resource.id : r.resource; if (resId === cargoItem.value && remaining > 0) { const deduct = Math.min(r.amount, remaining); remaining -= deduct; return { ...r, amount: r.amount - deduct }; } return r; }) .filter((r) => r.amount > 0); } } await payload.update({ collection: "game-structures", id: originId, data: { storedResources: updatedResources }, }); // Deduct fuel from vehicle await payload.update({ collection: "game-vehicles", id: vehicleId, data: { currentFuel: vehicle.currentFuel - fuelCost, status: "in_transit", deployedAt: null, }, }); // Create shipment const shipment = await payload.create({ collection: "shipments", data: { name: `SHP-${Date.now().toString(36).toUpperCase()}`, origin: originId, destination: destinationId, transportVehicle: vehicleId, distance: Math.round(distance * 100) / 100, cargo: cargo.map((c) => ({ item: { relationTo: c.relationTo, value: c.value } as | { relationTo: "resources"; value: number | Resource } | { relationTo: "assets"; value: number } | { relationTo: "vehicles"; value: number }, amount: c.amount, })), fuelCost, fuelConsumed: 0, status: "dispatched", dispatchedAt: now.toISOString(), estimatedArrival: new Date(now.getTime() + transitTimeMs).toISOString(), autoReturn: false, createdBy: user.id, }, }); // Emit event await emitGameEvent(payload, { type: EventTypes.ShipmentCreate, message: `Shipment "${shipment.name}" dispatched from ${origin.name} to ${destination.name}`, actor: user.id, structure: originId, targetCollection: "shipments", targetId: shipment.id, data: { distance: Math.round(distance * 100) / 100, fuelCost, vehicleId, }, }); return { success: true, data: shipment }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (message === "Unauthorized") { return { success: false, error: "You must be logged in." }; } return { success: false, error: message }; } } export async function cancelShipment(shipmentId: number): Promise { try { const { payload, user } = await authenticate(); if (!hasRoles(["user"], user)) { return { success: false, error: "Insufficient permissions." }; } const shipment = await payload .findByID({ collection: "shipments", id: shipmentId, depth: 2, }) .catch(() => null); if (!shipment) { return { success: false, error: "Shipment not found." }; } if (shipment.status !== "dispatched" && shipment.status !== "in_transit") { return { success: false, error: `Cannot cancel shipment with status "${shipment.status}".`, }; } const originId = typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number); const vehicleId = typeof shipment.transportVehicle === "object" ? shipment.transportVehicle.id : (shipment.transportVehicle as number); // Return cargo to origin 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; const origin = await payload.findByID({ collection: "game-structures", id: originId, depth: 2, }); const existingResources = origin.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: originId, data: { storedResources: existingResources }, }); } // Return vehicle to idle at origin await payload.update({ collection: "game-vehicles", id: vehicleId, data: { deployedAt: originId, status: "idle", }, }); // Update shipment await payload.update({ collection: "shipments", id: shipmentId, data: { status: "cancelled", completedAt: new Date().toISOString(), }, }); await emitGameEvent(payload, { type: EventTypes.ShipmentCancel, message: `Shipment "${shipment.name ?? `SHP-${shipmentId}`}" cancelled`, actor: user.id, structure: originId, targetCollection: "shipments", targetId: shipmentId, }); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (message === "Unauthorized") { return { success: false, error: "You must be logged in." }; } return { success: false, error: message }; } } export async function toggleAutoReturn( shipmentId: number, autoReturn: boolean, ): Promise { try { const { payload, user } = await authenticate(); if (!hasRoles(["user"], user)) { return { success: false, error: "Insufficient permissions." }; } const shipment = await payload .findByID({ collection: "shipments", id: shipmentId }) .catch(() => null); if (!shipment) { return { success: false, error: "Shipment not found." }; } if (shipment.status !== "dispatched" && shipment.status !== "in_transit") { return { success: false, error: "Can only toggle auto-return on active shipments.", }; } await payload.update({ collection: "shipments", id: shipmentId, data: { autoReturn }, }); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return { success: false, error: message }; } }