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:
parent
824b03ec49
commit
e72b849774
8 changed files with 317 additions and 210 deletions
|
|
@ -22,6 +22,13 @@ APP_URL=http://localhost:3000
|
|||
# Coolify (or to your cron). See DEPLOYMENT.md.
|
||||
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) ---
|
||||
# Optional. Leave blank to disable outbound email (password resets, etc).
|
||||
EMAIL_FROM_ADDRESS=
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -31,6 +31,9 @@ npm-debug.log*
|
|||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# bin script file logs (game-tick, market-tick, mission-tick, generate-mission)
|
||||
/logs/
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
- **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.
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -57,10 +57,13 @@ Dev credentials: `dev` / `Test123` (if seed data has been applied).
|
|||
| `bun run payload <bin>` | Run Payload binaries |
|
||||
| `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 mission-tick` | Auto-complete missions whose scheduled day has passed |
|
||||
| `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`.
|
||||
|
||||
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 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)**.
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import type { SanitizedConfig } from "payload";
|
|||
import { getPayload } from "payload";
|
||||
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
||||
import { processShipmentTick } from "./processShipmentTick";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
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({
|
||||
collection: "shipments",
|
||||
|
|
@ -29,11 +32,11 @@ export const script = async (config: SanitizedConfig) => {
|
|||
origin: GameStructure;
|
||||
destination: GameStructure;
|
||||
},
|
||||
log,
|
||||
);
|
||||
processedCount++;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Game Tick] Error processing shipment ${shipment.id}: ${message}`);
|
||||
log.error(`[Game Tick] Error processing shipment ${shipment.id}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,15 +60,18 @@ export const script = async (config: SanitizedConfig) => {
|
|||
},
|
||||
body: JSON.stringify({ processedAt: new Date().toISOString() }),
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Game Tick] Failed to notify app clients: ${message}`);
|
||||
log.error(`[Game Tick] Failed to notify app clients: ${errorMessage(error)}`);
|
||||
});
|
||||
} else {
|
||||
payload.logger.warn(
|
||||
"[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification",
|
||||
);
|
||||
log.warn("[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification");
|
||||
}
|
||||
|
||||
payload.logger.info(`[Game Tick] Complete. Processed ${processedCount} shipments.`);
|
||||
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);
|
||||
}
|
||||
};
|
||||
77
src/scripts/lib/binFileLogger.ts
Normal file
77
src/scripts/lib/binFileLogger.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -8,11 +8,14 @@ import { applyNpcPriceModifier, resolveVendorNpc } from "@/lib/market/npcs";
|
|||
import { expireNegotiationsForListing } from "@/lib/market/negotiations";
|
||||
import { resolveAssetId } from "@/lib/locker";
|
||||
import { getMainCurrencyId } from "@/lib/banking";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
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 nowIso = now.toISOString();
|
||||
|
|
@ -63,8 +66,7 @@ export const script = async (config: SanitizedConfig) => {
|
|||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Market Tick] Error expiring listing ${listing.id}: ${message}`);
|
||||
log.error(`[Market Tick] Error expiring listing ${listing.id}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,9 +129,8 @@ export const script = async (config: SanitizedConfig) => {
|
|||
});
|
||||
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}`,
|
||||
log.error(
|
||||
`[Market Tick] Error generating listing for asset ${asset.id}: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -157,13 +158,16 @@ export const script = async (config: SanitizedConfig) => {
|
|||
},
|
||||
body: JSON.stringify({ processedAt: nowIso, source: "market-tick" }),
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
payload.logger.error(`[Market Tick] Failed to notify app clients: ${message}`);
|
||||
log.error(`[Market Tick] Failed to notify app clients: ${errorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
payload.logger.info(
|
||||
`[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`,
|
||||
);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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 };
|
||||
|
|
@ -11,7 +12,11 @@ type ShipmentDoc = Shipment & {
|
|||
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 vehicle = shipment.transportVehicle;
|
||||
|
||||
|
|
@ -70,7 +75,7 @@ export async function processShipmentTick(payload: Payload, shipment: ShipmentDo
|
|||
const newFuelConsumed = Math.min(currentFuelConsumed + fuelPerTick, shipment.fuelCost);
|
||||
|
||||
if (now >= estimatedArrival) {
|
||||
await completeShipment(payload, shipment, newFuelConsumed);
|
||||
await completeShipment(payload, shipment, newFuelConsumed, log);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +93,7 @@ async function completeShipment(
|
|||
payload: Payload,
|
||||
shipment: ShipmentDoc,
|
||||
finalFuelConsumed: number,
|
||||
log: Pick<BinLogger, "error">,
|
||||
): Promise<void> {
|
||||
const vehicle = shipment.transportVehicle;
|
||||
const destinationId =
|
||||
|
|
@ -138,7 +144,7 @@ async function completeShipment(
|
|||
amount: cargoEntry.amount,
|
||||
resourceName: resName,
|
||||
});
|
||||
console.error(
|
||||
log.error(
|
||||
`Shipment "${shipment.name ?? `SHP-${shipment.id}`}" delivery rejected at destination: ${storageViolationMessage(resName, ruleCheck)}`,
|
||||
);
|
||||
continue;
|
||||
|
|
@ -169,7 +175,7 @@ async function completeShipment(
|
|||
data: { storedResources: existingResources },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to transfer cargo item ${itemId}:`, error);
|
||||
log.error(`Failed to transfer cargo item ${itemId}: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue