import type { Payload } from "payload"; import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { notifyUser } from "@/lib/notifications"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { errorMessage, type BinLogger } from "./lib/binFileLogger"; type ShipmentDoc = Shipment & { transportVehicle: GameVehicle & { type: Vehicle }; origin: GameStructure; destination: GameStructure; }; export async function processShipmentTick( payload: Payload, shipment: ShipmentDoc, log: Pick, ): Promise { const now = new Date(); const vehicle = shipment.transportVehicle; if (!vehicle || typeof vehicle === "number") return; const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null; if (!vehicleType) return; const dispatchedAt = new Date(shipment.dispatchedAt!); const estimatedArrival = new Date(shipment.estimatedArrival!); const gameRules = await payload.findGlobal({ slug: "game-rules" }); const tickIntervalMinutes = (gameRules.gameTickIntervalMinutes as number) || 10; const totalDurationMs = estimatedArrival.getTime() - dispatchedAt.getTime(); const totalTicks = Math.max(1, Math.ceil(totalDurationMs / (tickIntervalMinutes * 60 * 1000))); const fuelPerTick = shipment.fuelCost / totalTicks; const currentFuelConsumed = shipment.fuelConsumed ?? 0; if (vehicle.currentFuel < fuelPerTick) { await payload.update({ collection: "shipments", id: shipment.id, data: { status: "stranded", failureReason: "Vehicle ran out of fuel during transit.", }, }); await payload.update({ collection: "game-vehicles", id: vehicle.id, data: { status: "damaged" }, }); const originId = typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number); await emitGameEvent(payload, { type: EventTypes.ShipmentFail, message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" stranded — vehicle out of fuel`, structure: originId, targetCollection: "shipments", targetId: shipment.id, data: { reason: "out_of_fuel", vehicleId: vehicle.id }, }); return; } const newFuel = Math.max(0, vehicle.currentFuel - fuelPerTick); await payload.update({ collection: "game-vehicles", id: vehicle.id, data: { currentFuel: newFuel }, }); const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost); if (now >= estimatedArrival) { await completeShipment(payload, shipment, newFuelConsumed, log); return; } await payload.update({ collection: "shipments", id: shipment.id, data: { fuelConsumed: newFuelConsumed, status: "in_transit", }, }); } async function completeShipment( payload: Payload, shipment: ShipmentDoc, finalFuelConsumed: number, log: Pick, ): Promise { const vehicle = shipment.transportVehicle; const destinationId = typeof shipment.destination === "object" ? shipment.destination.id : (shipment.destination as number); const originId = typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number); const bounced: { itemId: number; amount: number; resourceName: string }[] = []; 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; try { const destination = await payload.findByID({ collection: "game-structures", id: destinationId, depth: 2, }); const destType = typeof destination.type === "object" ? destination.type : null; if (destType) { const destStored = destination.storedResources ?? []; const destVoid = (destination as GameStructure).voidStorage ?? []; const currentStored = [...destStored, ...destVoid].reduce((sum, r) => { const resId = typeof r.resource === "object" ? r.resource.id : r.resource; return sum + (resId === itemId ? r.amount : 0); }, 0); const ruleCheck = checkStorageDeposit(destType, itemId, cargoEntry.amount, currentStored); if (!ruleCheck.allowed) { const resName = ( await payload .findByID({ collection: "resources", id: itemId, depth: 0 }) .catch(() => null) )?.name ?? `resource #${itemId}`; bounced.push({ itemId, amount: cargoEntry.amount, resourceName: resName, }); log.error( `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`, ); continue; } } const existingResources = destination.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: destinationId, data: { storedResources: existingResources }, }); } catch (error) { log.error(`Failed to transfer cargo item ${itemId}: ${errorMessage(error)}`); } } await payload.update({ collection: "game-vehicles", id: vehicle.id, data: { deployedAt: destinationId, status: "idle", }, }); if (bounced.length > 0) { // Return rejected cargo to the origin structure const origin = await payload .findByID({ collection: "game-structures", id: originId, depth: 1, }) .catch(() => null); if (origin) { const existingResources = [...(origin.storedResources ?? [])]; for (const b of bounced) { const existingEntry = existingResources.find((r) => { const resId = typeof r.resource === "object" ? r.resource.id : r.resource; return resId === b.itemId; }); if (existingEntry) { existingEntry.amount += b.amount; } else { existingResources.push({ resource: b.itemId, amount: b.amount, gridX: 0, gridY: existingResources.length, rotated: false, }); } } await payload.update({ collection: "game-structures", id: originId, data: { storedResources: existingResources }, }); } await payload.update({ collection: "shipments", id: shipment.id, data: { status: "failed", failureReason: `Destination storage rules prevented delivery: ${bounced .map((b) => `${b.resourceName} ×${b.amount.toLocaleString()}`) .join(", ")} — returned to origin.`, completedAt: new Date().toISOString(), }, }); await emitGameEvent(payload, { type: EventTypes.ShipmentFail, message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected by destination storage rules`, structure: destinationId, targetCollection: "shipments", targetId: shipment.id, data: { reason: "storage_rules", bounced: bounced.map((b) => ({ itemId: b.itemId, amount: b.amount, resourceName: b.resourceName, })), }, }); return; } await payload.update({ collection: "shipments", id: shipment.id, data: { status: "completed", completedAt: new Date().toISOString(), fuelConsumed: finalFuelConsumed, }, }); await emitGameEvent(payload, { type: EventTypes.ShipmentComplete, message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" arrived at destination`, structure: destinationId, targetCollection: "shipments", targetId: shipment.id, data: { vehicleId: vehicle.id, originId, destinationId }, }); // Notify the user who created the shipment (the closest thing to a recipient/owner). const creatorId = typeof shipment.createdBy === "object" && shipment.createdBy ? shipment.createdBy.id : (shipment.createdBy as number | null | undefined); if (creatorId != null) { await notifyUser(payload, { userId: creatorId, type: "shipment:completed", title: "Shipment completed", message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" arrived at its destination.`, link: `/logistics/shipments/${shipment.id}`, }); } if (shipment.autoReturn) { await createAutoReturnShipment(payload, shipment, vehicle as GameVehicle & { type: Vehicle }); } } async function createAutoReturnShipment( payload: Payload, originalShipment: ShipmentDoc, vehicle: GameVehicle & { type: Vehicle }, ): Promise { const originId = typeof originalShipment.origin === "object" ? originalShipment.origin.id : (originalShipment.origin as number); const destinationId = typeof originalShipment.destination === "object" ? originalShipment.destination.id : (originalShipment.destination as number); const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null; if (!vehicleType) return; const speed = vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50; const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1; const returnFuelCost = Math.ceil(originalShipment.distance * rate); const returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000; const now = new Date(); if (vehicle.currentFuel < returnFuelCost) { await emitGameEvent(payload, { type: EventTypes.ShipmentFail, message: `Auto-return failed — insufficient fuel`, structure: destinationId, targetCollection: "game-vehicles", targetId: vehicle.id, data: { reason: "insufficient_fuel_for_return" }, }); return; } await payload.update({ collection: "game-vehicles", id: vehicle.id, data: { currentFuel: vehicle.currentFuel - returnFuelCost, status: "in_transit", deployedAt: null, }, }); const returnShipment = await payload.create({ collection: "shipments", data: { name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`, origin: destinationId, destination: originId, transportVehicle: vehicle.id, distance: originalShipment.distance, cargo: [], fuelCost: returnFuelCost, fuelConsumed: 0, status: "dispatched", dispatchedAt: now.toISOString(), estimatedArrival: new Date(now.getTime() + returnDurationMs).toISOString(), autoReturn: false, createdBy: originalShipment.createdBy, }, }); await emitGameEvent(payload, { type: EventTypes.ShipmentAutoReturn, message: `Vehicle auto-returning to origin`, structure: destinationId, targetCollection: "shipments", targetId: returnShipment.id, data: { vehicleId: vehicle.id, originalShipmentId: originalShipment.id }, }); }