1
0
Fork 0
polaris-task-force/src/scripts/processShipmentTick.ts
Z8MB1E e72b849774 feat(scripts): tee bin script logs to daily files
Add createBinLogger (src/scripts/lib/binFileLogger.ts): every log call
is appended synchronously to logs/<bin-key>/<YYYY-MM-DD>.log (UTC) in
addition to the console logger — sync writes because bin scripts
process.exit() immediately. Bin bodies are wrapped in try/catch so a
fatal error is logged to file and exits code 1. Override the directory
with BIN_LOG_DIR. logs/ is gitignored.

game-tick, market-tick, and processShipmentTick adopt the logger;
generate-mission follows with its clone fix; mission-tick lands with
its feature. Document BIN_LOG_DIR in .env.example and the logging in
AGENTS.md / README.
2026-08-25 12:29:07 -04:00

370 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Payload } from "payload";
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
import { errorMessage, type BinLogger } from "./lib/binFileLogger";
type ShipmentDoc = Shipment & {
transportVehicle: GameVehicle & { type: Vehicle };
origin: GameStructure;
destination: GameStructure;
};
export async function processShipmentTick(
payload: Payload,
shipment: ShipmentDoc,
log: Pick<BinLogger, "error">,
): Promise<void> {
const now = new Date();
const vehicle = shipment.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 fuelPerTick = shipment.fuelCost / totalTicks;
const currentFuelConsumed = shipment.fuelConsumed ?? 0;
if (vehicle.currentFuel < fuelPerTick) {
await payload.update({
collection: "shipments",
id: shipment.id,
data: {
status: "stranded",
failureReason: "Vehicle ran out of fuel during transit.",
},
});
await payload.update({
collection: "game-vehicles",
id: vehicle.id,
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,
targetCollection: "shipments",
targetId: shipment.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 },
});
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
if (now >= estimatedArrival) {
await completeShipment(payload, shipment, newFuelConsumed, log);
return;
}
await payload.update({
collection: "shipments",
id: shipment.id,
data: {
fuelConsumed: newFuelConsumed,
status: "in_transit",
},
});
}
async function completeShipment(
payload: Payload,
shipment: ShipmentDoc,
finalFuelConsumed: number,
log: Pick<BinLogger, "error">,
): 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 bounced: { itemId: number; amount: number; resourceName: string }[] = [];
for (const cargoEntry of shipment.cargo ?? []) {
const itemRef = cargoEntry.item;
if (!itemRef || typeof itemRef === "number") continue;
const polymorphic = itemRef as { relationTo: string; value: number | { id: number } };
if (polymorphic.relationTo !== "resources") continue;
const itemId = typeof polymorphic.value === "object" ? polymorphic.value.id : polymorphic.value;
try {
const destination = await payload.findByID({
collection: "game-structures",
id: destinationId,
depth: 2,
});
const destType = typeof destination.type === "object" ? destination.type : null;
if (destType) {
const destStored = destination.storedResources ?? [];
const destVoid = (destination as GameStructure).voidStorage ?? [];
const currentStored = [...destStored, ...destVoid].reduce((sum, r) => {
const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
return sum + (resId === itemId ? r.amount : 0);
}, 0);
const ruleCheck = checkStorageDeposit(destType, itemId, cargoEntry.amount, currentStored);
if (!ruleCheck.allowed) {
const resName =
(
await payload
.findByID({ collection: "resources", id: itemId, depth: 0 })
.catch(() => null)
)?.name ?? `resource #${itemId}`;
bounced.push({
itemId,
amount: cargoEntry.amount,
resourceName: resName,
});
log.error(
`Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`,
);
continue;
}
}
const existingResources = destination.storedResources ?? [];
const existingEntry = existingResources.find((r) => {
const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === itemId;
});
if (existingEntry) {
existingEntry.amount += cargoEntry.amount;
} else {
existingResources.push({
resource: itemId,
amount: cargoEntry.amount,
gridX: 0,
gridY: existingResources.length,
rotated: false,
});
}
await payload.update({
collection: "game-structures",
id: destinationId,
data: { storedResources: existingResources },
});
} catch (error) {
log.error(`Failed to transfer cargo item ${itemId}: ${errorMessage(error)}`);
}
}
await payload.update({
collection: "game-vehicles",
id: vehicle.id,
data: {
deployedAt: destinationId,
status: "idle",
},
});
if (bounced.length > 0) {
// Return rejected cargo to the origin structure
const origin = await payload
.findByID({
collection: "game-structures",
id: originId,
depth: 1,
})
.catch(() => null);
if (origin) {
const existingResources = [...(origin.storedResources ?? [])];
for (const b of bounced) {
const existingEntry = existingResources.find((r) => {
const resId = typeof r.resource === "object" ? r.resource.id : r.resource;
return resId === b.itemId;
});
if (existingEntry) {
existingEntry.amount += b.amount;
} else {
existingResources.push({
resource: b.itemId,
amount: b.amount,
gridX: 0,
gridY: existingResources.length,
rotated: false,
});
}
}
await payload.update({
collection: "game-structures",
id: originId,
data: { storedResources: existingResources },
});
}
await payload.update({
collection: "shipments",
id: shipment.id,
data: {
status: "failed",
failureReason: `Destination storage rules prevented delivery: ${bounced
.map((b) => `${b.resourceName} ×${b.amount.toLocaleString()}`)
.join(", ")} — returned to origin.`,
completedAt: new Date().toISOString(),
},
});
await emitGameEvent(payload, {
type: EventTypes.ShipmentFail,
message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected by destination storage rules`,
structure: destinationId,
targetCollection: "shipments",
targetId: shipment.id,
data: {
reason: "storage_rules",
bounced: bounced.map((b) => ({
itemId: b.itemId,
amount: b.amount,
resourceName: b.resourceName,
})),
},
});
return;
}
await payload.update({
collection: "shipments",
id: shipment.id,
data: {
status: "completed",
completedAt: new Date().toISOString(),
fuelConsumed: finalFuelConsumed,
},
});
await emitGameEvent(payload, {
type: EventTypes.ShipmentComplete,
message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" arrived at destination`,
structure: destinationId,
targetCollection: "shipments",
targetId: shipment.id,
data: { vehicleId: vehicle.id, originId, destinationId },
});
// Notify the user who created the shipment (the closest thing to a recipient/owner).
const creatorId =
typeof shipment.createdBy === "object" && shipment.createdBy
? shipment.createdBy.id
: (shipment.createdBy as number | null | undefined);
if (creatorId != null) {
await notifyUser(payload, {
userId: creatorId,
type: "shipment:completed",
title: "Shipment completed",
message: `Shipment "${shipment.name ?? `SHP-${shipment.id}`}" arrived at its destination.`,
link: `/logistics/shipments/${shipment.id}`,
});
}
if (shipment.autoReturn) {
await createAutoReturnShipment(payload, shipment, vehicle as GameVehicle & { type: Vehicle });
}
}
async function createAutoReturnShipment(
payload: Payload,
originalShipment: ShipmentDoc,
vehicle: GameVehicle & { type: Vehicle },
): Promise<void> {
const originId =
typeof originalShipment.origin === "object"
? originalShipment.origin.id
: (originalShipment.origin as number);
const destinationId =
typeof originalShipment.destination === "object"
? originalShipment.destination.id
: (originalShipment.destination as number);
const vehicleType = typeof vehicle.type === "object" ? vehicle.type : null;
if (!vehicleType) return;
const speed =
vehicleType.maxSpeedOnRoad ?? vehicleType.maxSpeedOffRoad ?? vehicleType.maxAirspeed ?? 50;
const rate = vehicleType.fuel?.fuelConsumptionRate ?? 1;
const returnFuelCost = Math.ceil(originalShipment.distance * rate);
const returnDurationMs = (originalShipment.distance / speed) * 60 * 60 * 1000;
const now = new Date();
if (vehicle.currentFuel < returnFuelCost) {
await emitGameEvent(payload, {
type: EventTypes.ShipmentFail,
message: `Auto-return failed — insufficient fuel`,
structure: destinationId,
targetCollection: "game-vehicles",
targetId: vehicle.id,
data: { reason: "insufficient_fuel_for_return" },
});
return;
}
await payload.update({
collection: "game-vehicles",
id: vehicle.id,
data: {
currentFuel: vehicle.currentFuel - returnFuelCost,
status: "in_transit",
deployedAt: null,
},
});
const returnShipment = await payload.create({
collection: "shipments",
data: {
name: `Return: ${originalShipment.name ?? `SHP-${originalShipment.id}`}`,
origin: destinationId,
destination: originId,
transportVehicle: vehicle.id,
distance: originalShipment.distance,
cargo: [],
fuelCost: returnFuelCost,
fuelConsumed: 0,
status: "dispatched",
dispatchedAt: now.toISOString(),
estimatedArrival: new Date(now.getTime() + returnDurationMs).toISOString(),
autoReturn: false,
createdBy: originalShipment.createdBy,
},
});
await emitGameEvent(payload, {
type: EventTypes.ShipmentAutoReturn,
message: `Vehicle auto-returning to origin`,
structure: destinationId,
targetCollection: "shipments",
targetId: returnShipment.id,
data: { vehicleId: vehicle.id, originalShipmentId: originalShipment.id },
});
}