Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|