diff --git a/src/app/(frontend)/logistics/shipments/actions.ts b/src/app/(frontend)/logistics/shipments/actions.ts new file mode 100644 index 0000000..7650491 --- /dev/null +++ b/src/app/(frontend)/logistics/shipments/actions.ts @@ -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 { + 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, + 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[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 { + 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 { + 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 }; + } +} diff --git a/src/app/(frontend)/logistics/structures/[id]/page.tsx b/src/app/(frontend)/logistics/structures/[id]/page.tsx index e6ae9f6..3bf8179 100644 --- a/src/app/(frontend)/logistics/structures/[id]/page.tsx +++ b/src/app/(frontend)/logistics/structures/[id]/page.tsx @@ -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} )} - + {user && } @@ -208,6 +211,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag {structureType.upkeep && structureType.upkeep.length > 0 && ( diff --git a/src/app/(frontend)/logistics/structures/actions.ts b/src/app/(frontend)/logistics/structures/actions.ts index c83f8a9..258ce91 100644 --- a/src/app/(frontend)/logistics/structures/actions.ts +++ b/src/app/(frontend)/logistics/structures/actions.ts @@ -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.` }; } diff --git a/src/collections/logistics/Structures.ts b/src/collections/logistics/Structures.ts index 028f072..a76643a 100644 --- a/src/collections/logistics/Structures.ts +++ b/src/collections/logistics/Structures.ts @@ -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", diff --git a/src/components/frontend/storage/ManageStorageDialog.tsx b/src/components/frontend/storage/ManageStorageDialog.tsx index c31df2d..1ee6b33 100644 --- a/src/components/frontend/storage/ManageStorageDialog.tsx +++ b/src/components/frontend/storage/ManageStorageDialog.tsx @@ -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([]); + 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(""); + const [shipmentAmount, setShipmentAmount] = useState("1"); + const [shipmentDestId, setShipmentDestId] = useState(""); + const [shipmentVehicleId, setShipmentVehicleId] = useState(""); + // Form state const [depositResourceId, setDepositResourceId] = useState(""); const [depositAmount, setDepositAmount] = useState("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({
+ ) : shipmentMode ? ( + /* ── Shipment Creation Mode ─────────────────────── */ +
+
+ + Create Shipment +
+ +
+
+ From + {structure?.name} +
+
+ To + + {allStructures.find((s) => s.id === Number(shipmentDestId))?.name ?? "Unknown"} + +
+
+ Cargo + + {shipmentAmount}× {allResources.find((r) => r.id === Number(shipmentResourceId))?.name ?? "Unknown"} + +
+
+ +
+ + {availableVehicles.length > 0 ? ( + + ) : ( +
+ + No idle vehicles stationed at this structure. Deploy a vehicle here first to create shipments. +
+ )} +
+ + {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 ( +
+
+
+ Distance + {distance.toFixed(1)} units +
+
+ Fuel Cost + + {fuelCost} units + +
+
+ Transit Time + + {transitHours < 1 + ? `${Math.ceil(transitHours * 60)} min` + : `${transitHours.toFixed(1)} hr`} + +
+
+ Vehicle Fuel + {selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity} +
+
+ {!canAfford && ( +
+ Vehicle does not have enough fuel for this trip. Need {fuelCost} units, has {selectedVehicle.currentFuel?.toFixed(1)}. +
+ )} +
+ ); + })()} + +
+ + +
+
) : ( <> {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), + ); }} > @@ -384,11 +672,20 @@ export function ManageStorageDialog({ {allResources .filter((r) => isResourceAllowed(r.id)) - .map((resource) => ( - - {resource.name} - - ))} + .map((resource) => { + const cap = getAllowedDepositCap(resource.id); + return ( + + {resource.name} + {cap !== undefined && ( + + {" "} + — {cap.toLocaleString()} max + + )} + + ); + })} @@ -401,9 +698,12 @@ export function ManageStorageDialog({ value={depositAmount} onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))} /> - {getDepositMax() !== undefined && ( + {getDepositLimitInfoFor(depositResourceId) !== undefined && ( - Max: {getDepositMax()!.toLocaleString()} (limited by remaining capacity) + Max:{" "} + {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "} + (limited by{" "} + {getDepositLimitInfoFor(depositResourceId)!.reason}) )} @@ -483,7 +783,15 @@ export function ManageStorageDialog({
- { + setTransferToId(val); + setTransferAmount( + clampAmount(transferAmount, getTransferMaxFor(val)), + ); + }} + > @@ -536,11 +844,42 @@ export function ManageStorageDialog({ Max: {getTransferMax()!.toLocaleString()} stored )} + {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 ( + + Destination prohibits this resource. + + ); + } + if (!isResourceWhitelisted(destType, resId)) { + return ( + + Destination only accepts items on its allowed storage list — this resource is not permitted. + + ); + } + const cap = getResourceStorageCap(destType, resId); + const destStored = getStoredAmountOf(dest, resId); + return ( + + {cap != null + ? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}` + : "Destination has no per-item storage limit"} + + ); + })()}
{renderCapacityPreview("transfer")}