1
0
Fork 0

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.
This commit is contained in:
Jason Fraley 2026-08-25 12:29:07 -04:00
parent 824b03ec49
commit e72b849774
8 changed files with 317 additions and 210 deletions

View file

@ -22,6 +22,13 @@ APP_URL=http://localhost:3000
# Coolify (or to your cron). See DEPLOYMENT.md. # Coolify (or to your cron). See DEPLOYMENT.md.
GAME_TICK_NOTIFY_SECRET=YOUR_GAME_TICK_NOTIFY_SECRET GAME_TICK_NOTIFY_SECRET=YOUR_GAME_TICK_NOTIFY_SECRET
# --- Bin script file logging ---
# Optional. Payload bin scripts (game-tick, market-tick, mission-tick,
# generate-mission) tee their logs to daily files. Defaults to <repo>/logs
# (inside containers: /app/logs — mount a persistent volume or point this at
# a persisted path, e.g. /app/media/logs, to keep them across restarts).
# BIN_LOG_DIR=
# --- Payload email (nodemailer / SMTP relay) --- # --- Payload email (nodemailer / SMTP relay) ---
# Optional. Leave blank to disable outbound email (password resets, etc). # Optional. Leave blank to disable outbound email (password resets, etc).
EMAIL_FROM_ADDRESS= EMAIL_FROM_ADDRESS=

3
.gitignore vendored
View file

@ -31,6 +31,9 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# bin script file logs (game-tick, market-tick, mission-tick, generate-mission)
/logs/
# local env files # local env files
.env*.local .env*.local

View file

@ -147,6 +147,7 @@ Shipping simulation (`src/collections/logistics/Shipments.ts`, `src/scripts/`, `
- **Shipment fields**: `origin`/`destination` → `game-structures`, `transportVehicle` → `game-vehicles`, `cargo[]` (relationship to resources/assets/vehicles + amount), `distance`, `fuelCost`/`fuelConsumed`, `status` (`pending`/`dispatched`/`in_transit`/`arrived`/`completed`/`cancelled`/`failed`/`stranded`), `autoReturn` checkbox, `failureReason`. - **Shipment fields**: `origin`/`destination` → `game-structures`, `transportVehicle` → `game-vehicles`, `cargo[]` (relationship to resources/assets/vehicles + amount), `distance`, `fuelCost`/`fuelConsumed`, `status` (`pending`/`dispatched`/`in_transit`/`arrived`/`completed`/`cancelled`/`failed`/`stranded`), `autoReturn` checkbox, `failureReason`.
- **Game tick**: `bun run payload game-tick` — a `bin` registered on `payload.config.ts`, NOT an npm script. It processes active shipments + fuel consumption, then `process.exit(0)`. - **Game tick**: `bun run payload game-tick` — a `bin` registered on `payload.config.ts`, NOT an npm script. It processes active shipments + fuel consumption, then `process.exit(0)`.
- **Bin file logging**: all four bins (`game-tick`, `market-tick`, `mission-tick`, `generate-mission`) tee their logs to daily files `logs/<bin-key>/<YYYY-MM-DD>.log` (UTC) via `createBinLogger` in `src/scripts/lib/binFileLogger.ts` — in addition to the console. Writes are `appendFileSync` (bin scripts `process.exit` immediately, so async streams would truncate). A fatal error in a bin is caught, logged to the file, and exits code 1. Directory override: `BIN_LOG_DIR` env (defaults to `<cwd>/logs` — inside Docker point it at a persistent volume). `logs/` is gitignored.
- **Arrival handling** (`src/scripts/processShipmentTick.ts`): destination storage rules are re-checked on arrival; rejected cargo bounces back to origin, shipment goes `failed`, `ShipmentFail` event logged. - **Arrival handling** (`src/scripts/processShipmentTick.ts`): destination storage rules are re-checked on arrival; rejected cargo bounces back to origin, shipment goes `failed`, `ShipmentFail` event logged.
- **GameRules tuning**: `proximityThreshold` and `gameTickIntervalMinutes` live on the global `game-rules` doc. - **GameRules tuning**: `proximityThreshold` and `gameTickIntervalMinutes` live on the global `game-rules` doc.
- **UI**: `src/app/(frontend)/logistics/shipments/` (list + `[id]` detail with `ShipmentActions` controls), `src/app/(frontend)/logistics/game-vehicles/` (deployed vehicle views). - **UI**: `src/app/(frontend)/logistics/shipments/` (list + `[id]` detail with `ShipmentActions` controls), `src/app/(frontend)/logistics/game-vehicles/` (deployed vehicle views).

View file

@ -57,10 +57,13 @@ Dev credentials: `dev` / `Test123` (if seed data has been applied).
| `bun run payload <bin>` | Run Payload binaries | | `bun run payload <bin>` | Run Payload binaries |
| `bun run payload game-tick` | Process shipment arrivals and fuel consumption | | `bun run payload game-tick` | Process shipment arrivals and fuel consumption |
| `bun run payload market-tick` | Expire listings and refresh NPC vendor stock | | `bun run payload market-tick` | Expire listings and refresh NPC vendor stock |
| `bun run payload mission-tick` | Auto-complete missions whose scheduled day has passed |
| `bun run deploy` | Bump patch version, build, and deploy | | `bun run deploy` | Bump patch version, build, and deploy |
Note: `game-tick` and `market-tick` are Payload bins registered in `payload.config.ts`, not npm scripts. Run via `bun run payload`. Note: `game-tick` and `market-tick` are Payload bins registered in `payload.config.ts`, not npm scripts. Run via `bun run payload`.
All four bin scripts also log to daily files under `logs/<bin-key>/` (e.g. `logs/game-tick/2026-08-24.log`) so tick output can be tracked over time. Override the directory with the `BIN_LOG_DIR` env var.
For an Arma 3 unit deployment we self-host on **Coolify** with one container per environment (dev / stg / prod), a Coolify-managed PostgreSQL service per environment, a persistent volume for `/app/media`, and `docker exec` scheduled jobs for the game tick. The Dockerfile is Bun-based (multi-stage, Next.js standalone runtime + Payload CLI kept at runtime), and `docker-compose.yml` mirrors the same shape for local full-stack dev. For an Arma 3 unit deployment we self-host on **Coolify** with one container per environment (dev / stg / prod), a Coolify-managed PostgreSQL service per environment, a persistent volume for `/app/media`, and `docker exec` scheduled jobs for the game tick. The Dockerfile is Bun-based (multi-stage, Next.js standalone runtime + Payload CLI kept at runtime), and `docker-compose.yml` mirrors the same shape for local full-stack dev.
For the full setup — per-env Postgres provisioning, env vars, scheduled jobs, persistent media volume, SSE single-instance caveat, rollbacks, troubleshooting — see **[DEPLOYMENT.md](./DEPLOYMENT.md)**. For the full setup — per-env Postgres provisioning, env vars, scheduled jobs, persistent media volume, SSE single-instance caveat, rollbacks, troubleshooting — see **[DEPLOYMENT.md](./DEPLOYMENT.md)**.

View file

@ -2,70 +2,76 @@ 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 { processShipmentTick } from "./processShipmentTick";
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";
export const script = async (config: SanitizedConfig) => { export const script = async (config: SanitizedConfig) => {
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const log = createBinLogger("game-tick", payload.logger);
payload.logger.info("[Game Tick] Starting game tick..."); try {
log.info("[Game Tick] Starting game tick...");
const activeShipments = await payload.find({ const activeShipments = await payload.find({
collection: "shipments", collection: "shipments",
where: { where: {
status: { in: ["dispatched", "in_transit"] }, status: { in: ["dispatched", "in_transit"] },
},
limit: 100,
depth: 2,
});
let processedCount = 0;
for (const shipment of activeShipments.docs) {
try {
await processShipmentTick(
payload,
shipment as unknown as Shipment & {
transportVehicle: GameVehicle & { type: Vehicle };
origin: GameStructure;
destination: GameStructure;
},
);
processedCount++;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(`[Game Tick] Error processing shipment ${shipment.id}: ${message}`);
}
}
await emitGameEvent(payload, {
type: EventTypes.GameTick,
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
data: {
processedCount,
totalActive: activeShipments.docs.length,
},
});
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) {
await fetch(`${notifyUrl}/api/game-tick/notify`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-game-tick-secret": notifySecret,
}, },
body: JSON.stringify({ processedAt: new Date().toISOString() }), limit: 100,
}).catch((error: unknown) => { depth: 2,
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(`[Game Tick] Failed to notify app clients: ${message}`);
}); });
} else {
payload.logger.warn(
"[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification",
);
}
payload.logger.info(`[Game Tick] Complete. Processed ${processedCount} shipments.`); let processedCount = 0;
process.exit(0); for (const shipment of activeShipments.docs) {
}; try {
await processShipmentTick(
payload,
shipment as unknown as Shipment & {
transportVehicle: GameVehicle & { type: Vehicle };
origin: GameStructure;
destination: GameStructure;
},
log,
);
processedCount++;
} catch (error) {
log.error(`[Game Tick] Error processing shipment ${shipment.id}: ${errorMessage(error)}`);
}
}
await emitGameEvent(payload, {
type: EventTypes.GameTick,
message: `Game tick processed ${processedCount} of ${activeShipments.docs.length} active shipments`,
data: {
processedCount,
totalActive: activeShipments.docs.length,
},
});
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) {
await fetch(`${notifyUrl}/api/game-tick/notify`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-game-tick-secret": notifySecret,
},
body: JSON.stringify({ processedAt: new Date().toISOString() }),
}).catch((error: unknown) => {
log.error(`[Game Tick] Failed to notify app clients: ${errorMessage(error)}`);
});
} else {
log.warn("[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification");
}
log.info(`[Game Tick] Complete. Processed ${processedCount} shipments.`);
process.exit(0);
} catch (error) {
log.error(
`[Game Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
);
process.exit(1);
}
};

View file

@ -0,0 +1,77 @@
import fs from "node:fs";
import path from "node:path";
/**
* File logging for Payload bin scripts (game-tick, market-tick, mission-tick,
* generate-mission).
*
* Each log call is appended SYNCHRONOUSLY to a daily file under
* `logs/<scriptKey>/<YYYY-MM-DD>.log` (UTC) and also forwarded to the console
* logger (payload.logger), so console output is unchanged. Sync writes are
* deliberate: bin scripts call `process.exit(0)` immediately after their last
* log line, which would truncate anything still buffered in an async stream
* (Payload uses a sync logger for bins for the same reason).
*
* File logging must never break the tick: all fs errors are swallowed and the
* message still goes to console.
*
* Override the directory with `BIN_LOG_DIR` (e.g. point it at a persistent
* volume in container deployments — by default it is `<cwd>/logs`, which is
* ephemeral inside Docker).
*/
/** Minimal structural type satisfied by `payload.logger` (pino). */
export interface ConsoleLogger {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
}
export interface BinLogger {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
}
type Level = "INFO" | "WARN" | "ERROR";
function resolveLogDir(scriptKey: string): string {
const base = process.env.BIN_LOG_DIR ?? path.join(process.cwd(), "logs");
return path.join(base, scriptKey);
}
function logFilePath(logDir: string, now: Date): string {
return path.join(logDir, `${now.toISOString().slice(0, 10)}.log`);
}
function writeLogLine(logDir: string, level: Level, msg: string): void {
try {
const now = new Date();
fs.mkdirSync(logDir, { recursive: true });
fs.appendFileSync(logFilePath(logDir, now), `${now.toISOString()} [${level}] ${msg}\n`);
} catch {
// File logging is best-effort; the console copy below still goes out.
}
}
export function createBinLogger(scriptKey: string, consoleLogger: ConsoleLogger): BinLogger {
const logDir = resolveLogDir(scriptKey);
const tee =
(level: Level, consoleFn: (msg: string) => void): ((msg: string) => void) =>
(msg: string) => {
writeLogLine(logDir, level, msg);
consoleFn(msg);
};
return {
info: tee("INFO", (msg) => consoleLogger.info(msg)),
warn: tee("WARN", (msg) => consoleLogger.warn(msg)),
error: tee("ERROR", (msg) => consoleLogger.error(msg)),
};
}
/** Uniform message extraction for caught unknowns (matches the actions.ts convention). */
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unknown error";
}

View file

@ -8,162 +8,166 @@ import { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs";
import { expireNegotiationsForListing } from "@/lib/market/negotiations"; import { expireNegotiationsForListing } from "@/lib/market/negotiations";
import { resolveAssetId } from "@/lib/locker"; import { resolveAssetId } from "@/lib/locker";
import { getMainCurrencyId } from "@/lib/banking"; import { getMainCurrencyId } from "@/lib/banking";
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
export const script = async (config: SanitizedConfig) => { export const script = async (config: SanitizedConfig) => {
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const log = createBinLogger("market-tick", payload.logger);
payload.logger.info("[Market Tick] Starting market tick..."); try {
log.info("[Market Tick] Starting market tick...");
const now = new Date(); const now = new Date();
const nowIso = now.toISOString(); const nowIso = now.toISOString();
// 1. Expire overdue listings (returns stock to sellers for user listings). // 1. Expire overdue listings (returns stock to sellers for user listings).
const expiring = await payload.find({ const expiring = await payload.find({
collection: "market-listings", collection: "market-listings",
where: { where: {
and: [{ status: { equals: "active" } }, { expiresAt: { less_than_equal: nowIso } }], and: [{ status: { equals: "active" } }, { expiresAt: { less_than_equal: nowIso } }],
},
limit: 200,
depth: 1,
sort: "expiresAt",
});
let expiredCount = 0;
let expiredUserCount = 0;
for (const listing of expiring.docs) {
const sellerId =
typeof listing.seller === "object" && listing.seller
? (listing.seller as { id: number }).id
: (listing.seller as number | null);
const isUserListing = sellerId != null && !listing.isAutoGenerated;
try {
await expireListing(payload, listing as unknown as MarketListing);
expiredCount++;
if (isUserListing) expiredUserCount++;
const assetName =
typeof listing.asset === "object" && listing.asset
? listing.asset.name
: `asset #${resolveAssetId(listing.asset)}`;
await expireNegotiationsForListing(payload, listing.id, assetName, {
reason: "expired before you could close the deal",
});
await emitGameEvent(payload, {
type: EventTypes.MarketListingExpire,
message: `Listing for ${listing.quantity}× ${assetName} expired${
isUserListing ? " — stock returned to seller" : ""
}`,
actor: sellerId ?? undefined,
targetCollection: "market-listings",
targetId: listing.id,
data: {
assetId: listing.asset,
assetName,
quantity: listing.quantity,
autoGenerated: listing.isAutoGenerated,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(`[Market Tick] Error expiring listing ${listing.id}: ${message}`);
}
}
// 2. Top up auto-generated entries for tradeable live assets with no active listing.
const active = await payload.find({
collection: "market-listings",
where: { status: { equals: "active" } },
limit: 2000,
depth: 0,
});
const activeAssetIds = new Set(active.docs.map((d) => resolveAssetId(d.asset)));
const assets = await payload.find({
collection: "assets",
where: {
and: [{ tradeable: { equals: true } }, { isLive: { equals: true } }],
},
limit: 2000,
depth: 0,
sort: "name",
});
const currencyId = await getMainCurrencyId(payload);
let generatedCount = 0;
let generatedNpcCount = 0;
for (const asset of assets.docs as unknown as Asset[]) {
if (activeAssetIds.has(asset.id)) continue;
const basePrice = autoPrice(asset, "buy");
if (basePrice == null) continue;
// Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs,
// fall back to generating a placeholder NPC when none sell this good.
const npc = await resolveVendorNpc(payload, asset.id);
if (npc.isGenerated === true) generatedNpcCount++;
const price = applyNpcPriceModifier(basePrice, npc);
if (price <= 0) continue;
const quantity = autoQuantity(asset);
const expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString();
const minPrice = Math.max(1, Math.round(price * 0.6));
try {
await payload.create({
collection: "market-listings",
data: {
asset: asset.id,
seller: null,
npc: npc.id,
quantity,
price,
desiredPrice: price,
minPrice,
currency: currencyId ?? null,
status: "active",
isAutoGenerated: true,
listedAt: nowIso,
expiresAt,
},
overrideAccess: true,
depth: 0,
});
generatedCount++;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
payload.logger.error(
`[Market Tick] Error generating listing for asset ${asset.id}: ${message}`,
);
}
}
await emitGameEvent(payload, {
type: EventTypes.MarketAutoGenerate,
message: `Market tick: expired ${expiredCount} listing(s), generated ${generatedCount} new market entry/entries${generatedNpcCount > 0 ? ` (${generatedNpcCount} new NPC${generatedNpcCount !== 1 ? "s" : ""} generated)` : ""}`,
data: {
expiredCount,
generatedCount,
generatedNpcCount,
activeListings: active.docs.length,
},
});
// 3. Notify connected clients so the market refreshes.
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) {
await fetch(`${notifyUrl}/api/game-tick/notify`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-game-tick-secret": notifySecret,
}, },
body: JSON.stringify({ processedAt: nowIso, source: "market-tick" }), limit: 200,
}).catch((error: unknown) => { depth: 1,
const message = error instanceof Error ? error.message : "Unknown error"; sort: "expiresAt",
payload.logger.error(`[Market Tick] Failed to notify app clients: ${message}`);
}); });
}
payload.logger.info( let expiredCount = 0;
`[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`, let expiredUserCount = 0;
); for (const listing of expiring.docs) {
process.exit(0); const sellerId =
typeof listing.seller === "object" && listing.seller
? (listing.seller as { id: number }).id
: (listing.seller as number | null);
const isUserListing = sellerId != null && !listing.isAutoGenerated;
try {
await expireListing(payload, listing as unknown as MarketListing);
expiredCount++;
if (isUserListing) expiredUserCount++;
const assetName =
typeof listing.asset === "object" && listing.asset
? listing.asset.name
: `asset #${resolveAssetId(listing.asset)}`;
await expireNegotiationsForListing(payload, listing.id, assetName, {
reason: "expired before you could close the deal",
});
await emitGameEvent(payload, {
type: EventTypes.MarketListingExpire,
message: `Listing for ${listing.quantity}× ${assetName} expired${
isUserListing ? " — stock returned to seller" : ""
}`,
actor: sellerId ?? undefined,
targetCollection: "market-listings",
targetId: listing.id,
data: {
assetId: listing.asset,
assetName,
quantity: listing.quantity,
autoGenerated: listing.isAutoGenerated,
},
});
} catch (error) {
log.error(`[Market Tick] Error expiring listing ${listing.id}: ${errorMessage(error)}`);
}
}
// 2. Top up auto-generated entries for tradeable live assets with no active listing.
const active = await payload.find({
collection: "market-listings",
where: { status: { equals: "active" } },
limit: 2000,
depth: 0,
});
const activeAssetIds = new Set(active.docs.map((d) => resolveAssetId(d.asset)));
const assets = await payload.find({
collection: "assets",
where: {
and: [{ tradeable: { equals: true } }, { isLive: { equals: true } }],
},
limit: 2000,
depth: 0,
sort: "name",
});
const currencyId = await getMainCurrencyId(payload);
let generatedCount = 0;
let generatedNpcCount = 0;
for (const asset of assets.docs as unknown as Asset[]) {
if (activeAssetIds.has(asset.id)) continue;
const basePrice = autoPrice(asset, "buy");
if (basePrice == null) continue;
// Assign an NPC to stock this entry: prefer GM-assigned vendor NPCs,
// fall back to generating a placeholder NPC when none sell this good.
const npc = await resolveVendorNpc(payload, asset.id);
if (npc.isGenerated === true) generatedNpcCount++;
const price = applyNpcPriceModifier(basePrice, npc);
if (price <= 0) continue;
const quantity = autoQuantity(asset);
const expiresAt = new Date(Date.now() + AUTO_LISTING_DURATION_MS).toISOString();
const minPrice = Math.max(1, Math.round(price * 0.6));
try {
await payload.create({
collection: "market-listings",
data: {
asset: asset.id,
seller: null,
npc: npc.id,
quantity,
price,
desiredPrice: price,
minPrice,
currency: currencyId ?? null,
status: "active",
isAutoGenerated: true,
listedAt: nowIso,
expiresAt,
},
overrideAccess: true,
depth: 0,
});
generatedCount++;
} catch (error) {
log.error(
`[Market Tick] Error generating listing for asset ${asset.id}: ${errorMessage(error)}`,
);
}
}
await emitGameEvent(payload, {
type: EventTypes.MarketAutoGenerate,
message: `Market tick: expired ${expiredCount} listing(s), generated ${generatedCount} new market entry/entries${generatedNpcCount > 0 ? ` (${generatedNpcCount} new NPC${generatedNpcCount !== 1 ? "s" : ""} generated)` : ""}`,
data: {
expiredCount,
generatedCount,
generatedNpcCount,
activeListings: active.docs.length,
},
});
// 3. Notify connected clients so the market refreshes.
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) {
await fetch(`${notifyUrl}/api/game-tick/notify`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-game-tick-secret": notifySecret,
},
body: JSON.stringify({ processedAt: nowIso, source: "market-tick" }),
}).catch((error: unknown) => {
log.error(`[Market Tick] Failed to notify app clients: ${errorMessage(error)}`);
});
}
log.info(`[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`);
process.exit(0);
} catch (error) {
log.error(
`[Market Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
);
process.exit(1);
}
}; };

View file

@ -4,6 +4,7 @@ import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
import { errorMessage, type BinLogger } from "./lib/binFileLogger";
type ShipmentDoc = Shipment & { type ShipmentDoc = Shipment & {
transportVehicle: GameVehicle & { type: Vehicle }; transportVehicle: GameVehicle & { type: Vehicle };
@ -11,7 +12,11 @@ type ShipmentDoc = Shipment & {
destination: GameStructure; destination: GameStructure;
}; };
export async function processShipmentTick(payload: Payload, shipment: ShipmentDoc): Promise<void> { export async function processShipmentTick(
payload: Payload,
shipment: ShipmentDoc,
log: Pick<BinLogger, "error">,
): Promise<void> {
const now = new Date(); const now = new Date();
const vehicle = shipment.transportVehicle; const vehicle = shipment.transportVehicle;
@ -70,7 +75,7 @@ export async function processShipmentTick(payload: Payload, shipment: ShipmentDo
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost); const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
if (now >= estimatedArrival) { if (now >= estimatedArrival) {
await completeShipment(payload, shipment, newFuelConsumed); await completeShipment(payload, shipment, newFuelConsumed, log);
return; return;
} }
@ -88,6 +93,7 @@ async function completeShipment(
payload: Payload, payload: Payload,
shipment: ShipmentDoc, shipment: ShipmentDoc,
finalFuelConsumed: number, finalFuelConsumed: number,
log: Pick<BinLogger, "error">,
): Promise<void> { ): Promise<void> {
const vehicle = shipment.transportVehicle; const vehicle = shipment.transportVehicle;
const destinationId = const destinationId =
@ -138,7 +144,7 @@ async function completeShipment(
amount: cargoEntry.amount, amount: cargoEntry.amount,
resourceName: resName, resourceName: resName,
}); });
console.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;
@ -169,7 +175,7 @@ async function completeShipment(
data: { storedResources: existingResources }, data: { storedResources: existingResources },
}); });
} catch (error) { } catch (error) {
console.error(`Failed to transfer cargo item ${itemId}:`, error); log.error(`Failed to transfer cargo item ${itemId}: ${errorMessage(error)}`);
} }
} }