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.
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
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 },
|
||
});
|
||
}
|