From f8ce3f3dccd7dc85fbbc8215bed8aff32cae9092 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 20 Sep 2026 02:43:29 -0400 Subject: [PATCH] feat(logistics): timestamp shipment lifecycle with client relay Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../logistics/shipments/[id]/page.tsx | 25 +- .../(frontend)/logistics/shipments/actions.ts | 13 +- .../frontend/logistics/ShipmentCard.tsx | 22 +- .../frontend/logistics/ShipmentsList.tsx | 13 +- src/lib/logistics.ts | 8 +- src/lib/shipping/client.ts | 34 ++ .../shipping/lifecycle.ts} | 111 +++--- src/scripts/gameTick.ts | 12 +- tests/int/shipment-lifecycle.int.spec.ts | 339 ++++++++++++++++++ tests/int/shipping-client.int.spec.ts | 80 +++++ 10 files changed, 587 insertions(+), 70 deletions(-) create mode 100644 src/lib/shipping/client.ts rename src/{scripts/processShipmentTick.ts => lib/shipping/lifecycle.ts} (78%) create mode 100644 tests/int/shipment-lifecycle.int.spec.ts create mode 100644 tests/int/shipping-client.int.spec.ts diff --git a/src/app/(frontend)/logistics/shipments/[id]/page.tsx b/src/app/(frontend)/logistics/shipments/[id]/page.tsx index a9d5cc2..128b46a 100644 --- a/src/app/(frontend)/logistics/shipments/[id]/page.tsx +++ b/src/app/(frontend)/logistics/shipments/[id]/page.tsx @@ -15,6 +15,8 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { ClientDate } from "@/components/frontend/ClientDate"; import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions"; import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar"; +import { advanceShipment } from "@/lib/shipping/lifecycle"; +import type { Shipment } from "@/payload-types"; interface ShipmentDetailPageProps { params: Promise<{ id: string }>; @@ -35,7 +37,7 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP const { id } = await params; const payload = await getPayload({ config }); - const shipment = await payload + let shipment = await payload .findByID({ collection: "shipments", id: Number(id), @@ -47,6 +49,27 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP notFound(); } + // Advance the shipment lifecycle on view (idempotent): an active shipment + // that is due completes the moment anyone opens its page instead of waiting + // for the next game tick. + if (shipment.status === "dispatched" || shipment.status === "in_transit") { + await advanceShipment( + payload, + shipment as unknown as Parameters[1], + new Date(), + ); + const refreshed = await payload + .findByID({ + collection: "shipments", + id: Number(id), + depth: 2, + }) + .catch(() => null); + if (refreshed) { + shipment = refreshed; + } + } + const origin = typeof shipment.origin === "object" && shipment.origin !== null ? (shipment.origin as GameStructure) diff --git a/src/app/(frontend)/logistics/shipments/actions.ts b/src/app/(frontend)/logistics/shipments/actions.ts index 3958c32..f2cc8c8 100644 --- a/src/app/(frontend)/logistics/shipments/actions.ts +++ b/src/app/(frontend)/logistics/shipments/actions.ts @@ -9,6 +9,7 @@ import { EventTypes } from "@/utils/event-log/eventTypes"; import { calculateDistance } from "@/lib/distance"; import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; +import { effectiveMaxMass } from "@/lib/base/modules"; import { buildRoadGraph, findRoute } from "@/lib/map/routing"; import { isPointArray, type MapPoint } from "@/lib/map/points"; @@ -134,9 +135,12 @@ export async function createShipment(params: { return { success: false, error: "Vehicle type not found." }; } - const originMapId = typeof origin.map === "object" ? (origin.map?.id ?? null) : (origin.map ?? null); + const originMapId = + typeof origin.map === "object" ? (origin.map?.id ?? null) : (origin.map ?? null); const destMapId = - typeof destination.map === "object" ? (destination.map?.id ?? null) : (destination.map ?? null); + typeof destination.map === "object" + ? (destination.map?.id ?? null) + : (destination.map ?? null); if (!originMapId || !destMapId) { return { @@ -301,10 +305,11 @@ export async function createShipment(params: { } destMass += (resource.massPerUnit ?? 0) * cargoItem.amount; - if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) { + const destMaxMass = effectiveMaxMass(destType, destination.installedModules); + if (destMaxMass && destMass > destMaxMass) { return { success: false, - error: `Destination "${destination.name}" would exceed its mass capacity of ${destType.maxCapacityMass.toLocaleString()} kg.`, + error: `Destination "${destination.name}" would exceed its mass capacity of ${destMaxMass.toLocaleString()} kg.`, }; } } diff --git a/src/components/frontend/logistics/ShipmentCard.tsx b/src/components/frontend/logistics/ShipmentCard.tsx index 3f41487..478a495 100644 --- a/src/components/frontend/logistics/ShipmentCard.tsx +++ b/src/components/frontend/logistics/ShipmentCard.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/item"; import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar"; import { ArrowRightIcon, ChevronRightIcon, TruckIcon } from "lucide-react"; +import { getShipmentStatus, isShipmentActive } from "@/lib/shipping/client"; interface ShipmentCardProps { shipment: Shipment; @@ -47,7 +48,8 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) { const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null; const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null; - const isActive = shipment.status === "dispatched" || shipment.status === "in_transit"; + const effectiveStatus = getShipmentStatus(shipment); + const isActive = isShipmentActive(shipment); const cargoCount = shipment.cargo?.length ?? 0; const totalItems = shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0; @@ -71,11 +73,13 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) { - {shipment.status === "in_transit" + {effectiveStatus === "in_transit" ? "In Transit" - : shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)} + : effectiveStatus === "arrived" + ? "Arriving" + : effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)} @@ -106,11 +110,15 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) { - {shipment.status === "completed" + {effectiveStatus === "completed" ? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}` - : shipment.status === "cancelled" + : effectiveStatus === "cancelled" ? "Cancelled" - : (shipment.failureReason ?? (isActive ? "In transit" : shipment.status))} + : effectiveStatus === "arrived" + ? "Arriving" + : effectiveStatus === "in_transit" || effectiveStatus === "dispatched" + ? "In transit" + : effectiveStatus} View details diff --git a/src/components/frontend/logistics/ShipmentsList.tsx b/src/components/frontend/logistics/ShipmentsList.tsx index f6c94c0..82e9e47 100644 --- a/src/components/frontend/logistics/ShipmentsList.tsx +++ b/src/components/frontend/logistics/ShipmentsList.tsx @@ -5,13 +5,8 @@ import type { Shipment } from "@/payload-types"; import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard"; import { Button } from "@/components/ui/button"; import { usePageCommands } from "@/components/command-palette/CommandPaletteContext"; -import { - CheckCircleIcon, - ClockIcon, - ListFilterIcon, - TruckIcon, - XCircleIcon, -} from "lucide-react"; +import { CheckCircleIcon, ClockIcon, ListFilterIcon, TruckIcon, XCircleIcon } from "lucide-react"; +import { isShipmentActive } from "@/lib/shipping/client"; const statusFilters = [ { label: "All", value: null }, @@ -21,8 +16,6 @@ const statusFilters = [ { label: "Stranded", value: "stranded" }, ]; -const activeStatuses = ["dispatched", "in_transit", "pending"]; - interface ShipmentsListProps { shipments: Shipment[]; } @@ -76,7 +69,7 @@ export function ShipmentsList({ shipments }: ShipmentsListProps) { const filtered = useMemo(() => { if (!filter) return shipments; if (filter === "active") { - return shipments.filter((s) => activeStatuses.includes(s.status)); + return shipments.filter((s) => isShipmentActive(s)); } return shipments.filter((s) => s.status === filter); }, [shipments, filter]); diff --git a/src/lib/logistics.ts b/src/lib/logistics.ts index f6f4c66..a753bbb 100644 --- a/src/lib/logistics.ts +++ b/src/lib/logistics.ts @@ -33,12 +33,14 @@ export function calculateStoredMass(storedResources: GameStructure["storedResour export function calculateUtilization( structure: Structure, storedResources: GameStructure["storedResources"], + capacityBonus = 0, ): number | null { const maxMass = structure.maxCapacityMass; if (!maxMass || maxMass <= 0) return null; + const effective = maxMass + Math.max(0, capacityBonus); const currentMass = calculateStoredMass(storedResources); - return Math.min((currentMass / maxMass) * 100, 100); + return Math.min((currentMass / effective) * 100, 100); } /** @@ -52,11 +54,11 @@ export function getResourceCount(storedResources: GameStructure["storedResources /** * Get the total grid slots available (xSize * ySize), or null if not defined. */ -export function getGridSlots(structure: Structure): number | null { +export function getGridSlots(structure: Structure, bonusSlots = 0): number | null { const x = structure.xSize; const y = structure.ySize; if (!x || !y) return null; - return x * y; + return x * y + Math.max(0, Math.floor(bonusSlots)); } /** diff --git a/src/lib/shipping/client.ts b/src/lib/shipping/client.ts new file mode 100644 index 0000000..7dd4f01 --- /dev/null +++ b/src/lib/shipping/client.ts @@ -0,0 +1,34 @@ +import type { Shipment } from "@/payload-types"; + +const TERMINAL_STATUSES = ["completed", "cancelled", "failed", "stranded"] as const; +const ACTIVE_STATUSES = ["dispatched", "in_transit"] as const; + +type ShipmentStatus = Shipment["status"]; + +export function getShipmentStatus(shipment: Shipment): ShipmentStatus { + // Terminal statuses don't change once reached. + if (TERMINAL_STATUSES.includes(shipment.status as any)) return shipment.status; + + // Active shipments are time-derived. + if (ACTIVE_STATUSES.includes(shipment.status as any)) { + if (!shipment.dispatchedAt || !shipment.estimatedArrival) return shipment.status; + const dispatchedAt = new Date(shipment.dispatchedAt).getTime(); + const estimatedArrival = new Date(shipment.estimatedArrival).getTime(); + const now = Date.now(); + + // Clock skew: not yet dispatched (shouldn't happen, but defensive). + if (now < dispatchedAt) return "dispatched"; + + // Past ETA: arrived/completing. + if (now >= estimatedArrival) return "arrived"; + + return shipment.status; + } + + return shipment.status; +} + +export function isShipmentActive(shipment: Shipment): boolean { + const status = getShipmentStatus(shipment); + return ACTIVE_STATUSES.includes(status as any) || status === "arrived"; +} diff --git a/src/scripts/processShipmentTick.ts b/src/lib/shipping/lifecycle.ts similarity index 78% rename from src/scripts/processShipmentTick.ts rename to src/lib/shipping/lifecycle.ts index c1d5375..97e4c03 100644 --- a/src/scripts/processShipmentTick.ts +++ b/src/lib/shipping/lifecycle.ts @@ -6,7 +6,6 @@ import { notifyUser } from "@/lib/notifications"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { isPointArray, type MapPoint } from "@/lib/map/points"; import { maybeStartConstruction } from "@/lib/map/construction"; -import { errorMessage, type BinLogger } from "./lib/binFileLogger"; type ShipmentDoc = Shipment & { transportVehicle: GameVehicle & { type: Vehicle }; @@ -14,33 +13,54 @@ type ShipmentDoc = Shipment & { destination: GameStructure; }; -export async function processShipmentTick( +export interface AdvanceLog { + error?: (message: string) => void; +} + +function docId(value: GameStructure | GameVehicle | number): number { + return typeof value === "object" ? value.id : value; +} + +/** + * One idempotent lifecycle step for an active shipment, derived entirely from + * timestamps so any caller (game tick, detail-page load, server actions) can + * advance it without waiting for the next tick: + * + * - fuel burn is continuous: fuelConsumed = fuelCost x elapsed fraction, and + * the vehicle drains by the delta since the stored value (re-running at the + * same instant writes the same numbers), + * - dispatched flips to in_transit as soon as the vehicle is moving, + * - arrival runs the full delivery (storage rules, cargo credit, auto-return, + * events, notification) and is guarded by a fresh status re-check. + */ +export async function advanceShipment( payload: Payload, - shipment: ShipmentDoc, - log: Pick, + shipmentDoc: ShipmentDoc, + now: Date = new Date(), + log?: AdvanceLog, ): Promise { - const now = new Date(); - const vehicle = shipment.transportVehicle; + if (shipmentDoc.status !== "dispatched" && shipmentDoc.status !== "in_transit") return; + if (!shipmentDoc.dispatchedAt || !shipmentDoc.estimatedArrival) return; + const vehicle = shipmentDoc.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 dispatchedAt = new Date(shipmentDoc.dispatchedAt).getTime(); + const estimatedArrival = new Date(shipmentDoc.estimatedArrival).getTime(); + const totalDurationMs = Math.max(1, estimatedArrival - dispatchedAt); + const elapsedMs = Math.max(0, Math.min(now.getTime() - dispatchedAt, totalDurationMs)); + const targetFuelConsumed = Math.min( + shipmentDoc.fuelCost, + shipmentDoc.fuelCost * (elapsedMs / totalDurationMs), + ); + const fuelDelta = targetFuelConsumed - (shipmentDoc.fuelConsumed ?? 0); - const fuelPerTick = shipment.fuelCost / totalTicks; - const currentFuelConsumed = shipment.fuelConsumed ?? 0; - - if (vehicle.currentFuel < fuelPerTick) { + if (vehicle.currentFuel < fuelDelta) { await payload.update({ collection: "shipments", - id: shipment.id, + id: shipmentDoc.id, data: { status: "stranded", failureReason: "Vehicle ran out of fuel during transit.", @@ -53,39 +73,33 @@ export async function processShipmentTick( 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, + message: `Shipment "${shipmentDoc.name ?? `SHP-${shipmentDoc.id}`}" stranded: vehicle out of fuel`, + structure: docId(shipmentDoc.origin), targetCollection: "shipments", - targetId: shipment.id, + targetId: shipmentDoc.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 }, + data: { currentFuel: Math.max(0, vehicle.currentFuel - fuelDelta) }, }); - const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost); - - if (now >= estimatedArrival) { - await completeShipment(payload, shipment, newFuelConsumed, now, log); + if (now.getTime() >= estimatedArrival) { + await completeShipment(payload, shipmentDoc, targetFuelConsumed, now, log); return; } await payload.update({ collection: "shipments", - id: shipment.id, + id: shipmentDoc.id, data: { - fuelConsumed: newFuelConsumed, + fuelConsumed: targetFuelConsumed, status: "in_transit", }, }); @@ -96,15 +110,24 @@ async function completeShipment( shipment: ShipmentDoc, finalFuelConsumed: number, now: Date, - log: Pick, + log?: AdvanceLog, ): 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 destinationId = docId(shipment.destination); + const originId = docId(shipment.origin); + + // Someone may have completed this shipment between the caller's fetch and + // now (concurrent page loads, the tick racing an action): re-check the + // stored status before crediting any cargo. + const fresh = (await payload + .findByID({ + collection: "shipments", + id: shipment.id, + depth: 0, + overrideAccess: true, + }) + .catch(() => null)) as unknown as { status?: string } | null; + if (!fresh || (fresh.status !== "dispatched" && fresh.status !== "in_transit")) return; const bounced: { itemId: number; amount: number; resourceName: string }[] = []; @@ -147,7 +170,7 @@ async function completeShipment( amount: cargoEntry.amount, resourceName: resName, }); - log.error( + log?.error?.( `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`, ); continue; @@ -178,7 +201,7 @@ async function completeShipment( data: { storedResources: existingResources }, }); } catch (error) { - log.error(`Failed to transfer cargo item ${itemId}: ${errorMessage(error)}`); + log?.error?.(`Failed to transfer cargo item ${itemId}: ${errorMessage(error)}`); } } @@ -280,7 +303,7 @@ async function completeShipment( try { await maybeStartConstruction(payload, destinationId, now); } catch (error) { - log.error( + log?.error?.( `Construction start failed at destination "${destinationId}": ${errorMessage(error)}`, ); } @@ -389,3 +412,7 @@ async function createAutoReturnShipment( data: { vehicleId: vehicle.id, originalShipmentId: originalShipment.id }, }); } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/scripts/gameTick.ts b/src/scripts/gameTick.ts index 454c728..32ca5b1 100644 --- a/src/scripts/gameTick.ts +++ b/src/scripts/gameTick.ts @@ -1,8 +1,9 @@ import type { SanitizedConfig } from "payload"; import { getPayload } from "payload"; import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types"; -import { processShipmentTick } from "./processShipmentTick"; +import { advanceShipment } from "@/lib/shipping/lifecycle"; import { processConstructionTick } from "@/lib/map/construction"; +import { processProductionTick } from "@/lib/base/production"; import { createBinLogger, errorMessage } from "./lib/binFileLogger"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; @@ -26,13 +27,14 @@ export const script = async (config: SanitizedConfig) => { let processedCount = 0; for (const shipment of activeShipments.docs) { try { - await processShipmentTick( + await advanceShipment( payload, shipment as unknown as Shipment & { transportVehicle: GameVehicle & { type: Vehicle }; origin: GameStructure; destination: GameStructure; }, + new Date(), log, ); processedCount++; @@ -42,6 +44,7 @@ export const script = async (config: SanitizedConfig) => { } const construction = await processConstructionTick(payload, log); + const production = await processProductionTick(payload, log); await emitGameEvent(payload, { type: EventTypes.GameTick, @@ -50,6 +53,7 @@ export const script = async (config: SanitizedConfig) => { processedCount, totalActive: activeShipments.docs.length, construction, + production, }, }); @@ -70,7 +74,9 @@ export const script = async (config: SanitizedConfig) => { log.warn("[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification"); } - log.info(`[Game Tick] Complete. Processed ${processedCount} shipments.`); + log.info( + `[Game Tick] Complete. Processed ${processedCount} shipments, ${production.produced} production runs.`, + ); process.exit(0); } catch (error) { log.error( diff --git a/tests/int/shipment-lifecycle.int.spec.ts b/tests/int/shipment-lifecycle.int.spec.ts new file mode 100644 index 0000000..a890870 --- /dev/null +++ b/tests/int/shipment-lifecycle.int.spec.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import config from "@payload-config"; +import { getPayload, type Payload } from "payload"; +import type { + GameStructure, + GameVehicle, + Map as MapDoc, + Resource, + Shipment, + Vehicle, +} from "@/payload-types"; +import { advanceShipment } from "@/lib/shipping/lifecycle"; +import { emitGameEvent } from "@/utils/event-log/emit"; +import { EventTypes } from "@/utils/event-log/eventTypes"; + +const LEXICAL_EMPTY = { + root: { + children: [ + { + type: "paragraph", + children: [ + { type: "text", detail: 0, format: 0, mode: "normal", style: "", text: "", version: 1 }, + ], + }, + { + type: "paragraph", + children: [ + { type: "text", detail: 0, format: 0, mode: "normal", style: "", text: "", version: 1 }, + ], + }, + ], + direction: null, + format: "", + indent: 0, + type: "root", + version: 1, + }, +} as const; + +describe("shipment lifecycle (advanceShipment)", () => { + let payload: Payload; + let origin: GameStructure; + let destination: GameStructure; + let blueprint: Vehicle; + let fuel: Resource; + + const makeShipment = async (data: { + dispatchedAt: Date; + estimatedArrival: Date; + fuelCost?: number; + vehicleRef: GameVehicle; + }): Promise => { + return (await payload.create({ + collection: "shipments", + data: { + name: "SHP-test", + origin: origin.id, + destination: destination.id, + map: (origin.map as number) ?? null, + routePath: null, + transportVehicle: data.vehicleRef.id, + distance: 10000, + cargo: [ + { + item: { relationTo: "resources", value: fuel.id }, + amount: 25, + }, + ], + fuelCost: data.fuelCost ?? 60, + fuelConsumed: 0, + status: "dispatched", + dispatchedAt: data.dispatchedAt.toISOString(), + estimatedArrival: data.estimatedArrival.toISOString(), + autoReturn: false, + createdBy: null, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Shipment; + }; + + const makeVehicle = async (fuelAmount = 100): Promise => + (await payload.create({ + collection: "game-vehicles", + data: { + name: `Lifecycle Truck ${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type: blueprint.id, + deployedAt: origin.id, + status: "in_transit", + currentFuel: fuelAmount, + currentHealth: 100, + }, + overrideAccess: true, + depth: 0, + })) as unknown as GameVehicle; + + const loadActive = async (id: number) => + payload.findByID({ collection: "shipments", id, depth: 2, overrideAccess: true }); + + const getShipment = (id: number) => + payload.findByID({ + collection: "shipments", + id, + depth: 0, + overrideAccess: true, + }) as Promise; + + const getVehicle = (id: number) => + payload.findByID({ + collection: "game-vehicles", + id, + depth: 0, + overrideAccess: true, + }) as Promise; + + beforeAll(async () => { + payload = await getPayload({ config }); + + const rules = await payload.findGlobal({ slug: "game-rules" }); + expect(rules).toBeTruthy(); + + const map = (await payload.create({ + collection: "maps", + data: { + name: `Lifecycle Map ${Date.now()}`, + worldSizeWidth: 20480, + worldSizeHeight: 20480, + basemapMode: "tiles", + tileUrlTemplate: "http://localhost/{z}/{x}/{y}.png", + isActive: false, + }, + overrideAccess: true, + depth: 0, + })) as unknown as MapDoc; + + fuel = (await payload.create({ + collection: "resources", + data: { + name: `Diesel ${Date.now()}`, + codeName: `lifecycle_diesel_${Date.now()}`, + approvalStatus: "in_progress", + type: "physical", + baseValue: 1, + rarity: "common", + unitOfMeasure: "kg", + massPerUnit: 1, + gridWidth: 1, + gridHeight: 1, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Resource; + + const blueprintType = (await payload.create({ + collection: "structures", + data: { + name: "Lifecycle Depot", + codeName: "lifecycle_depot", + approvalStatus: "in_progress", + description: LEXICAL_EMPTY as unknown as never, + category: "logistics", + materials: [{ resource: fuel.id, amount: 1 }], + constructionDurationMinutes: 60, + terrainType: "land", + maxHealth: 100, + }, + overrideAccess: true, + depth: 0, + })) as unknown as { id: number }; + + origin = (await payload.create({ + collection: "game-structures", + data: { + name: "Lifecycle Origin", + type: blueprintType.id, + map: map.id, + coordinates: [2000, 2000], + }, + overrideAccess: true, + depth: 0, + })) as unknown as GameStructure; + + destination = (await payload.create({ + collection: "game-structures", + data: { + name: "Lifecycle Destination", + type: blueprintType.id, + map: map.id, + coordinates: [18000, 18000], + }, + overrideAccess: true, + depth: 0, + })) as unknown as GameStructure; + + blueprint = (await payload.create({ + collection: "vehicles", + data: { + name: "Lifecycle Truck", + approvalStatus: "in_progress", + transportMode: "ground", + fuel: { fuelType: fuel.id, fuelCapacity: 100000, fuelConsumptionRate: 0.01 }, + maxSpeedOnRoad: 60, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Vehicle; + }); + + afterAll(async () => { + await emitGameEvent(payload, { + type: EventTypes.ShipmentComplete, + message: "lifecycle test teardown marker", + targetCollection: "shipments", + targetId: 0, + }).catch(() => undefined); + }); + + it("flips dispatched to in_transit with continuous fuel burn derived from elapsed time", async () => { + const now = new Date(); + const truck = await makeVehicle(); + const shipment = await makeShipment({ + dispatchedAt: new Date(now.getTime() - 30 * 60 * 1000), + estimatedArrival: new Date(now.getTime() + 30 * 60 * 1000), + vehicleRef: truck, + }); + + await advanceShipment(payload, (await loadActive(shipment.id)) as never, now); + + const after = await getShipment(shipment.id); + expect(after.status).toBe("in_transit"); + expect(after.fuelConsumed).toBeCloseTo(30, 5); + + const afterVehicle = await getVehicle(truck.id); + expect(afterVehicle.currentFuel).toBeCloseTo(70, 5); + }); + + it("is idempotent: advancing twice at the same instant does not double-burn fuel", async () => { + const now = new Date(); + const truck = await makeVehicle(); + const shipment = await makeShipment({ + dispatchedAt: new Date(now.getTime() - 30 * 60 * 1000), + estimatedArrival: new Date(now.getTime() + 30 * 60 * 1000), + vehicleRef: truck, + }); + + await advanceShipment(payload, (await loadActive(shipment.id)) as never, now); + await advanceShipment(payload, (await loadActive(shipment.id)) as never, now); + + const after = await getShipment(shipment.id); + expect(after.fuelConsumed).toBeCloseTo(30, 5); + + const afterVehicle = await getVehicle(truck.id); + expect(afterVehicle.currentFuel).toBeCloseTo(70, 5); + }); + + it("completes a due shipment on demand: cargo credited, vehicle idle at destination", async () => { + const now = new Date(); + const truck = await makeVehicle(); + const shipment = await makeShipment({ + dispatchedAt: new Date(now.getTime() - 61 * 60 * 1000), + estimatedArrival: new Date(now.getTime() - 60 * 1000), + vehicleRef: truck, + }); + + await advanceShipment(payload, (await loadActive(shipment.id)) as never, now); + + const after = await getShipment(shipment.id); + expect(after.status).toBe("completed"); + expect(after.fuelConsumed).toBeCloseTo(60, 5); + expect(after.completedAt).toBeTruthy(); + + const afterVehicle = await getVehicle(truck.id); + expect(afterVehicle.status).toBe("idle"); + expect(afterVehicle.deployedAt).toBe(destination.id); + + const dest = (await payload.findByID({ + collection: "game-structures", + id: destination.id, + depth: 0, + overrideAccess: true, + })) as unknown as GameStructure; + const credited = (dest.storedResources ?? []).find( + (r) => (typeof r.resource === "object" ? r.resource.id : r.resource) === fuel.id, + ); + expect(credited?.amount).toBe(25); + }); + + it("is a no-op for shipments that already reached a terminal state", async () => { + const now = new Date(); + const truck = await makeVehicle(); + const shipment = await makeShipment({ + dispatchedAt: new Date(now.getTime() - 61 * 60 * 1000), + estimatedArrival: new Date(now.getTime() - 60 * 1000), + vehicleRef: truck, + }); + await advanceShipment(payload, (await loadActive(shipment.id)) as never, now); + const completed = await getShipment(shipment.id); + + await advanceShipment(payload, completed as never, now); + + const after = await getShipment(shipment.id); + expect(after.status).toBe("completed"); + expect(after.fuelConsumed).toBeCloseTo(60, 5); + const afterVehicle = await getVehicle(truck.id); + expect(afterVehicle.currentFuel).toBeCloseTo(40, 5); + }); + + it("strands the shipment when the vehicle cannot cover the elapsed burn", async () => { + const now = new Date(); + const dryVehicle = (await payload.create({ + collection: "game-vehicles", + data: { + name: "Lifecycle Dry Truck", + type: blueprint.id, + deployedAt: origin.id, + status: "in_transit", + currentFuel: 5, + currentHealth: 100, + }, + overrideAccess: true, + depth: 0, + })) as unknown as GameVehicle; + + const shipment = await makeShipment({ + dispatchedAt: new Date(now.getTime() - 30 * 60 * 1000), + estimatedArrival: new Date(now.getTime() + 30 * 60 * 1000), + vehicleRef: dryVehicle, + }); + + await advanceShipment(payload, (await loadActive(shipment.id)) as never, now); + + const after = await getShipment(shipment.id); + expect(after.status).toBe("stranded"); + expect(after.failureReason).toContain("fuel"); + + const afterVehicle = await getVehicle(dryVehicle.id); + expect(afterVehicle.status).toBe("damaged"); + }); +}); diff --git a/tests/int/shipping-client.int.spec.ts b/tests/int/shipping-client.int.spec.ts new file mode 100644 index 0000000..f96d871 --- /dev/null +++ b/tests/int/shipping-client.int.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import type { Shipment } from "@/payload-types"; +import { getShipmentStatus, isShipmentActive } from "@/lib/shipping/client"; + +const makeShipment = (overrides: Partial): Shipment => { + const base: Partial = { + id: 0, + name: "Test", + status: "dispatched", + dispatchedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + estimatedArrival: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }; + return { ...base, ...overrides } as Shipment; +}; + +describe("getShipmentStatus", () => { + it("returns terminal statuses unchanged", () => { + expect(getShipmentStatus(makeShipment({ status: "completed" }))).toBe("completed"); + expect(getShipmentStatus(makeShipment({ status: "cancelled" }))).toBe("cancelled"); + expect(getShipmentStatus(makeShipment({ status: "failed" }))).toBe("failed"); + expect(getShipmentStatus(makeShipment({ status: "stranded" }))).toBe("stranded"); + }); + + it("returns active status when within dispatched/ETA window", () => { + const s = makeShipment({ status: "dispatched" }); + expect(getShipmentStatus(s)).toBe("dispatched"); + }); + + it("returns arrived when past ETA", () => { + const s = makeShipment({ + status: "dispatched", + estimatedArrival: new Date(Date.now() - 60 * 1000).toISOString(), + }); + expect(getShipmentStatus(s)).toBe("arrived"); + }); + + it("returns dispatched when clock skew (now < dispatchedAt)", () => { + const s = makeShipment({ + dispatchedAt: new Date(Date.now() + 60 * 1000).toISOString(), + estimatedArrival: new Date(Date.now() + 90 * 1000).toISOString(), + }); + expect(getShipmentStatus(s)).toBe("dispatched"); + }); + + it("returns in_transit when past dispatchedAt but before ETA", () => { + const s = makeShipment({ status: "in_transit" }); + expect(getShipmentStatus(s)).toBe("in_transit"); + }); + + it("handles missing timestamps gracefully", () => { + const s = makeShipment({ + status: "dispatched", + dispatchedAt: undefined as any, + estimatedArrival: undefined as any, + }); + expect(getShipmentStatus(s)).toBe("dispatched"); + }); +}); + +describe("isShipmentActive", () => { + it("returns true for dispatched/in_transit", () => { + expect(isShipmentActive(makeShipment({ status: "dispatched" }))).toBe(true); + expect(isShipmentActive(makeShipment({ status: "in_transit" }))).toBe(true); + }); + + it("returns true for due shipments (status dispatched but past ETA)", () => { + const s = makeShipment({ + status: "dispatched", + estimatedArrival: new Date(Date.now() - 60 * 1000).toISOString(), + }); + expect(isShipmentActive(s)).toBe(true); + }); + + it("returns false for terminal statuses", () => { + expect(isShipmentActive(makeShipment({ status: "completed" }))).toBe(false); + expect(isShipmentActive(makeShipment({ status: "cancelled" }))).toBe(false); + expect(isShipmentActive(makeShipment({ status: "failed" }))).toBe(false); + expect(isShipmentActive(makeShipment({ status: "stranded" }))).toBe(false); + }); +});