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 { 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)
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
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 { 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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
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