534 lines
16 KiB
TypeScript
534 lines
16 KiB
TypeScript
"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<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 };
|
|
}
|
|
}
|