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.
|
# 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
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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)**.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
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,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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue