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 config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link"; import Link from "next/link";
import { GameStructure, Structure, Faction, Resource } from "@/payload-types"; import { GameStructure, Structure, Faction, Resource } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
@ -41,6 +42,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
const { id } = await params; const { id } = await params;
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); const payload = await getPayload({ config: payloadConfig });
const headers = await nextHeaders();
const { user } = await payload.auth({ headers, canSetHeaders: false });
const structureRes = await payload.findByID({ const structureRes = await payload.findByID({
collection: "game-structures", collection: "game-structures",
@ -108,7 +111,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
{structure.faction.name} {structure.faction.name}
</span> </span>
)} )}
<ManageStorageButton structureId={structure.id} /> {user && <ManageStorageButton structureId={structure.id} />}
</div> </div>
</div> </div>
</div> </div>
@ -208,6 +211,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
<StorageRules <StorageRules
allowedStorage={structureType.allowedStorage} allowedStorage={structureType.allowedStorage}
prohibitedStorage={structureType.prohibitedStorage} prohibitedStorage={structureType.prohibitedStorage}
restrictToAllowed={structureType.restrictToAllowed}
/> />
{structureType.upkeep && structureType.upkeep.length > 0 && ( {structureType.upkeep && structureType.upkeep.length > 0 && (

View file

@ -6,6 +6,10 @@ import { GameStructure, Resource, Structure } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit"; import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import {
checkStorageDeposit,
storageViolationMessage,
} from "@/lib/storageRules";
interface ActionResult { interface ActionResult {
success: boolean; success: boolean;
@ -79,21 +83,7 @@ export async function addResource(
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const structureType = structure.type; const structureType = structure.type;
// Check prohibited storage // Fetch the resource to check rules + mass
const isProhibited = structureType.prohibitedStorage?.some((rule) => {
if (!rule.item) return false;
const item = rule.item;
if (typeof item === "object" && "id" in item) {
return item.relationTo === "resources" && item.value === resourceId;
}
return false;
});
if (isProhibited) {
return { success: false, error: "This resource is prohibited from storage at this structure." };
}
// Fetch the resource to check mass
const resource = await payload.findByID({ const resource = await payload.findByID({
collection: "resources", collection: "resources",
id: resourceId, id: resourceId,
@ -104,6 +94,25 @@ export async function addResource(
return { success: false, error: "Resource not found." }; return { success: false, error: "Resource not found." };
} }
// Check storage rules (prohibited → whitelist → per-item cap)
const currentStored =
getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit(
structureType,
resourceId,
amount,
currentStored,
);
if (!ruleCheck.allowed) {
return {
success: false,
error: storageViolationMessage(resource.name, ruleCheck),
};
}
// Check capacity // Check capacity
const currentMass = calculateMass(structure.storedResources); const currentMass = calculateMass(structure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount; const addedMass = (resource.massPerUnit ?? 0) * amount;
@ -366,6 +375,24 @@ export async function transferResource(
const fromStructure = await getStructure(payload, fromStructureId); const fromStructure = await getStructure(payload, fromStructureId);
const toStructure = await getStructure(payload, toStructureId); const toStructure = await getStructure(payload, toStructureId);
// Check proximity — if beyond threshold, require shipment
const fromCoords = fromStructure.coordinates as [number, number] | null;
const toCoords = toStructure.coordinates as [number, number] | null;
if (fromCoords && toCoords) {
const gameRules = await payload.findGlobal({ slug: "game-rules" });
const threshold = (gameRules.proximityThreshold as number) || 10;
const { calculateDistance } = await import("@/lib/distance");
const distance = calculateDistance(fromCoords, toCoords);
if (distance > threshold) {
return {
success: false,
error: `Structures are too far apart for instant transfer (${distance.toFixed(1)} units, max ${threshold}). Use the Shipments system to transport resources over long distances.`,
};
}
}
const sourceStored = getStoredAmount(fromStructure.storedResources, resourceId); const sourceStored = getStoredAmount(fromStructure.storedResources, resourceId);
const sourceVoid = getStoredAmount((fromStructure as any).voidStorage, resourceId); const sourceVoid = getStoredAmount((fromStructure as any).voidStorage, resourceId);
const sourceAmount = sourceStored + sourceVoid; const sourceAmount = sourceStored + sourceVoid;
@ -377,7 +404,7 @@ export async function transferResource(
}; };
} }
// Check destination capacity // Fetch the resource for rule + capacity checks
const resource = await payload.findByID({ const resource = await payload.findByID({
collection: "resources", collection: "resources",
id: resourceId, id: resourceId,
@ -388,6 +415,26 @@ export async function transferResource(
return { success: false, error: "Resource not found." }; return { success: false, error: "Resource not found." };
} }
// Check destination storage rules (prohibited → whitelist → per-item cap)
const destCurrentStored =
getStoredAmount(toStructure.storedResources, resourceId) +
getStoredAmount(toStructure.voidStorage ?? [], resourceId);
const destRuleCheck = checkStorageDeposit(
toStructure.type,
resourceId,
amount,
destCurrentStored,
);
if (!destRuleCheck.allowed) {
return {
success: false,
error: storageViolationMessage(resource.name, destRuleCheck),
};
}
// Check destination capacity
const destMass = calculateMass(toStructure.storedResources); const destMass = calculateMass(toStructure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount; const addedMass = (resource.massPerUnit ?? 0) * amount;
const destMaxMass = toStructure.type.maxCapacityMass; const destMaxMass = toStructure.type.maxCapacityMass;
@ -460,21 +507,25 @@ export async function placeResourceOnGrid(
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const structureType = structure.type; const structureType = structure.type;
const isProhibited = structureType.prohibitedStorage?.some((rule) => {
if (!rule.item) return false;
const item = rule.item;
if (typeof item === "object" && "id" in item) {
return item.relationTo === "resources" && item.value === resourceId;
}
return false;
});
if (isProhibited) {
return { success: false, error: "This resource is prohibited from storage here." };
}
const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null); const resource = await payload.findByID({ collection: "resources", id: resourceId, depth: 0 }).catch(() => null);
if (!resource) return { success: false, error: "Resource not found." }; if (!resource) return { success: false, error: "Resource not found." };
// Check storage rules (prohibited → whitelist → per-item cap)
const currentStored =
getStoredAmount(structure.storedResources, resourceId) +
getStoredAmount(structure.voidStorage ?? [], resourceId);
const ruleCheck = checkStorageDeposit(
structureType,
resourceId,
amount,
currentStored,
);
if (!ruleCheck.allowed) {
return { success: false, error: storageViolationMessage(resource.name, ruleCheck) };
}
if (resource.stackable && resource.maxStackSize && amount > resource.maxStackSize) { if (resource.stackable && resource.maxStackSize && amount > resource.maxStackSize) {
return { success: false, error: `Cannot stack more than ${resource.maxStackSize} units.` }; return { success: false, error: `Cannot stack more than ${resource.maxStackSize} units.` };
} }

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.)", "The maximum storage capacity of this structure as mass (retrieved from the mass defined for every asset stored here.)",
}, },
}, },
{
name: "restrictToAllowed",
type: "checkbox",
label: "Restrict to allowed items only",
defaultValue: false,
admin: {
description:
"When enabled, ONLY the items listed in Allowed Storage can be stored here — everything else is rejected. When disabled, the Allowed Storage list still enforces per-item quantity caps, but other items are permitted up to the mass capacity.",
},
},
{ {
label: "Storage Slot Dimensions", label: "Storage Slot Dimensions",
type: "group", type: "group",

View file

@ -25,12 +25,19 @@ import {
removeResource, removeResource,
transferResource, transferResource,
} from "@/app/(frontend)/logistics/structures/actions"; } from "@/app/(frontend)/logistics/structures/actions";
import { GameStructure, Structure, Resource } from "@/payload-types"; import { createShipment } from "@/app/(frontend)/logistics/shipments/actions";
import {
getResourceStorageCap,
isResourceProhibited,
isResourceWhitelisted,
} from "@/lib/storageRules";
import { GameStructure, Structure, Resource, GameVehicle, Vehicle } from "@/payload-types";
import { import {
ArrowDownToLineIcon, ArrowDownToLineIcon,
ArrowUpFromLineIcon, ArrowUpFromLineIcon,
ArrowRightLeftIcon, ArrowRightLeftIcon,
Loader2Icon, Loader2Icon,
TruckIcon,
} from "lucide-react"; } from "lucide-react";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { cn, pluralize } from "@/lib/utils"; import { cn, pluralize } from "@/lib/utils";
@ -53,11 +60,21 @@ export function ManageStorageDialog({
(GameStructure & { type: Structure })[] (GameStructure & { type: Structure })[]
>([]); >([]);
const [allResources, setAllResources] = useState<Resource[]>([]); const [allResources, setAllResources] = useState<Resource[]>([]);
const [availableVehicles, setAvailableVehicles] = useState<
(GameVehicle & { type: Vehicle })[]
>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const router = useRouter(); const router = useRouter();
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Shipment flow state
const [shipmentMode, setShipmentMode] = useState(false);
const [shipmentResourceId, setShipmentResourceId] = useState<string>("");
const [shipmentAmount, setShipmentAmount] = useState<string>("1");
const [shipmentDestId, setShipmentDestId] = useState<string>("");
const [shipmentVehicleId, setShipmentVehicleId] = useState<string>("");
// Form state // Form state
const [depositResourceId, setDepositResourceId] = useState<string>(""); const [depositResourceId, setDepositResourceId] = useState<string>("");
const [depositAmount, setDepositAmount] = useState<string>("1"); const [depositAmount, setDepositAmount] = useState<string>("1");
@ -70,14 +87,28 @@ export function ManageStorageDialog({
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [structRes, allRes, resourcesRes] = await Promise.all([ const [structRes, allRes, resourcesRes, vehiclesRes] = await Promise.all([
fetch(`/api/game-structures/${structureId}?depth=2`), fetch(`/api/game-structures/${structureId}?depth=2`),
fetch(`/api/game-structures?depth=2&limit=500`), fetch(`/api/game-structures?depth=2&limit=500`),
fetch(`/api/resources?depth=0&limit=500`), fetch(`/api/resources?depth=0&limit=500`),
fetch(`/api/game-vehicles?depth=2&limit=500&where[deployedAt][equals]=${structureId}&where[status][equals]=idle`),
]); ]);
const anyUnauthorized = [structRes, allRes, resourcesRes, vehiclesRes].some(
(res) => res.status === 401 || res.status === 403,
);
if (anyUnauthorized) {
setMessage({
type: "error",
text: "You must be logged in to manage storage.",
});
return;
}
const structData = await structRes.json(); const structData = await structRes.json();
const allData = await allRes.json(); const allData = await allRes.json();
const resourcesData = await resourcesRes.json(); const resourcesData = await resourcesRes.json();
const vehiclesData = await vehiclesRes.json();
setStructure(structData); setStructure(structData);
setAllStructures( setAllStructures(
allData.docs.filter( allData.docs.filter(
@ -85,6 +116,7 @@ export function ManageStorageDialog({
), ),
); );
setAllResources(resourcesData.docs ?? []); setAllResources(resourcesData.docs ?? []);
setAvailableVehicles(vehiclesData.docs ?? []);
} catch { } catch {
setMessage({ type: "error", text: "Failed to load structure data." }); setMessage({ type: "error", text: "Failed to load structure data." });
} finally { } finally {
@ -103,6 +135,11 @@ export function ManageStorageDialog({
setTransferToId(""); setTransferToId("");
setTransferResourceId(""); setTransferResourceId("");
setTransferAmount("1"); setTransferAmount("1");
setShipmentMode(false);
setShipmentResourceId("");
setShipmentAmount("1");
setShipmentDestId("");
setShipmentVehicleId("");
} }
}, [open, fetchData]); }, [open, fetchData]);
@ -134,25 +171,111 @@ export function ManageStorageDialog({
return { resource, amount: entry.amount }; return { resource, amount: entry.amount };
}; };
const getDepositMax = (): number | undefined => { const getStoredAmountOf = (
const resource = findResource(depositResourceId); s: GameStructure & { type: Structure },
if (!resource) return undefined; resourceId: number,
if (remainingMass === null) return undefined; ): number => {
const massPerUnit = resource.massPerUnit ?? 0; const grid = (s.storedResources ?? []).reduce((sum, e) => {
if (massPerUnit <= 0) return undefined; const res = e.resource;
return Math.floor(remainingMass / massPerUnit); const rid =
typeof res === "object" && res !== null && "id" in res
? res.id
: (res as number);
return sum + (rid === resourceId ? e.amount : 0);
}, 0);
const voidAmount = (s.voidStorage ?? []).reduce((sum, e) => {
const res = e.resource;
const rid =
typeof res === "object" && res !== null && "id" in res
? res.id
: (res as number);
return sum + (rid === resourceId ? e.amount : 0);
}, 0);
return grid + voidAmount;
}; };
const getMassOf = (s: GameStructure & { type: Structure }): number => {
return (s.storedResources ?? []).reduce((total, entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return total;
return total + (resource.massPerUnit ?? 0) * entry.amount;
}, 0);
};
const getAllowedDepositCap = (resourceId: number): number | undefined => {
if (!structureType) return undefined;
const cap = getResourceStorageCap(structureType, resourceId);
if (cap == null) return undefined;
const stored = structure ? getStoredAmountOf(structure, resourceId) : 0;
return Math.max(0, cap - stored);
};
const getDepositLimitInfoFor = (
resourceId: string,
): { max: number; reason: string } | undefined => {
const resource = findResource(resourceId);
if (!resource) return undefined;
const reasons: { value: number; label: string }[] = [];
if (remainingMass !== null) {
const massPerUnit = resource.massPerUnit ?? 0;
if (massPerUnit > 0) {
reasons.push({
value: Math.floor(remainingMass / massPerUnit),
label: "mass capacity",
});
}
}
const allowance = getAllowedDepositCap(resource.id);
if (allowance !== undefined) {
reasons.push({ value: allowance, label: "storage allowance" });
}
if (reasons.length === 0) return undefined;
const binding = reasons.reduce(
(min, r) => (r.value < min.value ? r : min),
reasons[0],
);
return { max: binding.value, reason: binding.label };
};
const getDepositMax = (): number | undefined =>
getDepositLimitInfoFor(depositResourceId)?.max;
const getWithdrawMax = (): number | undefined => { const getWithdrawMax = (): number | undefined => {
const stored = findStoredResource(withdrawResourceId); const stored = findStoredResource(withdrawResourceId);
return stored?.amount; return stored?.amount;
}; };
const getTransferMax = (): number | undefined => { const getTransferMaxFor = (destId: string): number | undefined => {
const stored = findStoredResource(transferResourceId); const stored = findStoredResource(transferResourceId);
return stored?.amount; if (!stored) return undefined;
const resId = stored.resource.id;
const dest = allStructures.find((s) => s.id === Number(destId));
const destType = dest?.type;
if (dest && destType) {
if (isResourceProhibited(destType, resId)) return 0;
if (!isResourceWhitelisted(destType, resId)) return 0;
const caps: number[] = [stored.amount];
const cap = getResourceStorageCap(destType, resId);
if (cap != null) {
caps.push(Math.max(0, cap - getStoredAmountOf(dest, resId)));
}
const destMaxMass = destType.maxCapacityMass;
if (destMaxMass != null) {
const massPerUnit = stored.resource.massPerUnit ?? 0;
if (massPerUnit > 0) {
caps.push(
Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit),
);
}
}
return Math.min(...caps);
}
return stored.amount;
}; };
const getTransferMax = (): number | undefined =>
getTransferMaxFor(transferToId);
const clampAmount = (value: string, max: number | undefined): string => { const clampAmount = (value: string, max: number | undefined): string => {
const num = Number(value); const num = Number(value);
if (isNaN(num) || num < 1) return "1"; if (isNaN(num) || num < 1) return "1";
@ -236,15 +359,8 @@ export function ManageStorageDialog({
const isResourceAllowed = (resourceId: number): boolean => { const isResourceAllowed = (resourceId: number): boolean => {
if (!structureType) return true; if (!structureType) return true;
const prohibited = structureType.prohibitedStorage?.some((rule) => { if (isResourceProhibited(structureType, resourceId)) return false;
if (!rule.item) return false; return isResourceWhitelisted(structureType, resourceId);
const item = rule.item;
if (typeof item === "object" && "id" in item) {
return item.relationTo === "resources" && item.value === resourceId;
}
return false;
});
return !prohibited;
}; };
const handleDeposit = () => { const handleDeposit = () => {
@ -306,7 +422,49 @@ export function ManageStorageDialog({
await fetchData(); await fetchData();
router.refresh(); router.refresh();
} else { } else {
setMessage({ type: "error", text: result.error ?? "Unknown error." }); const errorText = result.error ?? "Unknown error.";
// Detect distance error — offer shipment flow
if (errorText.includes("too far apart")) {
setShipmentMode(true);
setShipmentResourceId(transferResourceId);
setShipmentAmount(transferAmount);
setShipmentDestId(transferToId);
setMessage({
type: "error",
text: "These structures are too far for instant transfer. Create a shipment instead?",
});
} else {
setMessage({ type: "error", text: errorText });
}
}
});
};
const handleCreateShipment = () => {
if (!shipmentDestId || !shipmentResourceId || !shipmentVehicleId) return;
const destId = Number(shipmentDestId);
const resId = Number(shipmentResourceId);
const vehicleId = Number(shipmentVehicleId);
const amt = Number(shipmentAmount);
if (isNaN(amt) || amt <= 0) {
setMessage({ type: "error", text: "Amount must be a positive number." });
return;
}
startTransition(async () => {
const result = await createShipment({
originId: structureId,
destinationId: destId,
vehicleId,
cargo: [{ relationTo: "resources", value: resId, amount: amt }],
});
if (result.success) {
setMessage({ type: "success", text: "Shipment created and dispatched!" });
setShipmentMode(false);
setShipmentAmount("1");
await fetchData();
router.refresh();
} else {
setMessage({ type: "error", text: result.error ?? "Failed to create shipment." });
} }
}); });
}; };
@ -325,6 +483,141 @@ export function ManageStorageDialog({
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" /> <Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div> </div>
) : shipmentMode ? (
/* ── Shipment Creation Mode ─────────────────────── */
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm">
<TruckIcon className="size-4 text-muted-foreground" />
<span className="font-semibold">Create Shipment</span>
</div>
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>From</span>
<span className="font-medium text-foreground">{structure?.name}</span>
</div>
<div className="flex justify-between">
<span>To</span>
<span className="font-medium text-foreground">
{allStructures.find((s) => s.id === Number(shipmentDestId))?.name ?? "Unknown"}
</span>
</div>
<div className="flex justify-between">
<span>Cargo</span>
<span className="font-medium text-foreground">
{shipmentAmount}× {allResources.find((r) => r.id === Number(shipmentResourceId))?.name ?? "Unknown"}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Transport Vehicle</Label>
{availableVehicles.length > 0 ? (
<Select value={shipmentVehicleId} onValueChange={setShipmentVehicleId}>
<SelectTrigger>
<SelectValue placeholder="Select a vehicle..." />
</SelectTrigger>
<SelectContent>
{availableVehicles.map((v) => {
const vType = typeof v.type === "object" ? v.type : null;
return (
<SelectItem key={v.id} value={String(v.id)}>
{v.name} ({vType?.name ?? "Unknown"}) — {v.currentFuel?.toFixed(0)} fuel
</SelectItem>
);
})}
</SelectContent>
</Select>
) : (
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
<TruckIcon className="size-4 shrink-0" />
<span>No idle vehicles stationed at this structure. Deploy a vehicle here first to create shipments.</span>
</div>
)}
</div>
{shipmentVehicleId && (() => {
const selectedVehicle = availableVehicles.find((v) => v.id === Number(shipmentVehicleId));
const vType = selectedVehicle && typeof selectedVehicle.type === "object" ? selectedVehicle.type : null;
if (!selectedVehicle || !vType) return null;
const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId));
const originCoords = structure?.coordinates as [number, number] | null;
const destCoords = destStructure?.coordinates as [number, number] | null;
let distance = 0;
if (originCoords && destCoords) {
distance = Math.sqrt(
(destCoords[0] - originCoords[0]) ** 2 +
(destCoords[1] - originCoords[1]) ** 2,
);
}
const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1;
const fuelCost = Math.ceil(distance * consumptionRate);
const speed = vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50;
const transitHours = distance / speed;
const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost;
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs">
<div className="flex justify-between text-muted-foreground">
<span>Distance</span>
<span className="font-mono">{distance.toFixed(1)} units</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Fuel Cost</span>
<span className={cn("font-mono", !canAfford && "text-red-400 font-medium")}>
{fuelCost} units
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Transit Time</span>
<span className="font-mono">
{transitHours < 1
? `${Math.ceil(transitHours * 60)} min`
: `${transitHours.toFixed(1)} hr`}
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Vehicle Fuel</span>
<span className="font-mono">{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}</span>
</div>
</div>
{!canAfford && (
<div className="flex items-center gap-2 p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm text-red-400">
<span>Vehicle does not have enough fuel for this trip. Need {fuelCost} units, has {selectedVehicle.currentFuel?.toFixed(1)}.</span>
</div>
)}
</div>
);
})()}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setShipmentMode(false);
setMessage(null);
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleCreateShipment}
disabled={isPending || !shipmentVehicleId || !shipmentDestId || availableVehicles.length === 0}
className="flex-1"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<TruckIcon className="size-4 mr-2" />
)}
Dispatch Shipment
</Button>
</div>
</div>
) : ( ) : (
<> <>
{maxMass && ( {maxMass && (
@ -368,14 +661,9 @@ export function ManageStorageDialog({
value={depositResourceId} value={depositResourceId}
onValueChange={(val) => { onValueChange={(val) => {
setDepositResourceId(val); setDepositResourceId(val);
const res = findResource(val); setDepositAmount(
if (res) { clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
const massPerUnit = res.massPerUnit ?? 0; );
const depositMax = remainingMass !== null && massPerUnit > 0
? Math.floor(remainingMass / massPerUnit)
: undefined;
setDepositAmount(clampAmount(depositAmount, depositMax));
}
}} }}
> >
<SelectTrigger> <SelectTrigger>
@ -384,11 +672,20 @@ export function ManageStorageDialog({
<SelectContent> <SelectContent>
{allResources {allResources
.filter((r) => isResourceAllowed(r.id)) .filter((r) => isResourceAllowed(r.id))
.map((resource) => ( .map((resource) => {
<SelectItem key={resource.id} value={String(resource.id)}> const cap = getAllowedDepositCap(resource.id);
{resource.name} return (
</SelectItem> <SelectItem key={resource.id} value={String(resource.id)}>
))} {resource.name}
{cap !== undefined && (
<span className="text-muted-foreground">
{" "}
— {cap.toLocaleString()} max
</span>
)}
</SelectItem>
);
})}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -401,9 +698,12 @@ export function ManageStorageDialog({
value={depositAmount} value={depositAmount}
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))} onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
/> />
{getDepositMax() !== undefined && ( {getDepositLimitInfoFor(depositResourceId) !== undefined && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Max: {getDepositMax()!.toLocaleString()} (limited by remaining capacity) Max:{" "}
{getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
(limited by{" "}
{getDepositLimitInfoFor(depositResourceId)!.reason})
</span> </span>
)} )}
</div> </div>
@ -483,7 +783,15 @@ export function ManageStorageDialog({
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4"> <TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Destination</Label> <Label>Destination</Label>
<Select value={transferToId} onValueChange={setTransferToId}> <Select
value={transferToId}
onValueChange={(val) => {
setTransferToId(val);
setTransferAmount(
clampAmount(transferAmount, getTransferMaxFor(val)),
);
}}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a structure..." /> <SelectValue placeholder="Select a structure..." />
</SelectTrigger> </SelectTrigger>
@ -536,11 +844,42 @@ export function ManageStorageDialog({
Max: {getTransferMax()!.toLocaleString()} stored Max: {getTransferMax()!.toLocaleString()} stored
</span> </span>
)} )}
{transferToId && transferResourceId && (() => {
const dest = allStructures.find((s) => s.id === Number(transferToId));
const destType = dest?.type;
if (!dest || !destType) return null;
const stored = findStoredResource(transferResourceId);
if (!stored) return null;
const resId = stored.resource.id;
if (isResourceProhibited(destType, resId)) {
return (
<span className="text-xs text-red-400">
Destination prohibits this resource.
</span>
);
}
if (!isResourceWhitelisted(destType, resId)) {
return (
<span className="text-xs text-amber-400">
Destination only accepts items on its allowed storage list — this resource is not permitted.
</span>
);
}
const cap = getResourceStorageCap(destType, resId);
const destStored = getStoredAmountOf(dest, resId);
return (
<span className="text-xs text-muted-foreground">
{cap != null
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
: "Destination has no per-item storage limit"}
</span>
);
})()}
</div> </div>
{renderCapacityPreview("transfer")} {renderCapacityPreview("transfer")}
<Button <Button
onClick={handleTransfer} onClick={handleTransfer}
disabled={isPending || !transferToId || !transferResourceId} disabled={isPending || !transferToId || !transferResourceId || getTransferMax() === 0}
className="w-full" className="w-full"
> >
{isPending ? ( {isPending ? (

View file

@ -5,18 +5,28 @@ import {
ItemHeader, ItemHeader,
ItemTitle, ItemTitle,
} from "@/components/ui/item"; } from "@/components/ui/item";
import { CheckCircleIcon, XCircleIcon, ShieldCheckIcon } from "lucide-react"; import {
CheckCircleIcon,
XCircleIcon,
ShieldCheckIcon,
ListRestartIcon,
} from "lucide-react";
interface StorageRulesProps { interface StorageRulesProps {
allowedStorage: Structure["allowedStorage"]; allowedStorage: Structure["allowedStorage"];
prohibitedStorage: Structure["prohibitedStorage"]; prohibitedStorage: Structure["prohibitedStorage"];
restrictToAllowed?: Structure["restrictToAllowed"];
} }
export function StorageRules({ allowedStorage, prohibitedStorage }: StorageRulesProps) { export function StorageRules({
allowedStorage,
prohibitedStorage,
restrictToAllowed,
}: StorageRulesProps) {
const hasAllowed = allowedStorage && allowedStorage.length > 0; const hasAllowed = allowedStorage && allowedStorage.length > 0;
const hasProhibited = prohibitedStorage && prohibitedStorage.length > 0; const hasProhibited = prohibitedStorage && prohibitedStorage.length > 0;
if (!hasAllowed && !hasProhibited) return null; if (!hasAllowed && !hasProhibited && !restrictToAllowed) return null;
return ( return (
<Item variant="outline"> <Item variant="outline">
@ -28,6 +38,15 @@ export function StorageRules({ allowedStorage, prohibitedStorage }: StorageRules
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{restrictToAllowed && (
<div className="flex items-center gap-2 p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-xs text-amber-400">
<ListRestartIcon className="size-3.5 shrink-0" />
<span>
Whitelist mode — only the items listed under "Allowed" can be
stored here. All other items are rejected.
</span>
</div>
)}
{hasAllowed && ( {hasAllowed && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">

130
src/lib/storageRules.ts Normal file
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.`;
}
}