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 { getPayload } from "payload";
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { GameStructure, Structure, Faction, Resource } from "@/payload-types";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
|
|
@ -41,6 +42,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
const { id } = await params;
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const headers = await nextHeaders();
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
const structureRes = await payload.findByID({
|
||||
collection: "game-structures",
|
||||
|
|
@ -108,7 +111,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
{structure.faction.name}
|
||||
</span>
|
||||
)}
|
||||
<ManageStorageButton structureId={structure.id} />
|
||||
{user && <ManageStorageButton structureId={structure.id} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -208,6 +211,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
<StorageRules
|
||||
allowedStorage={structureType.allowedStorage}
|
||||
prohibitedStorage={structureType.prohibitedStorage}
|
||||
restrictToAllowed={structureType.restrictToAllowed}
|
||||
/>
|
||||
|
||||
{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 { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import {
|
||||
checkStorageDeposit,
|
||||
storageViolationMessage,
|
||||
} from "@/lib/storageRules";
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
|
|
@ -79,21 +83,7 @@ export async function addResource(
|
|||
const structure = await getStructure(payload, structureId);
|
||||
const structureType = structure.type;
|
||||
|
||||
// Check prohibited storage
|
||||
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
|
||||
// Fetch the resource to check rules + mass
|
||||
const resource = await payload.findByID({
|
||||
collection: "resources",
|
||||
id: resourceId,
|
||||
|
|
@ -104,6 +94,25 @@ export async function addResource(
|
|||
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
|
||||
const currentMass = calculateMass(structure.storedResources);
|
||||
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||
|
|
@ -366,6 +375,24 @@ export async function transferResource(
|
|||
const fromStructure = await getStructure(payload, fromStructureId);
|
||||
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 sourceVoid = getStoredAmount((fromStructure as any).voidStorage, resourceId);
|
||||
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({
|
||||
collection: "resources",
|
||||
id: resourceId,
|
||||
|
|
@ -388,6 +415,26 @@ export async function transferResource(
|
|||
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 addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||
const destMaxMass = toStructure.type.maxCapacityMass;
|
||||
|
|
@ -460,21 +507,25 @@ export async function placeResourceOnGrid(
|
|||
const structure = await getStructure(payload, structureId);
|
||||
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);
|
||||
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) {
|
||||
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.)",
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
type: "group",
|
||||
|
|
|
|||
|
|
@ -25,12 +25,19 @@ import {
|
|||
removeResource,
|
||||
transferResource,
|
||||
} 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 {
|
||||
ArrowDownToLineIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
ArrowRightLeftIcon,
|
||||
Loader2Icon,
|
||||
TruckIcon,
|
||||
} from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn, pluralize } from "@/lib/utils";
|
||||
|
|
@ -53,11 +60,21 @@ export function ManageStorageDialog({
|
|||
(GameStructure & { type: Structure })[]
|
||||
>([]);
|
||||
const [allResources, setAllResources] = useState<Resource[]>([]);
|
||||
const [availableVehicles, setAvailableVehicles] = useState<
|
||||
(GameVehicle & { type: Vehicle })[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
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
|
||||
const [depositResourceId, setDepositResourceId] = useState<string>("");
|
||||
const [depositAmount, setDepositAmount] = useState<string>("1");
|
||||
|
|
@ -70,14 +87,28 @@ export function ManageStorageDialog({
|
|||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
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?depth=2&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 allData = await allRes.json();
|
||||
const resourcesData = await resourcesRes.json();
|
||||
const vehiclesData = await vehiclesRes.json();
|
||||
setStructure(structData);
|
||||
setAllStructures(
|
||||
allData.docs.filter(
|
||||
|
|
@ -85,6 +116,7 @@ export function ManageStorageDialog({
|
|||
),
|
||||
);
|
||||
setAllResources(resourcesData.docs ?? []);
|
||||
setAvailableVehicles(vehiclesData.docs ?? []);
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to load structure data." });
|
||||
} finally {
|
||||
|
|
@ -103,6 +135,11 @@ export function ManageStorageDialog({
|
|||
setTransferToId("");
|
||||
setTransferResourceId("");
|
||||
setTransferAmount("1");
|
||||
setShipmentMode(false);
|
||||
setShipmentResourceId("");
|
||||
setShipmentAmount("1");
|
||||
setShipmentDestId("");
|
||||
setShipmentVehicleId("");
|
||||
}
|
||||
}, [open, fetchData]);
|
||||
|
||||
|
|
@ -134,25 +171,111 @@ export function ManageStorageDialog({
|
|||
return { resource, amount: entry.amount };
|
||||
};
|
||||
|
||||
const getDepositMax = (): number | undefined => {
|
||||
const resource = findResource(depositResourceId);
|
||||
if (!resource) return undefined;
|
||||
if (remainingMass === null) return undefined;
|
||||
const massPerUnit = resource.massPerUnit ?? 0;
|
||||
if (massPerUnit <= 0) return undefined;
|
||||
return Math.floor(remainingMass / massPerUnit);
|
||||
const getStoredAmountOf = (
|
||||
s: GameStructure & { type: Structure },
|
||||
resourceId: number,
|
||||
): number => {
|
||||
const grid = (s.storedResources ?? []).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);
|
||||
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 stored = findStoredResource(withdrawResourceId);
|
||||
return stored?.amount;
|
||||
};
|
||||
|
||||
const getTransferMax = (): number | undefined => {
|
||||
const getTransferMaxFor = (destId: string): number | undefined => {
|
||||
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 num = Number(value);
|
||||
if (isNaN(num) || num < 1) return "1";
|
||||
|
|
@ -236,15 +359,8 @@ export function ManageStorageDialog({
|
|||
|
||||
const isResourceAllowed = (resourceId: number): boolean => {
|
||||
if (!structureType) return true;
|
||||
const prohibited = 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;
|
||||
});
|
||||
return !prohibited;
|
||||
if (isResourceProhibited(structureType, resourceId)) return false;
|
||||
return isResourceWhitelisted(structureType, resourceId);
|
||||
};
|
||||
|
||||
const handleDeposit = () => {
|
||||
|
|
@ -306,7 +422,49 @@ export function ManageStorageDialog({
|
|||
await fetchData();
|
||||
router.refresh();
|
||||
} 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">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</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 && (
|
||||
|
|
@ -368,14 +661,9 @@ export function ManageStorageDialog({
|
|||
value={depositResourceId}
|
||||
onValueChange={(val) => {
|
||||
setDepositResourceId(val);
|
||||
const res = findResource(val);
|
||||
if (res) {
|
||||
const massPerUnit = res.massPerUnit ?? 0;
|
||||
const depositMax = remainingMass !== null && massPerUnit > 0
|
||||
? Math.floor(remainingMass / massPerUnit)
|
||||
: undefined;
|
||||
setDepositAmount(clampAmount(depositAmount, depositMax));
|
||||
}
|
||||
setDepositAmount(
|
||||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
|
|
@ -384,11 +672,20 @@ export function ManageStorageDialog({
|
|||
<SelectContent>
|
||||
{allResources
|
||||
.filter((r) => isResourceAllowed(r.id))
|
||||
.map((resource) => (
|
||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||
{resource.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
.map((resource) => {
|
||||
const cap = getAllowedDepositCap(resource.id);
|
||||
return (
|
||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||
{resource.name}
|
||||
{cap !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {cap.toLocaleString()} max
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -401,9 +698,12 @@ export function ManageStorageDialog({
|
|||
value={depositAmount}
|
||||
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
|
||||
/>
|
||||
{getDepositMax() !== undefined && (
|
||||
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -483,7 +783,15 @@ export function ManageStorageDialog({
|
|||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Destination</Label>
|
||||
<Select value={transferToId} onValueChange={setTransferToId}>
|
||||
<Select
|
||||
value={transferToId}
|
||||
onValueChange={(val) => {
|
||||
setTransferToId(val);
|
||||
setTransferAmount(
|
||||
clampAmount(transferAmount, getTransferMaxFor(val)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a structure..." />
|
||||
</SelectTrigger>
|
||||
|
|
@ -536,11 +844,42 @@ export function ManageStorageDialog({
|
|||
Max: {getTransferMax()!.toLocaleString()} stored
|
||||
</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>
|
||||
{renderCapacityPreview("transfer")}
|
||||
<Button
|
||||
onClick={handleTransfer}
|
||||
disabled={isPending || !transferToId || !transferResourceId}
|
||||
disabled={isPending || !transferToId || !transferResourceId || getTransferMax() === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
|
|
|
|||
|
|
@ -5,18 +5,28 @@ import {
|
|||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { CheckCircleIcon, XCircleIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ShieldCheckIcon,
|
||||
ListRestartIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface StorageRulesProps {
|
||||
allowedStorage: Structure["allowedStorage"];
|
||||
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 hasProhibited = prohibitedStorage && prohibitedStorage.length > 0;
|
||||
|
||||
if (!hasAllowed && !hasProhibited) return null;
|
||||
if (!hasAllowed && !hasProhibited && !restrictToAllowed) return null;
|
||||
|
||||
return (
|
||||
<Item variant="outline">
|
||||
|
|
@ -28,6 +38,15 @@ export function StorageRules({ allowedStorage, prohibitedStorage }: StorageRules
|
|||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<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 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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