1
0
Fork 0

feat(logistics): enforce structure storage rules on deposits, transfers, and shipments

This commit is contained in:
Jason Fraley 2026-08-02 02:04:07 -04:00
parent a9af0b5246
commit 01f4820f2e
7 changed files with 1204 additions and 70 deletions

View 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 };
}
}

View file

@ -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 && (

View file

@ -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.` };
}

View file

@ -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",

View file

@ -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 ? (

View file

@ -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
View 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.`;
}
}