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:
parent
7a0f997f70
commit
f8ce3f3dcc
10 changed files with 587 additions and 70 deletions
|
|
@ -15,6 +15,8 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { ClientDate } from "@/components/frontend/ClientDate";
|
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||||
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
|
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
|
||||||
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
||||||
|
import { advanceShipment } from "@/lib/shipping/lifecycle";
|
||||||
|
import type { Shipment } from "@/payload-types";
|
||||||
|
|
||||||
interface ShipmentDetailPageProps {
|
interface ShipmentDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
|
@ -35,7 +37,7 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
|
||||||
const shipment = await payload
|
let shipment = await payload
|
||||||
.findByID({
|
.findByID({
|
||||||
collection: "shipments",
|
collection: "shipments",
|
||||||
id: Number(id),
|
id: Number(id),
|
||||||
|
|
@ -47,6 +49,27 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
|
||||||
notFound();
|
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 =
|
const origin =
|
||||||
typeof shipment.origin === "object" && shipment.origin !== null
|
typeof shipment.origin === "object" && shipment.origin !== null
|
||||||
? (shipment.origin as GameStructure)
|
? (shipment.origin as GameStructure)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { calculateDistance } from "@/lib/distance";
|
import { calculateDistance } from "@/lib/distance";
|
||||||
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
|
import { calculateFuelCost, calculateTransitTime, getVehicleEffectiveSpeed } from "@/lib/shipping";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
|
import { effectiveMaxMass } from "@/lib/base/modules";
|
||||||
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
|
import { buildRoadGraph, findRoute } from "@/lib/map/routing";
|
||||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
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." };
|
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 =
|
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) {
|
if (!originMapId || !destMapId) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -301,10 +305,11 @@ export async function createShipment(params: {
|
||||||
}
|
}
|
||||||
|
|
||||||
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
|
destMass += (resource.massPerUnit ?? 0) * cargoItem.amount;
|
||||||
if (destType.maxCapacityMass && destMass > destType.maxCapacityMass) {
|
const destMaxMass = effectiveMaxMass(destType, destination.installedModules);
|
||||||
|
if (destMaxMass && destMass > destMaxMass) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
||||||
import { ArrowRightIcon, ChevronRightIcon, TruckIcon } from "lucide-react";
|
import { ArrowRightIcon, ChevronRightIcon, TruckIcon } from "lucide-react";
|
||||||
|
import { getShipmentStatus, isShipmentActive } from "@/lib/shipping/client";
|
||||||
|
|
||||||
interface ShipmentCardProps {
|
interface ShipmentCardProps {
|
||||||
shipment: Shipment;
|
shipment: Shipment;
|
||||||
|
|
@ -47,7 +48,8 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
||||||
const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
|
const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
|
||||||
const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : 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 cargoCount = shipment.cargo?.length ?? 0;
|
||||||
const totalItems = shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
|
const totalItems = shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
|
||||||
|
|
@ -71,11 +73,13 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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"
|
? "In Transit"
|
||||||
: shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)}
|
: effectiveStatus === "arrived"
|
||||||
|
? "Arriving"
|
||||||
|
: effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
|
|
@ -106,11 +110,15 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemDescription>
|
<ItemDescription>
|
||||||
{shipment.status === "completed"
|
{effectiveStatus === "completed"
|
||||||
? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}`
|
? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}`
|
||||||
: shipment.status === "cancelled"
|
: effectiveStatus === "cancelled"
|
||||||
? "Cancelled"
|
? "Cancelled"
|
||||||
: (shipment.failureReason ?? (isActive ? "In transit" : shipment.status))}
|
: effectiveStatus === "arrived"
|
||||||
|
? "Arriving"
|
||||||
|
: effectiveStatus === "in_transit" || effectiveStatus === "dispatched"
|
||||||
|
? "In transit"
|
||||||
|
: effectiveStatus}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
|
<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 details <ChevronRightIcon className="size-3" />
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,8 @@ import type { Shipment } from "@/payload-types";
|
||||||
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
|
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
||||||
import {
|
import { CheckCircleIcon, ClockIcon, ListFilterIcon, TruckIcon, XCircleIcon } from "lucide-react";
|
||||||
CheckCircleIcon,
|
import { isShipmentActive } from "@/lib/shipping/client";
|
||||||
ClockIcon,
|
|
||||||
ListFilterIcon,
|
|
||||||
TruckIcon,
|
|
||||||
XCircleIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
{ label: "All", value: null },
|
{ label: "All", value: null },
|
||||||
|
|
@ -21,8 +16,6 @@ const statusFilters = [
|
||||||
{ label: "Stranded", value: "stranded" },
|
{ label: "Stranded", value: "stranded" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeStatuses = ["dispatched", "in_transit", "pending"];
|
|
||||||
|
|
||||||
interface ShipmentsListProps {
|
interface ShipmentsListProps {
|
||||||
shipments: Shipment[];
|
shipments: Shipment[];
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +69,7 @@ export function ShipmentsList({ shipments }: ShipmentsListProps) {
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!filter) return shipments;
|
if (!filter) return shipments;
|
||||||
if (filter === "active") {
|
if (filter === "active") {
|
||||||
return shipments.filter((s) => activeStatuses.includes(s.status));
|
return shipments.filter((s) => isShipmentActive(s));
|
||||||
}
|
}
|
||||||
return shipments.filter((s) => s.status === filter);
|
return shipments.filter((s) => s.status === filter);
|
||||||
}, [shipments, filter]);
|
}, [shipments, filter]);
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,14 @@ export function calculateStoredMass(storedResources: GameStructure["storedResour
|
||||||
export function calculateUtilization(
|
export function calculateUtilization(
|
||||||
structure: Structure,
|
structure: Structure,
|
||||||
storedResources: GameStructure["storedResources"],
|
storedResources: GameStructure["storedResources"],
|
||||||
|
capacityBonus = 0,
|
||||||
): number | null {
|
): number | null {
|
||||||
const maxMass = structure.maxCapacityMass;
|
const maxMass = structure.maxCapacityMass;
|
||||||
if (!maxMass || maxMass <= 0) return null;
|
if (!maxMass || maxMass <= 0) return null;
|
||||||
|
const effective = maxMass + Math.max(0, capacityBonus);
|
||||||
|
|
||||||
const currentMass = calculateStoredMass(storedResources);
|
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.
|
* 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 x = structure.xSize;
|
||||||
const y = structure.ySize;
|
const y = structure.ySize;
|
||||||
if (!x || !y) return null;
|
if (!x || !y) return null;
|
||||||
return x * y;
|
return x * y + Math.max(0, Math.floor(bonusSlots));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
34
src/lib/shipping/client.ts
Normal file
34
src/lib/shipping/client.ts
Normal 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";
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import { notifyUser } from "@/lib/notifications";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
import { isPointArray, type MapPoint } from "@/lib/map/points";
|
||||||
import { maybeStartConstruction } from "@/lib/map/construction";
|
import { maybeStartConstruction } from "@/lib/map/construction";
|
||||||
import { errorMessage, type BinLogger } from "./lib/binFileLogger";
|
|
||||||
|
|
||||||
type ShipmentDoc = Shipment & {
|
type ShipmentDoc = Shipment & {
|
||||||
transportVehicle: GameVehicle & { type: Vehicle };
|
transportVehicle: GameVehicle & { type: Vehicle };
|
||||||
|
|
@ -14,33 +13,54 @@ type ShipmentDoc = Shipment & {
|
||||||
destination: GameStructure;
|
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,
|
payload: Payload,
|
||||||
shipment: ShipmentDoc,
|
shipmentDoc: ShipmentDoc,
|
||||||
log: Pick<BinLogger, "error">,
|
now: Date = new Date(),
|
||||||
|
log?: AdvanceLog,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = new Date();
|
if (shipmentDoc.status !== "dispatched" && shipmentDoc.status !== "in_transit") return;
|
||||||
const vehicle = shipment.transportVehicle;
|
if (!shipmentDoc.dispatchedAt || !shipmentDoc.estimatedArrival) return;
|
||||||
|
|
||||||
|
const vehicle = shipmentDoc.transportVehicle;
|
||||||
if (!vehicle || typeof vehicle === "number") return;
|
if (!vehicle || typeof vehicle === "number") return;
|
||||||
|
|
||||||
const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
|
const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
|
||||||
if (!vehicleType) return;
|
if (!vehicleType) return;
|
||||||
|
|
||||||
const dispatchedAt = new Date(shipment.dispatchedAt!);
|
const dispatchedAt = new Date(shipmentDoc.dispatchedAt).getTime();
|
||||||
const estimatedArrival = new Date(shipment.estimatedArrival!);
|
const estimatedArrival = new Date(shipmentDoc.estimatedArrival).getTime();
|
||||||
const gameRules = await payload.findGlobal({ slug: "game-rules" });
|
const totalDurationMs = Math.max(1, estimatedArrival - dispatchedAt);
|
||||||
const tickIntervalMinutes = (gameRules.gameTickIntervalMinutes as number) || 10;
|
const elapsedMs = Math.max(0, Math.min(now.getTime() - dispatchedAt, totalDurationMs));
|
||||||
const totalDurationMs = estimatedArrival.getTime() - dispatchedAt.getTime();
|
const targetFuelConsumed = Math.min(
|
||||||
const totalTicks = Math.max(1, Math.ceil(totalDurationMs / (tickIntervalMinutes * 60 * 1000)));
|
shipmentDoc.fuelCost,
|
||||||
|
shipmentDoc.fuelCost * (elapsedMs / totalDurationMs),
|
||||||
|
);
|
||||||
|
const fuelDelta = targetFuelConsumed - (shipmentDoc.fuelConsumed ?? 0);
|
||||||
|
|
||||||
const fuelPerTick = shipment.fuelCost / totalTicks;
|
if (vehicle.currentFuel < fuelDelta) {
|
||||||
const currentFuelConsumed = shipment.fuelConsumed ?? 0;
|
|
||||||
|
|
||||||
if (vehicle.currentFuel < fuelPerTick) {
|
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: "shipments",
|
collection: "shipments",
|
||||||
id: shipment.id,
|
id: shipmentDoc.id,
|
||||||
data: {
|
data: {
|
||||||
status: "stranded",
|
status: "stranded",
|
||||||
failureReason: "Vehicle ran out of fuel during transit.",
|
failureReason: "Vehicle ran out of fuel during transit.",
|
||||||
|
|
@ -53,39 +73,33 @@ export async function processShipmentTick(
|
||||||
data: { status: "damaged" },
|
data: { status: "damaged" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const originId =
|
|
||||||
typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number);
|
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.ShipmentFail,
|
type: EventTypes.ShipmentFail,
|
||||||
message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" stranded: vehicle out of fuel`,
|
message: `Shipment "${shipmentDoc.name ?? `SHP-${shipmentDoc.id}`}" stranded: vehicle out of fuel`,
|
||||||
structure: originId,
|
structure: docId(shipmentDoc.origin),
|
||||||
targetCollection: "shipments",
|
targetCollection: "shipments",
|
||||||
targetId: shipment.id,
|
targetId: shipmentDoc.id,
|
||||||
data: { reason: "out_of_fuel", vehicleId: vehicle.id },
|
data: { reason: "out_of_fuel", vehicleId: vehicle.id },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newFuel = Math.max(0, vehicle.currentFuel - fuelPerTick);
|
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: "game-vehicles",
|
collection: "game-vehicles",
|
||||||
id: vehicle.id,
|
id: vehicle.id,
|
||||||
data: { currentFuel: newFuel },
|
data: { currentFuel: Math.max(0, vehicle.currentFuel - fuelDelta) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
if (now.getTime() >= estimatedArrival) {
|
||||||
|
await completeShipment(payload, shipmentDoc, targetFuelConsumed, now, log);
|
||||||
if (now >= estimatedArrival) {
|
|
||||||
await completeShipment(payload, shipment, newFuelConsumed, now, log);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: "shipments",
|
collection: "shipments",
|
||||||
id: shipment.id,
|
id: shipmentDoc.id,
|
||||||
data: {
|
data: {
|
||||||
fuelConsumed: newFuelConsumed,
|
fuelConsumed: targetFuelConsumed,
|
||||||
status: "in_transit",
|
status: "in_transit",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -96,15 +110,24 @@ async function completeShipment(
|
||||||
shipment: ShipmentDoc,
|
shipment: ShipmentDoc,
|
||||||
finalFuelConsumed: number,
|
finalFuelConsumed: number,
|
||||||
now: Date,
|
now: Date,
|
||||||
log: Pick<BinLogger, "error">,
|
log?: AdvanceLog,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const vehicle = shipment.transportVehicle;
|
const vehicle = shipment.transportVehicle;
|
||||||
const destinationId =
|
const destinationId = docId(shipment.destination);
|
||||||
typeof shipment.destination === "object"
|
const originId = docId(shipment.origin);
|
||||||
? shipment.destination.id
|
|
||||||
: (shipment.destination as number);
|
// Someone may have completed this shipment between the caller's fetch and
|
||||||
const originId =
|
// now (concurrent page loads, the tick racing an action): re-check the
|
||||||
typeof shipment.origin === "object" ? shipment.origin.id : (shipment.origin as number);
|
// 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 }[] = [];
|
const bounced: { itemId: number; amount: number; resourceName: string }[] = [];
|
||||||
|
|
||||||
|
|
@ -147,7 +170,7 @@ async function completeShipment(
|
||||||
amount: cargoEntry.amount,
|
amount: cargoEntry.amount,
|
||||||
resourceName: resName,
|
resourceName: resName,
|
||||||
});
|
});
|
||||||
log.error(
|
log?.error?.(
|
||||||
`Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`,
|
`Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -178,7 +201,7 @@ async function completeShipment(
|
||||||
data: { storedResources: existingResources },
|
data: { storedResources: existingResources },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
await maybeStartConstruction(payload, destinationId, now);
|
await maybeStartConstruction(payload, destinationId, now);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log?.error?.(
|
||||||
`Construction start failed at destination "${destinationId}": ${errorMessage(error)}`,
|
`Construction start failed at destination "${destinationId}": ${errorMessage(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -389,3 +412,7 @@ async function createAutoReturnShipment(
|
||||||
data: { vehicleId: vehicle.id, originalShipmentId: originalShipment.id },
|
data: { vehicleId: vehicle.id, originalShipmentId: originalShipment.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
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 { processConstructionTick } from "@/lib/map/construction";
|
||||||
|
import { processProductionTick } from "@/lib/base/production";
|
||||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
|
@ -26,13 +27,14 @@ export const script = async (config: SanitizedConfig) => {
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
for (const shipment of activeShipments.docs) {
|
for (const shipment of activeShipments.docs) {
|
||||||
try {
|
try {
|
||||||
await processShipmentTick(
|
await advanceShipment(
|
||||||
payload,
|
payload,
|
||||||
shipment as unknown as Shipment & {
|
shipment as unknown as Shipment & {
|
||||||
transportVehicle: GameVehicle & { type: Vehicle };
|
transportVehicle: GameVehicle & { type: Vehicle };
|
||||||
origin: GameStructure;
|
origin: GameStructure;
|
||||||
destination: GameStructure;
|
destination: GameStructure;
|
||||||
},
|
},
|
||||||
|
new Date(),
|
||||||
log,
|
log,
|
||||||
);
|
);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
|
|
@ -42,6 +44,7 @@ export const script = async (config: SanitizedConfig) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const construction = await processConstructionTick(payload, log);
|
const construction = await processConstructionTick(payload, log);
|
||||||
|
const production = await processProductionTick(payload, log);
|
||||||
|
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
type: EventTypes.GameTick,
|
type: EventTypes.GameTick,
|
||||||
|
|
@ -50,6 +53,7 @@ export const script = async (config: SanitizedConfig) => {
|
||||||
processedCount,
|
processedCount,
|
||||||
totalActive: activeShipments.docs.length,
|
totalActive: activeShipments.docs.length,
|
||||||
construction,
|
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.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);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
|
|
|
||||||
339
tests/int/shipment-lifecycle.int.spec.ts
Normal file
339
tests/int/shipment-lifecycle.int.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
80
tests/int/shipping-client.int.spec.ts
Normal file
80
tests/int/shipping-client.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue