feat(logistics): enforce structure storage rules on deposits, transfers, and shipments
This commit is contained in:
parent
a9af0b5246
commit
01f4820f2e
7 changed files with 1204 additions and 70 deletions
581
src/app/(frontend)/logistics/shipments/actions.ts
Normal file
581
src/app/(frontend)/logistics/shipments/actions.ts
Normal file
|
|
@ -0,0 +1,581 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GameStructure, Structure, Faction, Resource } from "@/payload-types";
|
import { GameStructure, Structure, Faction, Resource } from "@/payload-types";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
|
|
@ -41,6 +42,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const payloadConfig = await config;
|
const payloadConfig = await config;
|
||||||
const payload = await getPayload({ config: payloadConfig });
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const headers = await nextHeaders();
|
||||||
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
const structureRes = await payload.findByID({
|
const structureRes = await payload.findByID({
|
||||||
collection: "game-structures",
|
collection: "game-structures",
|
||||||
|
|
@ -108,7 +111,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
{structure.faction.name}
|
{structure.faction.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ManageStorageButton structureId={structure.id} />
|
{user && <ManageStorageButton structureId={structure.id} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -208,6 +211,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
<StorageRules
|
<StorageRules
|
||||||
allowedStorage={structureType.allowedStorage}
|
allowedStorage={structureType.allowedStorage}
|
||||||
prohibitedStorage={structureType.prohibitedStorage}
|
prohibitedStorage={structureType.prohibitedStorage}
|
||||||
|
restrictToAllowed={structureType.restrictToAllowed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{structureType.upkeep && structureType.upkeep.length > 0 && (
|
{structureType.upkeep && structureType.upkeep.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import { GameStructure, Resource, Structure } from "@/payload-types";
|
||||||
import hasRoles from "@/utils/access-control/hasRoles";
|
import hasRoles from "@/utils/access-control/hasRoles";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import {
|
||||||
|
checkStorageDeposit,
|
||||||
|
storageViolationMessage,
|
||||||
|
} from "@/lib/storageRules";
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -79,21 +83,7 @@ export async function addResource(
|
||||||
const structure = await getStructure(payload, structureId);
|
const structure = await getStructure(payload, structureId);
|
||||||
const structureType = structure.type;
|
const structureType = structure.type;
|
||||||
|
|
||||||
// Check prohibited storage
|
// Fetch the resource to check rules + mass
|
||||||
const isProhibited = structureType.prohibitedStorage?.some((rule) => {
|
|
||||||
if (!rule.item) return false;
|
|
||||||
const item = rule.item;
|
|
||||||
if (typeof item === "object" && "id" in item) {
|
|
||||||
return item.relationTo === "resources" && item.value === resourceId;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isProhibited) {
|
|
||||||
return { success: false, error: "This resource is prohibited from storage at this structure." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the resource to check mass
|
|
||||||
const resource = await payload.findByID({
|
const resource = await payload.findByID({
|
||||||
collection: "resources",
|
collection: "resources",
|
||||||
id: resourceId,
|
id: resourceId,
|
||||||
|
|
@ -104,6 +94,25 @@ export async function addResource(
|
||||||
return { success: false, error: "Resource not found." };
|
return { success: false, error: "Resource not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check storage rules (prohibited → whitelist → per-item cap)
|
||||||
|
const currentStored =
|
||||||
|
getStoredAmount(structure.storedResources, resourceId) +
|
||||||
|
getStoredAmount(structure.voidStorage ?? [], resourceId);
|
||||||
|
|
||||||
|
const ruleCheck = checkStorageDeposit(
|
||||||
|
structureType,
|
||||||
|
resourceId,
|
||||||
|
amount,
|
||||||
|
currentStored,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ruleCheck.allowed) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: storageViolationMessage(resource.name, ruleCheck),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Check capacity
|
// Check capacity
|
||||||
const currentMass = calculateMass(structure.storedResources);
|
const currentMass = calculateMass(structure.storedResources);
|
||||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||||
|
|
@ -366,6 +375,24 @@ export async function transferResource(
|
||||||
const fromStructure = await getStructure(payload, fromStructureId);
|
const fromStructure = await getStructure(payload, fromStructureId);
|
||||||
const toStructure = await getStructure(payload, toStructureId);
|
const toStructure = await getStructure(payload, toStructureId);
|
||||||
|
|
||||||
|
// Check proximity — if beyond threshold, require shipment
|
||||||
|
const fromCoords = fromStructure.coordinates as [number, number] | null;
|
||||||
|
const toCoords = toStructure.coordinates as [number, number] | null;
|
||||||
|
|
||||||
|
if (fromCoords && toCoords) {
|
||||||
|
const gameRules = await payload.findGlobal({ slug: "game-rules" });
|
||||||
|
const threshold = (gameRules.proximityThreshold as number) || 10;
|
||||||
|
const { calculateDistance } = await import("@/lib/distance");
|
||||||
|
const distance = calculateDistance(fromCoords, toCoords);
|
||||||
|
|
||||||
|
if (distance > threshold) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Structures are too far apart for instant transfer (${distance.toFixed(1)} units, max ${threshold}). Use the Shipments system to transport resources over long distances.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sourceStored = getStoredAmount(fromStructure.storedResources, resourceId);
|
const sourceStored = getStoredAmount(fromStructure.storedResources, resourceId);
|
||||||
const sourceVoid = getStoredAmount((fromStructure as any).voidStorage, resourceId);
|
const sourceVoid = getStoredAmount((fromStructure as any).voidStorage, resourceId);
|
||||||
const sourceAmount = sourceStored + sourceVoid;
|
const sourceAmount = sourceStored + sourceVoid;
|
||||||
|
|
@ -377,7 +404,7 @@ export async function transferResource(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check destination capacity
|
// Fetch the resource for rule + capacity checks
|
||||||
const resource = await payload.findByID({
|
const resource = await payload.findByID({
|
||||||
collection: "resources",
|
collection: "resources",
|
||||||
id: resourceId,
|
id: resourceId,
|
||||||
|
|
@ -388,6 +415,26 @@ export async function transferResource(
|
||||||
return { success: false, error: "Resource not found." };
|
return { success: false, error: "Resource not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check destination storage rules (prohibited → whitelist → per-item cap)
|
||||||
|
const destCurrentStored =
|
||||||
|
getStoredAmount(toStructure.storedResources, resourceId) +
|
||||||
|
getStoredAmount(toStructure.voidStorage ?? [], resourceId);
|
||||||
|
|
||||||
|
const destRuleCheck = checkStorageDeposit(
|
||||||
|
toStructure.type,
|
||||||
|
resourceId,
|
||||||
|
amount,
|
||||||
|
destCurrentStored,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!destRuleCheck.allowed) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: storageViolationMessage(resource.name, destRuleCheck),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check destination capacity
|
||||||
const destMass = calculateMass(toStructure.storedResources);
|
const destMass = calculateMass(toStructure.storedResources);
|
||||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||||
const destMaxMass = toStructure.type.maxCapacityMass;
|
const destMaxMass = toStructure.type.maxCapacityMass;
|
||||||
|
|
@ -460,21 +507,25 @@ export async function placeResourceOnGrid(
|
||||||
const structure = await getStructure(payload, structureId);
|
const structure = await getStructure(payload, structureId);
|
||||||
const structureType = structure.type;
|
const structureType = structure.type;
|
||||||
|
|
||||||
const isProhibited = structureType.prohibitedStorage?.some((rule) => {
|
|
||||||
if (!rule.item) return false;
|
|
||||||
const item = rule.item;
|
|
||||||
if (typeof item === "object" && "id" in item) {
|
|
||||||
return item.relationTo === "resources" && item.value === resourceId;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (isProhibited) {
|
|
||||||
return { success: false, error: "This resource is prohibited from storage here." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null);
|
const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null);
|
||||||
if (!resource) return { success: false, error: "Resource not found." };
|
if (!resource) return { success: false, error: "Resource not found." };
|
||||||
|
|
||||||
|
// Check storage rules (prohibited → whitelist → per-item cap)
|
||||||
|
const currentStored =
|
||||||
|
getStoredAmount(structure.storedResources, resourceId) +
|
||||||
|
getStoredAmount(structure.voidStorage ?? [], resourceId);
|
||||||
|
|
||||||
|
const ruleCheck = checkStorageDeposit(
|
||||||
|
structureType,
|
||||||
|
resourceId,
|
||||||
|
amount,
|
||||||
|
currentStored,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ruleCheck.allowed) {
|
||||||
|
return { success: false, error: storageViolationMessage(resource.name, ruleCheck) };
|
||||||
|
}
|
||||||
|
|
||||||
if (resource.stackable && resource.maxStackSize && amount > resource.maxStackSize) {
|
if (resource.stackable && resource.maxStackSize && amount > resource.maxStackSize) {
|
||||||
return { success: false, error: `Cannot stack more than ${resource.maxStackSize} units.` };
|
return { success: false, error: `Cannot stack more than ${resource.maxStackSize} units.` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -619,6 +619,16 @@ export const Structures: CollectionConfig = {
|
||||||
"The maximum storage capacity of this structure as mass (retrieved from the mass defined for every asset stored here.)",
|
"The maximum storage capacity of this structure as mass (retrieved from the mass defined for every asset stored here.)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "restrictToAllowed",
|
||||||
|
type: "checkbox",
|
||||||
|
label: "Restrict to allowed items only",
|
||||||
|
defaultValue: false,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"When enabled, ONLY the items listed in Allowed Storage can be stored here — everything else is rejected. When disabled, the Allowed Storage list still enforces per-item quantity caps, but other items are permitted up to the mass capacity.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Storage Slot Dimensions",
|
label: "Storage Slot Dimensions",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,19 @@ import {
|
||||||
removeResource,
|
removeResource,
|
||||||
transferResource,
|
transferResource,
|
||||||
} from "@/app/(frontend)/logistics/structures/actions";
|
} from "@/app/(frontend)/logistics/structures/actions";
|
||||||
import { GameStructure, Structure, Resource } from "@/payload-types";
|
import { createShipment } from "@/app/(frontend)/logistics/shipments/actions";
|
||||||
|
import {
|
||||||
|
getResourceStorageCap,
|
||||||
|
isResourceProhibited,
|
||||||
|
isResourceWhitelisted,
|
||||||
|
} from "@/lib/storageRules";
|
||||||
|
import { GameStructure, Structure, Resource, GameVehicle, Vehicle } from "@/payload-types";
|
||||||
import {
|
import {
|
||||||
ArrowDownToLineIcon,
|
ArrowDownToLineIcon,
|
||||||
ArrowUpFromLineIcon,
|
ArrowUpFromLineIcon,
|
||||||
ArrowRightLeftIcon,
|
ArrowRightLeftIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
|
TruckIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { cn, pluralize } from "@/lib/utils";
|
import { cn, pluralize } from "@/lib/utils";
|
||||||
|
|
@ -53,11 +60,21 @@ export function ManageStorageDialog({
|
||||||
(GameStructure & { type: Structure })[]
|
(GameStructure & { type: Structure })[]
|
||||||
>([]);
|
>([]);
|
||||||
const [allResources, setAllResources] = useState<Resource[]>([]);
|
const [allResources, setAllResources] = useState<Resource[]>([]);
|
||||||
|
const [availableVehicles, setAvailableVehicles] = useState<
|
||||||
|
(GameVehicle & { type: Vehicle })[]
|
||||||
|
>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Shipment flow state
|
||||||
|
const [shipmentMode, setShipmentMode] = useState(false);
|
||||||
|
const [shipmentResourceId, setShipmentResourceId] = useState<string>("");
|
||||||
|
const [shipmentAmount, setShipmentAmount] = useState<string>("1");
|
||||||
|
const [shipmentDestId, setShipmentDestId] = useState<string>("");
|
||||||
|
const [shipmentVehicleId, setShipmentVehicleId] = useState<string>("");
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [depositResourceId, setDepositResourceId] = useState<string>("");
|
const [depositResourceId, setDepositResourceId] = useState<string>("");
|
||||||
const [depositAmount, setDepositAmount] = useState<string>("1");
|
const [depositAmount, setDepositAmount] = useState<string>("1");
|
||||||
|
|
@ -70,14 +87,28 @@ export function ManageStorageDialog({
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [structRes, allRes, resourcesRes] = await Promise.all([
|
const [structRes, allRes, resourcesRes, vehiclesRes] = await Promise.all([
|
||||||
fetch(`/api/game-structures/${structureId}?depth=2`),
|
fetch(`/api/game-structures/${structureId}?depth=2`),
|
||||||
fetch(`/api/game-structures?depth=2&limit=500`),
|
fetch(`/api/game-structures?depth=2&limit=500`),
|
||||||
fetch(`/api/resources?depth=0&limit=500`),
|
fetch(`/api/resources?depth=0&limit=500`),
|
||||||
|
fetch(`/api/game-vehicles?depth=2&limit=500&where[deployedAt][equals]=${structureId}&where[status][equals]=idle`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const anyUnauthorized = [structRes, allRes, resourcesRes, vehiclesRes].some(
|
||||||
|
(res) => res.status === 401 || res.status === 403,
|
||||||
|
);
|
||||||
|
if (anyUnauthorized) {
|
||||||
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: "You must be logged in to manage storage.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const structData = await structRes.json();
|
const structData = await structRes.json();
|
||||||
const allData = await allRes.json();
|
const allData = await allRes.json();
|
||||||
const resourcesData = await resourcesRes.json();
|
const resourcesData = await resourcesRes.json();
|
||||||
|
const vehiclesData = await vehiclesRes.json();
|
||||||
setStructure(structData);
|
setStructure(structData);
|
||||||
setAllStructures(
|
setAllStructures(
|
||||||
allData.docs.filter(
|
allData.docs.filter(
|
||||||
|
|
@ -85,6 +116,7 @@ export function ManageStorageDialog({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setAllResources(resourcesData.docs ?? []);
|
setAllResources(resourcesData.docs ?? []);
|
||||||
|
setAvailableVehicles(vehiclesData.docs ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
setMessage({ type: "error", text: "Failed to load structure data." });
|
setMessage({ type: "error", text: "Failed to load structure data." });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -103,6 +135,11 @@ export function ManageStorageDialog({
|
||||||
setTransferToId("");
|
setTransferToId("");
|
||||||
setTransferResourceId("");
|
setTransferResourceId("");
|
||||||
setTransferAmount("1");
|
setTransferAmount("1");
|
||||||
|
setShipmentMode(false);
|
||||||
|
setShipmentResourceId("");
|
||||||
|
setShipmentAmount("1");
|
||||||
|
setShipmentDestId("");
|
||||||
|
setShipmentVehicleId("");
|
||||||
}
|
}
|
||||||
}, [open, fetchData]);
|
}, [open, fetchData]);
|
||||||
|
|
||||||
|
|
@ -134,25 +171,111 @@ export function ManageStorageDialog({
|
||||||
return { resource, amount: entry.amount };
|
return { resource, amount: entry.amount };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDepositMax = (): number | undefined => {
|
const getStoredAmountOf = (
|
||||||
const resource = findResource(depositResourceId);
|
s: GameStructure & { type: Structure },
|
||||||
if (!resource) return undefined;
|
resourceId: number,
|
||||||
if (remainingMass === null) return undefined;
|
): number => {
|
||||||
const massPerUnit = resource.massPerUnit ?? 0;
|
const grid = (s.storedResources ?? []).reduce((sum, e) => {
|
||||||
if (massPerUnit <= 0) return undefined;
|
const res = e.resource;
|
||||||
return Math.floor(remainingMass / massPerUnit);
|
const rid =
|
||||||
|
typeof res === "object" && res !== null && "id" in res
|
||||||
|
? res.id
|
||||||
|
: (res as number);
|
||||||
|
return sum + (rid === resourceId ? e.amount : 0);
|
||||||
|
}, 0);
|
||||||
|
const voidAmount = (s.voidStorage ?? []).reduce((sum, e) => {
|
||||||
|
const res = e.resource;
|
||||||
|
const rid =
|
||||||
|
typeof res === "object" && res !== null && "id" in res
|
||||||
|
? res.id
|
||||||
|
: (res as number);
|
||||||
|
return sum + (rid === resourceId ? e.amount : 0);
|
||||||
|
}, 0);
|
||||||
|
return grid + voidAmount;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMassOf = (s: GameStructure & { type: Structure }): number => {
|
||||||
|
return (s.storedResources ?? []).reduce((total, entry) => {
|
||||||
|
const resource = entry.resource as Resource;
|
||||||
|
if (!resource || typeof resource === "number") return total;
|
||||||
|
return total + (resource.massPerUnit ?? 0) * entry.amount;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllowedDepositCap = (resourceId: number): number | undefined => {
|
||||||
|
if (!structureType) return undefined;
|
||||||
|
const cap = getResourceStorageCap(structureType, resourceId);
|
||||||
|
if (cap == null) return undefined;
|
||||||
|
const stored = structure ? getStoredAmountOf(structure, resourceId) : 0;
|
||||||
|
return Math.max(0, cap - stored);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDepositLimitInfoFor = (
|
||||||
|
resourceId: string,
|
||||||
|
): { max: number; reason: string } | undefined => {
|
||||||
|
const resource = findResource(resourceId);
|
||||||
|
if (!resource) return undefined;
|
||||||
|
const reasons: { value: number; label: string }[] = [];
|
||||||
|
if (remainingMass !== null) {
|
||||||
|
const massPerUnit = resource.massPerUnit ?? 0;
|
||||||
|
if (massPerUnit > 0) {
|
||||||
|
reasons.push({
|
||||||
|
value: Math.floor(remainingMass / massPerUnit),
|
||||||
|
label: "mass capacity",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allowance = getAllowedDepositCap(resource.id);
|
||||||
|
if (allowance !== undefined) {
|
||||||
|
reasons.push({ value: allowance, label: "storage allowance" });
|
||||||
|
}
|
||||||
|
if (reasons.length === 0) return undefined;
|
||||||
|
const binding = reasons.reduce(
|
||||||
|
(min, r) => (r.value < min.value ? r : min),
|
||||||
|
reasons[0],
|
||||||
|
);
|
||||||
|
return { max: binding.value, reason: binding.label };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDepositMax = (): number | undefined =>
|
||||||
|
getDepositLimitInfoFor(depositResourceId)?.max;
|
||||||
|
|
||||||
const getWithdrawMax = (): number | undefined => {
|
const getWithdrawMax = (): number | undefined => {
|
||||||
const stored = findStoredResource(withdrawResourceId);
|
const stored = findStoredResource(withdrawResourceId);
|
||||||
return stored?.amount;
|
return stored?.amount;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTransferMax = (): number | undefined => {
|
const getTransferMaxFor = (destId: string): number | undefined => {
|
||||||
const stored = findStoredResource(transferResourceId);
|
const stored = findStoredResource(transferResourceId);
|
||||||
return stored?.amount;
|
if (!stored) return undefined;
|
||||||
|
const resId = stored.resource.id;
|
||||||
|
const dest = allStructures.find((s) => s.id === Number(destId));
|
||||||
|
const destType = dest?.type;
|
||||||
|
if (dest && destType) {
|
||||||
|
if (isResourceProhibited(destType, resId)) return 0;
|
||||||
|
if (!isResourceWhitelisted(destType, resId)) return 0;
|
||||||
|
const caps: number[] = [stored.amount];
|
||||||
|
const cap = getResourceStorageCap(destType, resId);
|
||||||
|
if (cap != null) {
|
||||||
|
caps.push(Math.max(0, cap - getStoredAmountOf(dest, resId)));
|
||||||
|
}
|
||||||
|
const destMaxMass = destType.maxCapacityMass;
|
||||||
|
if (destMaxMass != null) {
|
||||||
|
const massPerUnit = stored.resource.massPerUnit ?? 0;
|
||||||
|
if (massPerUnit > 0) {
|
||||||
|
caps.push(
|
||||||
|
Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.min(...caps);
|
||||||
|
}
|
||||||
|
return stored.amount;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTransferMax = (): number | undefined =>
|
||||||
|
getTransferMaxFor(transferToId);
|
||||||
|
|
||||||
const clampAmount = (value: string, max: number | undefined): string => {
|
const clampAmount = (value: string, max: number | undefined): string => {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (isNaN(num) || num < 1) return "1";
|
if (isNaN(num) || num < 1) return "1";
|
||||||
|
|
@ -236,15 +359,8 @@ export function ManageStorageDialog({
|
||||||
|
|
||||||
const isResourceAllowed = (resourceId: number): boolean => {
|
const isResourceAllowed = (resourceId: number): boolean => {
|
||||||
if (!structureType) return true;
|
if (!structureType) return true;
|
||||||
const prohibited = structureType.prohibitedStorage?.some((rule) => {
|
if (isResourceProhibited(structureType, resourceId)) return false;
|
||||||
if (!rule.item) return false;
|
return isResourceWhitelisted(structureType, resourceId);
|
||||||
const item = rule.item;
|
|
||||||
if (typeof item === "object" && "id" in item) {
|
|
||||||
return item.relationTo === "resources" && item.value === resourceId;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
return !prohibited;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeposit = () => {
|
const handleDeposit = () => {
|
||||||
|
|
@ -306,7 +422,49 @@ export function ManageStorageDialog({
|
||||||
await fetchData();
|
await fetchData();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: "error", text: result.error ?? "Unknown error." });
|
const errorText = result.error ?? "Unknown error.";
|
||||||
|
// Detect distance error — offer shipment flow
|
||||||
|
if (errorText.includes("too far apart")) {
|
||||||
|
setShipmentMode(true);
|
||||||
|
setShipmentResourceId(transferResourceId);
|
||||||
|
setShipmentAmount(transferAmount);
|
||||||
|
setShipmentDestId(transferToId);
|
||||||
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: "These structures are too far for instant transfer. Create a shipment instead?",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "error", text: errorText });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateShipment = () => {
|
||||||
|
if (!shipmentDestId || !shipmentResourceId || !shipmentVehicleId) return;
|
||||||
|
const destId = Number(shipmentDestId);
|
||||||
|
const resId = Number(shipmentResourceId);
|
||||||
|
const vehicleId = Number(shipmentVehicleId);
|
||||||
|
const amt = Number(shipmentAmount);
|
||||||
|
if (isNaN(amt) || amt <= 0) {
|
||||||
|
setMessage({ type: "error", text: "Amount must be a positive number." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createShipment({
|
||||||
|
originId: structureId,
|
||||||
|
destinationId: destId,
|
||||||
|
vehicleId,
|
||||||
|
cargo: [{ relationTo: "resources", value: resId, amount: amt }],
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
setMessage({ type: "success", text: "Shipment created and dispatched!" });
|
||||||
|
setShipmentMode(false);
|
||||||
|
setShipmentAmount("1");
|
||||||
|
await fetchData();
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "error", text: result.error ?? "Failed to create shipment." });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -325,6 +483,141 @@ export function ManageStorageDialog({
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
) : shipmentMode ? (
|
||||||
|
/* ── Shipment Creation Mode ─────────────────────── */
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<TruckIcon className="size-4 text-muted-foreground" />
|
||||||
|
<span className="font-semibold">Create Shipment</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>From</span>
|
||||||
|
<span className="font-medium text-foreground">{structure?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>To</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{allStructures.find((s) => s.id === Number(shipmentDestId))?.name ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Cargo</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{shipmentAmount}× {allResources.find((r) => r.id === Number(shipmentResourceId))?.name ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Transport Vehicle</Label>
|
||||||
|
{availableVehicles.length > 0 ? (
|
||||||
|
<Select value={shipmentVehicleId} onValueChange={setShipmentVehicleId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a vehicle..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableVehicles.map((v) => {
|
||||||
|
const vType = typeof v.type === "object" ? v.type : null;
|
||||||
|
return (
|
||||||
|
<SelectItem key={v.id} value={String(v.id)}>
|
||||||
|
{v.name} ({vType?.name ?? "Unknown"}) — {v.currentFuel?.toFixed(0)} fuel
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
|
||||||
|
<TruckIcon className="size-4 shrink-0" />
|
||||||
|
<span>No idle vehicles stationed at this structure. Deploy a vehicle here first to create shipments.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shipmentVehicleId && (() => {
|
||||||
|
const selectedVehicle = availableVehicles.find((v) => v.id === Number(shipmentVehicleId));
|
||||||
|
const vType = selectedVehicle && typeof selectedVehicle.type === "object" ? selectedVehicle.type : null;
|
||||||
|
if (!selectedVehicle || !vType) return null;
|
||||||
|
|
||||||
|
const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId));
|
||||||
|
const originCoords = structure?.coordinates as [number, number] | null;
|
||||||
|
const destCoords = destStructure?.coordinates as [number, number] | null;
|
||||||
|
let distance = 0;
|
||||||
|
if (originCoords && destCoords) {
|
||||||
|
distance = Math.sqrt(
|
||||||
|
(destCoords[0] - originCoords[0]) ** 2 +
|
||||||
|
(destCoords[1] - originCoords[1]) ** 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1;
|
||||||
|
const fuelCost = Math.ceil(distance * consumptionRate);
|
||||||
|
const speed = vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50;
|
||||||
|
const transitHours = distance / speed;
|
||||||
|
const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs">
|
||||||
|
<div className="flex justify-between text-muted-foreground">
|
||||||
|
<span>Distance</span>
|
||||||
|
<span className="font-mono">{distance.toFixed(1)} units</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-muted-foreground">
|
||||||
|
<span>Fuel Cost</span>
|
||||||
|
<span className={cn("font-mono", !canAfford && "text-red-400 font-medium")}>
|
||||||
|
{fuelCost} units
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-muted-foreground">
|
||||||
|
<span>Transit Time</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{transitHours < 1
|
||||||
|
? `${Math.ceil(transitHours * 60)} min`
|
||||||
|
: `${transitHours.toFixed(1)} hr`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-muted-foreground">
|
||||||
|
<span>Vehicle Fuel</span>
|
||||||
|
<span className="font-mono">{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!canAfford && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm text-red-400">
|
||||||
|
<span>Vehicle does not have enough fuel for this trip. Need {fuelCost} units, has {selectedVehicle.currentFuel?.toFixed(1)}.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShipmentMode(false);
|
||||||
|
setMessage(null);
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateShipment}
|
||||||
|
disabled={isPending || !shipmentVehicleId || !shipmentDestId || availableVehicles.length === 0}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<TruckIcon className="size-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Dispatch Shipment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{maxMass && (
|
{maxMass && (
|
||||||
|
|
@ -368,14 +661,9 @@ export function ManageStorageDialog({
|
||||||
value={depositResourceId}
|
value={depositResourceId}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
setDepositResourceId(val);
|
setDepositResourceId(val);
|
||||||
const res = findResource(val);
|
setDepositAmount(
|
||||||
if (res) {
|
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||||
const massPerUnit = res.massPerUnit ?? 0;
|
);
|
||||||
const depositMax = remainingMass !== null && massPerUnit > 0
|
|
||||||
? Math.floor(remainingMass / massPerUnit)
|
|
||||||
: undefined;
|
|
||||||
setDepositAmount(clampAmount(depositAmount, depositMax));
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -384,11 +672,20 @@ export function ManageStorageDialog({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{allResources
|
{allResources
|
||||||
.filter((r) => isResourceAllowed(r.id))
|
.filter((r) => isResourceAllowed(r.id))
|
||||||
.map((resource) => (
|
.map((resource) => {
|
||||||
|
const cap = getAllowedDepositCap(resource.id);
|
||||||
|
return (
|
||||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||||
{resource.name}
|
{resource.name}
|
||||||
|
{cap !== undefined && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
— {cap.toLocaleString()} max
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -401,9 +698,12 @@ export function ManageStorageDialog({
|
||||||
value={depositAmount}
|
value={depositAmount}
|
||||||
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
|
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
|
||||||
/>
|
/>
|
||||||
{getDepositMax() !== undefined && (
|
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Max: {getDepositMax()!.toLocaleString()} (limited by remaining capacity)
|
Max:{" "}
|
||||||
|
{getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
|
||||||
|
(limited by{" "}
|
||||||
|
{getDepositLimitInfoFor(depositResourceId)!.reason})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -483,7 +783,15 @@ export function ManageStorageDialog({
|
||||||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Destination</Label>
|
<Label>Destination</Label>
|
||||||
<Select value={transferToId} onValueChange={setTransferToId}>
|
<Select
|
||||||
|
value={transferToId}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
setTransferToId(val);
|
||||||
|
setTransferAmount(
|
||||||
|
clampAmount(transferAmount, getTransferMaxFor(val)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a structure..." />
|
<SelectValue placeholder="Select a structure..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -536,11 +844,42 @@ export function ManageStorageDialog({
|
||||||
Max: {getTransferMax()!.toLocaleString()} stored
|
Max: {getTransferMax()!.toLocaleString()} stored
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{transferToId && transferResourceId && (() => {
|
||||||
|
const dest = allStructures.find((s) => s.id === Number(transferToId));
|
||||||
|
const destType = dest?.type;
|
||||||
|
if (!dest || !destType) return null;
|
||||||
|
const stored = findStoredResource(transferResourceId);
|
||||||
|
if (!stored) return null;
|
||||||
|
const resId = stored.resource.id;
|
||||||
|
if (isResourceProhibited(destType, resId)) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-red-400">
|
||||||
|
Destination prohibits this resource.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isResourceWhitelisted(destType, resId)) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-amber-400">
|
||||||
|
Destination only accepts items on its allowed storage list — this resource is not permitted.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cap = getResourceStorageCap(destType, resId);
|
||||||
|
const destStored = getStoredAmountOf(dest, resId);
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{cap != null
|
||||||
|
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
|
||||||
|
: "Destination has no per-item storage limit"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{renderCapacityPreview("transfer")}
|
{renderCapacityPreview("transfer")}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleTransfer}
|
onClick={handleTransfer}
|
||||||
disabled={isPending || !transferToId || !transferResourceId}
|
disabled={isPending || !transferToId || !transferResourceId || getTransferMax() === 0}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,28 @@ import {
|
||||||
ItemHeader,
|
ItemHeader,
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { CheckCircleIcon, XCircleIcon, ShieldCheckIcon } from "lucide-react";
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
ListRestartIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface StorageRulesProps {
|
interface StorageRulesProps {
|
||||||
allowedStorage: Structure["allowedStorage"];
|
allowedStorage: Structure["allowedStorage"];
|
||||||
prohibitedStorage: Structure["prohibitedStorage"];
|
prohibitedStorage: Structure["prohibitedStorage"];
|
||||||
|
restrictToAllowed?: Structure["restrictToAllowed"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StorageRules({ allowedStorage, prohibitedStorage }: StorageRulesProps) {
|
export function StorageRules({
|
||||||
|
allowedStorage,
|
||||||
|
prohibitedStorage,
|
||||||
|
restrictToAllowed,
|
||||||
|
}: StorageRulesProps) {
|
||||||
const hasAllowed = allowedStorage && allowedStorage.length > 0;
|
const hasAllowed = allowedStorage && allowedStorage.length > 0;
|
||||||
const hasProhibited = prohibitedStorage && prohibitedStorage.length > 0;
|
const hasProhibited = prohibitedStorage && prohibitedStorage.length > 0;
|
||||||
|
|
||||||
if (!hasAllowed && !hasProhibited) return null;
|
if (!hasAllowed && !hasProhibited && !restrictToAllowed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item variant="outline">
|
<Item variant="outline">
|
||||||
|
|
@ -28,6 +38,15 @@ export function StorageRules({ allowedStorage, prohibitedStorage }: StorageRules
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{restrictToAllowed && (
|
||||||
|
<div className="flex items-center gap-2 p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-xs text-amber-400">
|
||||||
|
<ListRestartIcon className="size-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Whitelist mode — only the items listed under "Allowed" can be
|
||||||
|
stored here. All other items are rejected.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{hasAllowed && (
|
{hasAllowed && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
|
|
||||||
130
src/lib/storageRules.ts
Normal file
130
src/lib/storageRules.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import type { Structure } from "@/payload-types";
|
||||||
|
|
||||||
|
type ResourceRef = {
|
||||||
|
relationTo: "resources";
|
||||||
|
value: number | { id: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StorageRulesSource = Pick<
|
||||||
|
Structure,
|
||||||
|
"allowedStorage" | "prohibitedStorage" | "restrictToAllowed"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type StorageRuleViolation =
|
||||||
|
| "prohibited"
|
||||||
|
| "not_whitelisted"
|
||||||
|
| "over_cap";
|
||||||
|
|
||||||
|
export interface StorageRuleCheck {
|
||||||
|
allowed: boolean;
|
||||||
|
violation?: StorageRuleViolation;
|
||||||
|
cap?: number;
|
||||||
|
currentStored?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResourceRef(item: unknown): item is ResourceRef {
|
||||||
|
if (!item || typeof item !== "object") return false;
|
||||||
|
const ref = item as { relationTo?: string };
|
||||||
|
return ref.relationTo === "resources";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResourceId(value: number | { id: number }): number {
|
||||||
|
return typeof value === "object" && value !== null ? value.id : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this resource is on the structure's prohibited list.
|
||||||
|
*/
|
||||||
|
export function isResourceProhibited(
|
||||||
|
rules: StorageRulesSource,
|
||||||
|
resourceId: number,
|
||||||
|
): boolean {
|
||||||
|
return (rules.prohibitedStorage ?? []).some((rule) => {
|
||||||
|
if (!isResourceRef(rule.item)) return false;
|
||||||
|
return resolveResourceId(rule.item.value) === resourceId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this resource passes the whitelist gate. When `restrictToAllowed` is
|
||||||
|
* enabled, only resources listed in `allowedStorage` pass. Otherwise everything
|
||||||
|
* passes (prohibited list still applies separately).
|
||||||
|
*/
|
||||||
|
export function isResourceWhitelisted(
|
||||||
|
rules: StorageRulesSource,
|
||||||
|
resourceId: number,
|
||||||
|
): boolean {
|
||||||
|
if (!rules.restrictToAllowed) return true;
|
||||||
|
return (rules.allowedStorage ?? []).some((rule) => {
|
||||||
|
if (!isResourceRef(rule.item)) return false;
|
||||||
|
return resolveResourceId(rule.item.value) === resourceId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-item quantity cap defined in `allowedStorage`, or null if none set.
|
||||||
|
*/
|
||||||
|
export function getResourceStorageCap(
|
||||||
|
rules: StorageRulesSource,
|
||||||
|
resourceId: number,
|
||||||
|
): number | null {
|
||||||
|
const entry = (rules.allowedStorage ?? []).find((rule) => {
|
||||||
|
if (!isResourceRef(rule.item)) return false;
|
||||||
|
return resolveResourceId(rule.item.value) === resourceId;
|
||||||
|
});
|
||||||
|
return entry?.amount ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a deposit/transfer of `amount` of `resourceId` into a structure,
|
||||||
|
* given how much of that resource is currently stored (grid + void).
|
||||||
|
*
|
||||||
|
* Checks, in order: prohibited list, whitelist gate, per-item quantity cap.
|
||||||
|
*/
|
||||||
|
export function checkStorageDeposit(
|
||||||
|
rules: StorageRulesSource,
|
||||||
|
resourceId: number,
|
||||||
|
amount: number,
|
||||||
|
currentStored: number,
|
||||||
|
): StorageRuleCheck {
|
||||||
|
if (isResourceProhibited(rules, resourceId)) {
|
||||||
|
return { allowed: false, violation: "prohibited" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isResourceWhitelisted(rules, resourceId)) {
|
||||||
|
return { allowed: false, violation: "not_whitelisted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = getResourceStorageCap(rules, resourceId);
|
||||||
|
if (cap != null && currentStored + amount > cap) {
|
||||||
|
return { allowed: false, violation: "over_cap", cap, currentStored };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, cap: cap ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable message describing why a deposit/transfer was rejected.
|
||||||
|
*/
|
||||||
|
export function storageViolationMessage(
|
||||||
|
resourceName: string,
|
||||||
|
check: StorageRuleCheck,
|
||||||
|
): string {
|
||||||
|
switch (check.violation) {
|
||||||
|
case "prohibited":
|
||||||
|
return `This resource is prohibited from storage at this structure.`;
|
||||||
|
case "not_whitelisted":
|
||||||
|
return `This structure only accepts items on its allowed storage list — "${resourceName}" is not permitted.`;
|
||||||
|
case "over_cap": {
|
||||||
|
const available = Math.max(
|
||||||
|
0,
|
||||||
|
(check.cap ?? 0) - (check.currentStored ?? 0),
|
||||||
|
);
|
||||||
|
return available <= 0
|
||||||
|
? `${resourceName} is at its storage allowance here (${check.cap?.toLocaleString()}). No more can be stored.`
|
||||||
|
: `${resourceName} is limited to ${check.cap?.toLocaleString()} units here — you can add at most ${available.toLocaleString()} more.`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `This resource cannot be stored here.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue