1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/shipments/actions.ts

581 lines
16 KiB
TypeScript

"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import type {
GameStructure,
GameVehicle,
Structure,
Vehicle,
Resource,
} 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 { processShipmentTick } from "@/scripts/processShipmentTick";
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<ActionResult> {
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<GameStructure["storedResources"]>,
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<typeof getVehicleEffectiveSpeed>[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<ActionResult> {
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<ActionResult> {
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 };
}
}