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,70 +2,76 @@ 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",
|
||||
where: {
|
||||
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,
|
||||
const activeShipments = await payload.find({
|
||||
collection: "shipments",
|
||||
where: {
|
||||
status: { in: ["dispatched", "in_transit"] },
|
||||
},
|
||||
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}`);
|
||||
limit: 100,
|
||||
depth: 2,
|
||||
});
|
||||
} else {
|
||||
payload.logger.warn(
|
||||
"[Game Tick] GAME_TICK_NOTIFY_SECRET not set; skipping client notification",
|
||||
);
|
||||
}
|
||||
|
||||
payload.logger.info(`[Game Tick] Complete. Processed ${processedCount} shipments.`);
|
||||
process.exit(0);
|
||||
};
|
||||
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;
|
||||
},
|
||||
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 { 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();
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
|
||||
// 1. Expire overdue listings (returns stock to sellers for user listings).
|
||||
const expiring = await payload.find({
|
||||
collection: "market-listings",
|
||||
where: {
|
||||
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,
|
||||
// 1. Expire overdue listings (returns stock to sellers for user listings).
|
||||
const expiring = await payload.find({
|
||||
collection: "market-listings",
|
||||
where: {
|
||||
and: [{ status: { equals: "active" } }, { expiresAt: { less_than_equal: nowIso } }],
|
||||
},
|
||||
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}`);
|
||||
limit: 200,
|
||||
depth: 1,
|
||||
sort: "expiresAt",
|
||||
});
|
||||
}
|
||||
|
||||
payload.logger.info(
|
||||
`[Market Tick] Complete. Expired ${expiredCount}, generated ${generatedCount}.`,
|
||||
);
|
||||
process.exit(0);
|
||||
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) {
|
||||
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 { 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