1
0
Fork 0

feat(logistics): timestamp shipment lifecycle with client relay

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-20 02:43:29 -04:00
parent 7a0f997f70
commit f8ce3f3dcc
10 changed files with 587 additions and 70 deletions

View file

@ -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<typeof advanceShipment>[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)

View file

@ -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.`,
};
}
}

View file

@ -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) {
</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[shipment.status] ?? "bg-muted text-muted-foreground"}`}
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[effectiveStatus] ?? "bg-muted text-muted-foreground"}`}
>
{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)}
</span>
</ItemHeader>
<ItemContent>
@ -106,11 +110,15 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
</div>
</ItemContent>
<ItemDescription>
{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}
</ItemDescription>
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
View details <ChevronRightIcon className="size-3" />

View file

@ -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]);

View file

@ -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));
}
/**

View file

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

View file

@ -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<BinLogger, "error">,
shipmentDoc: ShipmentDoc,
now: Date = new Date(),
log?: AdvanceLog,
): Promise<void> {
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<BinLogger, "error">,
log?: AdvanceLog,
): Promise<void> {
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);
}

View file

@ -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(

View file

@ -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<Shipment> => {
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<GameVehicle> =>
(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<Shipment>;
const getVehicle = (id: number) =>
payload.findByID({
collection: "game-vehicles",
id,
depth: 0,
overrideAccess: true,
}) as Promise<GameVehicle>;
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");
});
});

View file

@ -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>): Shipment => {
const base: Partial<Shipment> = {
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);
});
});