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"); }); });