diff --git a/.gitignore b/.gitignore
index 1023ee80..ba81c5d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,4 @@ dist
build
.env
**/ai-decision-debugging/data/*
+apps/astriarch-backend/analysis/*
diff --git a/README.md b/README.md
index a1ee6ad8..bcf8ad1f 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,24 @@
Astriarch Ruler of the Stars, Space Strategy Game
http://www.astriarch.com/
+
+## Overview
+
+Astriarch - Ruler of the Stars is a real-time, Multi-player Space Strategy Game.
+Build planetary improvements and star ships to capture planets and defeat your enemies.
+Your ultimate goal is to become the master of the known universe, and earn the title of Astriarch!
+
+## Background
+
+Development started in 2010 by [Mastered Software](http://www.masteredsoftware.com/), Astriarch combines aspects of other classic space strategy games such as [Master of Orion 2](http://en.wikipedia.org/wiki/Master_of_Orion_II:_Battle_at_Antares) (MOO2), [Stellar Conflict](http://hol.abime.net/3427) (1987 Amiga), and [Star Control](http://en.wikipedia.org/wiki/Star_Control).
+
+
+Currently Astriarch is realeased as a free casual web game. Planned future enhancements include the ability to research Carriers and planetary improvements like warp gates, defensive cannons, as well as galaxy special items and events.
+
+
+The name Astriarch comes from the Ancient Greek words for star ([ắstron](http://en.wiktionary.org/wiki/%E1%BC%84%CF%83%CF%84%CF%81%CE%BF%CE%BD#Ancient_Greek)) and ruler ([arkhos](http://en.wiktionary.org/wiki/%E1%BC%80%CF%81%CF%87%CF%8C%CF%82))
+
+
## Quickstart
`docker-compose up`
@@ -47,26 +65,21 @@ Notes:
- Add new scenarios under `apps/astriarch-frontend/e2e/scenarios` and reuse fixtures/helpers from `apps/astriarch-frontend/e2e/fixtures` and `apps/astriarch-frontend/e2e/helpers`.
-## Overview
+## DevOps Notes
-Astriarch - Ruler of the Stars is a real-time, Multi-player Space Strategy Game.
-Build planetary improvements and star ships to capture planets and defeat your enemies.
-Your ultimate goal is to become the master of the known universe, and earn the title of Astriarch!
-
-## Background
-
-Developed in 2010 by Mastered Software, Astriarch combines aspects of other classic space strategy games such as Master of Orion 2 (MOO2), Stellar Conflict (1987 Amiga), and Star Control.
-
-Currently Astriarch is realeased as a free casual web game. Planned future enhancements include the ability to research and develop improvements, as well as galaxy special items and events.
-
-The name Astriarch comes from the Ancient Greek words for star (ắstron) and ruler (arkhos)
+Backup and restore:
+```bash
+mongodump --uri="mongodb+srv://" --out="./production_dump"
+```
+```bash
+mongorestore --uri="mongodb://localhost:27017" ./production_dump
+```
## Credits
-Astriarch - Ruler of the Stars, space strategy game designed and developed by Mastered Software, music by Resonant.
+Astriarch - Ruler of the Stars, space strategy game designed and developed by [Mastered Software](http://www.masteredsoftware.com/), music by Resonant.
## License
This version of Astriarch - Ruler of the Stars is released under the MIT License.
-
diff --git a/apps/astriarch-backend/.env.example b/apps/astriarch-backend/.env.example
index a9667d39..c777263f 100644
--- a/apps/astriarch-backend/.env.example
+++ b/apps/astriarch-backend/.env.example
@@ -5,6 +5,7 @@ MONGODB_PORT=27017
MONGODB_USERNAME=astriarch
MONGODB_PASSWORD=astriarch_password
MONGODB_DATABASE=astriarch_v2
+SESSION_DATABASE=astriarch_v2
# Server Configuration
NODE_ENV=development
@@ -12,7 +13,15 @@ HOST=localhost
PORT=8001
WS_PORT=8001
WS_PROTOCOL=ws
+WS_PING_FREQUENCY_MS=30000
COOKIE_SECRET=astriarch-v2-node-secret
+LOGLEVEL=INFO
# CORS Configuration
CORS_ORIGIN_LIST=http://localhost:5173,http://localhost:3000
+CORS_CREDENTIALS=true
+
+# Game cleanup configuration
+GAME_CLEANUP_ENABLED=true
+GAME_CLEANUP_INTERVAL_SECONDS=7200
+GAME_CLEANUP_MAX_AGE_HOURS=24
diff --git a/apps/astriarch-backend/config/default.json b/apps/astriarch-backend/config/default.json
deleted file mode 100644
index 3da2cd21..00000000
--- a/apps/astriarch-backend/config/default.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "loglevel": "INFO",
- "mongodb": {
- "gamedb_name": "astriarch_v2",
- "sessiondb_name": "astriarch_v2",
- "host": "127.0.0.1",
- "port": "27017",
- "username": null,
- "password": null
- },
- "cookie": {
- "secret": "astriarch-v2-node-secret"
- },
- "server": {
- "port": 8001,
- "host": "localhost"
- },
- "websocket": {
- "port": 8001,
- "protocol": "ws",
- "ping_frequency": 30000
- },
- "game": {
- "client_timeout": 120000,
- "cleanup_old_games": {
- "enabled": true,
- "check_interval_seconds": 7200
- }
- },
- "cors": {
- "origin": ["http://localhost:5173", "http://localhost:3000"],
- "credentials": true
- }
-}
diff --git a/apps/astriarch-backend/config/development.json b/apps/astriarch-backend/config/development.json
deleted file mode 100644
index 2dd0285a..00000000
--- a/apps/astriarch-backend/config/development.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "loglevel": "DEBUG",
- "mongodb": {
- "gamedb_name": "astriarch_v2_dev",
- "sessiondb_name": "astriarch_v2_dev"
- },
- "server": {
- "port": 8001
- }
-}
diff --git a/apps/astriarch-backend/package.json b/apps/astriarch-backend/package.json
index 992cb9d9..64143e74 100644
--- a/apps/astriarch-backend/package.json
+++ b/apps/astriarch-backend/package.json
@@ -7,6 +7,11 @@
"build": "tsc",
"dev": "tsx watch src/app.ts",
"start": "node --enable-source-maps dist/app.js",
+ "analyze:history": "node scripts/analyze-production-history.js",
+ "analyze:production-failures": "node scripts/analyze-production-failures.js",
+ "analyze:replay": "node scripts/export-replay-timeline.js",
+ "analyze:ai-human-strategy": "node scripts/analyze-ai-human-strategy.js",
+ "repair:orphaned-game-logs": "node scripts/repair-orphaned-game-logs.js",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"lint": "eslint src/**/*.ts",
@@ -19,7 +24,6 @@
"dependencies": {
"astriarch-engine": "workspace:*",
"async": "^3.2.4",
- "config": "^3.3.9",
"connect-mongo": "^5.1.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.6",
@@ -36,7 +40,6 @@
},
"devDependencies": {
"@types/async": "^3.2.24",
- "@types/config": "^3.3.3",
"@types/cookie": "^1.0.0",
"@types/cookie-parser": "^1.4.6",
"@types/cookie-signature": "^1.1.2",
diff --git a/apps/astriarch-backend/scripts/analyze-ai-human-strategy.js b/apps/astriarch-backend/scripts/analyze-ai-human-strategy.js
new file mode 100644
index 00000000..a9a85e0b
--- /dev/null
+++ b/apps/astriarch-backend/scripts/analyze-ai-human-strategy.js
@@ -0,0 +1,387 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+
+const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/astriarch_v2';
+const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), 'analysis', 'ai-human-strategy');
+const DEFAULT_TOP_N = 12;
+
+function parseArgs(argv) {
+ const args = {
+ mongoUrl: process.env.MONGODB_CONNECTION_STRING || DEFAULT_MONGO_URL,
+ outputDir: process.env.ASTRIARCH_ANALYSIS_OUTPUT_DIR || DEFAULT_OUTPUT_DIR,
+ topN: DEFAULT_TOP_N,
+ gameId: null,
+ cycleBucketSize: 20,
+ };
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === '--mongo-url' && argv[i + 1]) {
+ args.mongoUrl = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--mongo-url=')) {
+ args.mongoUrl = arg.slice('--mongo-url='.length);
+ } else if (arg === '--output-dir' && argv[i + 1]) {
+ args.outputDir = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--output-dir=')) {
+ args.outputDir = arg.slice('--output-dir='.length);
+ } else if (arg === '--top' && argv[i + 1]) {
+ args.topN = Number(argv[i + 1]);
+ i += 1;
+ } else if (arg.startsWith('--top=')) {
+ args.topN = Number(arg.slice('--top='.length));
+ } else if (arg === '--game-id' && argv[i + 1]) {
+ args.gameId = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--game-id=')) {
+ args.gameId = arg.slice('--game-id='.length);
+ } else if (arg === '--cycle-bucket-size' && argv[i + 1]) {
+ args.cycleBucketSize = Number(argv[i + 1]) || args.cycleBucketSize;
+ i += 1;
+ } else if (arg.startsWith('--cycle-bucket-size=')) {
+ args.cycleBucketSize = Number(arg.slice('--cycle-bucket-size='.length)) || args.cycleBucketSize;
+ }
+ }
+
+ if (!Number.isFinite(args.topN) || args.topN <= 0) {
+ args.topN = DEFAULT_TOP_N;
+ }
+ if (!Number.isFinite(args.cycleBucketSize) || args.cycleBucketSize <= 0) {
+ args.cycleBucketSize = 20;
+ }
+
+ return args;
+}
+
+function toId(value) {
+ if (value == null) return '';
+ return typeof value === 'string' ? value : String(value);
+}
+
+function inc(map, key, by = 1) {
+ map.set(key, (map.get(key) || 0) + by);
+}
+
+function topEntries(map, topN) {
+ return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN);
+}
+
+function pct(value, total) {
+ if (!total) return 0;
+ return value / total;
+}
+
+function formatPct(value) {
+ return `${(value * 100).toFixed(1)}%`;
+}
+
+function playerMetaIndex(games) {
+ const idx = new Map();
+ for (const game of games) {
+ const gameId = toId(game._id);
+ const players = Array.isArray(game.players) ? game.players : [];
+ const gameStatePlayers = Array.isArray(game.gameState?.players) ? game.gameState.players : [];
+
+ for (const p of players) {
+ const playerId = p.Id || p.id;
+ if (!playerId) continue;
+ idx.set(`${gameId}:${playerId}`, {
+ gameId,
+ playerId,
+ name: p.name || playerId,
+ isAI: Boolean(p.isAI),
+ });
+ }
+
+ for (const p of gameStatePlayers) {
+ const playerId = p.id || p.Id;
+ if (!playerId) continue;
+ const key = `${gameId}:${playerId}`;
+ if (!idx.has(key)) {
+ idx.set(key, {
+ gameId,
+ playerId,
+ name: p.name || playerId,
+ isAI: Boolean(p.type === 1 || p.type === 2 || p.type === 3 || p.type === 4),
+ });
+ }
+ }
+ }
+ return idx;
+}
+
+function summarize(games, commands, events, topN, cycleBucketSize) {
+ const playerIdx = playerMetaIndex(games);
+
+ const cohorts = {
+ AI: {
+ commands: 0,
+ failures: 0,
+ byType: new Map(),
+ sendShipsOrders: 0,
+ launchedShipCount: 0,
+ capturedPlanets: 0,
+ lostPlanets: 0,
+ tradesSubmitted: 0,
+ researchQueued: 0,
+ researchPercentAdjusted: 0,
+ byCycleBucket: new Map(),
+ },
+ Human: {
+ commands: 0,
+ failures: 0,
+ byType: new Map(),
+ sendShipsOrders: 0,
+ launchedShipCount: 0,
+ capturedPlanets: 0,
+ lostPlanets: 0,
+ tradesSubmitted: 0,
+ researchQueued: 0,
+ researchPercentAdjusted: 0,
+ byCycleBucket: new Map(),
+ },
+ Unknown: {
+ commands: 0,
+ failures: 0,
+ byType: new Map(),
+ sendShipsOrders: 0,
+ launchedShipCount: 0,
+ capturedPlanets: 0,
+ lostPlanets: 0,
+ tradesSubmitted: 0,
+ researchQueued: 0,
+ researchPercentAdjusted: 0,
+ byCycleBucket: new Map(),
+ },
+ };
+
+ const playerCommandCounts = new Map();
+ const playerAggression = new Map();
+
+ for (const c of commands) {
+ const playerKey = `${c.gameId}:${c.playerId}`;
+ const meta = playerIdx.get(playerKey);
+ const cohortName = meta == null ? 'Unknown' : meta.isAI ? 'AI' : 'Human';
+ const cohort = cohorts[cohortName];
+
+ cohort.commands += 1;
+ if (!c.resultSuccess) cohort.failures += 1;
+ inc(cohort.byType, c.commandType || 'UNKNOWN');
+
+ const cycle = Number(c.command?.clientCycle ?? c.gameCycle);
+ if (Number.isFinite(cycle)) {
+ const start = Math.floor(cycle / cycleBucketSize) * cycleBucketSize;
+ const label = `${start}-${start + cycleBucketSize - 0.01}`;
+ inc(cohort.byCycleBucket, label);
+ }
+
+ if (c.commandType === 'SEND_SHIPS') {
+ cohort.sendShipsOrders += 1;
+ const shipIds = c.command?.shipIds || {};
+ const launched =
+ (Array.isArray(shipIds.scouts) ? shipIds.scouts.length : 0) +
+ (Array.isArray(shipIds.destroyers) ? shipIds.destroyers.length : 0) +
+ (Array.isArray(shipIds.cruisers) ? shipIds.cruisers.length : 0) +
+ (Array.isArray(shipIds.battleships) ? shipIds.battleships.length : 0);
+ cohort.launchedShipCount += launched;
+ }
+ if (c.commandType === 'SUBMIT_TRADE') cohort.tradesSubmitted += 1;
+ if (c.commandType === 'SUBMIT_RESEARCH_ITEM') cohort.researchQueued += 1;
+ if (c.commandType === 'ADJUST_RESEARCH_PERCENT') cohort.researchPercentAdjusted += 1;
+
+ const currentPlayer = playerCommandCounts.get(playerKey) || {
+ gameId: c.gameId,
+ playerId: c.playerId,
+ playerName: meta?.name || c.playerId,
+ cohort: cohortName,
+ totalCommands: 0,
+ sendShips: 0,
+ trades: 0,
+ research: 0,
+ failures: 0,
+ };
+ currentPlayer.totalCommands += 1;
+ if (c.commandType === 'SEND_SHIPS') currentPlayer.sendShips += 1;
+ if (c.commandType === 'SUBMIT_TRADE') currentPlayer.trades += 1;
+ if (c.commandType === 'SUBMIT_RESEARCH_ITEM' || c.commandType === 'ADJUST_RESEARCH_PERCENT') currentPlayer.research += 1;
+ if (!c.resultSuccess) currentPlayer.failures += 1;
+ playerCommandCounts.set(playerKey, currentPlayer);
+ }
+
+ for (const e of events) {
+ if (e.eventType === 'PLANET_CAPTURED') {
+ const attackingId = e.payload?.data?.conflictData?.attackingClientPlayer?.id || e.sourcePlayerId;
+ const meta = playerIdx.get(`${e.gameId}:${attackingId}`);
+ const cohortName = meta == null ? 'Unknown' : meta.isAI ? 'AI' : 'Human';
+ cohorts[cohortName].capturedPlanets += 1;
+ }
+
+ if (e.eventType === 'PLANET_LOST') {
+ const ownerId = e.payload?.data?.conflictData?.defendingClientPlayer?.id || e.sourcePlayerId;
+ const meta = playerIdx.get(`${e.gameId}:${ownerId}`);
+ const cohortName = meta == null ? 'Unknown' : meta.isAI ? 'AI' : 'Human';
+ cohorts[cohortName].lostPlanets += 1;
+ }
+ }
+
+ for (const entry of playerCommandCounts.values()) {
+ const aggression = entry.totalCommands > 0 ? entry.sendShips / entry.totalCommands : 0;
+ const trading = entry.totalCommands > 0 ? entry.trades / entry.totalCommands : 0;
+ const research = entry.totalCommands > 0 ? entry.research / entry.totalCommands : 0;
+ playerAggression.set(`${entry.playerName}::${entry.playerId}::${entry.gameId}`, {
+ ...entry,
+ aggression,
+ trading,
+ research,
+ failureRate: entry.totalCommands > 0 ? entry.failures / entry.totalCommands : 0,
+ });
+ }
+
+ const byCohort = {};
+ for (const cohortName of ['AI', 'Human', 'Unknown']) {
+ const c = cohorts[cohortName];
+ byCohort[cohortName] = {
+ commands: c.commands,
+ failures: c.failures,
+ failureRate: pct(c.failures, c.commands),
+ sendShipsOrders: c.sendShipsOrders,
+ sendShipsRate: pct(c.sendShipsOrders, c.commands),
+ launchedShipCount: c.launchedShipCount,
+ avgShipsPerSendOrder: pct(c.launchedShipCount, c.sendShipsOrders),
+ capturedPlanets: c.capturedPlanets,
+ lostPlanets: c.lostPlanets,
+ captureToLossRatio: c.lostPlanets > 0 ? c.capturedPlanets / c.lostPlanets : c.capturedPlanets,
+ tradesSubmitted: c.tradesSubmitted,
+ tradeRate: pct(c.tradesSubmitted, c.commands),
+ researchQueued: c.researchQueued,
+ researchPercentAdjusted: c.researchPercentAdjusted,
+ commandMix: Object.fromEntries([...c.byType.entries()].sort((a, b) => b[1] - a[1])),
+ cycleBuckets: Object.fromEntries([...c.byCycleBucket.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
+ };
+ }
+
+ return {
+ byCohort,
+ topAggressivePlayers: [...playerAggression.entries()]
+ .map(([key, value]) => ({ key, ...value }))
+ .filter((x) => x.totalCommands >= 25)
+ .sort((a, b) => b.aggression - a.aggression)
+ .slice(0, topN),
+ topTradingPlayers: [...playerAggression.entries()]
+ .map(([key, value]) => ({ key, ...value }))
+ .filter((x) => x.totalCommands >= 25)
+ .sort((a, b) => b.trading - a.trading)
+ .slice(0, topN),
+ topResearchPlayers: [...playerAggression.entries()]
+ .map(([key, value]) => ({ key, ...value }))
+ .filter((x) => x.totalCommands >= 25)
+ .sort((a, b) => b.research - a.research)
+ .slice(0, topN),
+ };
+}
+
+function buildMarkdown(report) {
+ const lines = [];
+ lines.push('# AI vs Human Strategy Analysis');
+ lines.push('');
+ lines.push(`Generated: ${report.generatedAt}`);
+ lines.push(`Database: ${report.database}`);
+ lines.push(`Scope: ${report.scopeDescription}`);
+ lines.push('');
+
+ lines.push('## Cohort Summary');
+ for (const cohortName of ['AI', 'Human', 'Unknown']) {
+ const c = report.summary.byCohort[cohortName];
+ lines.push(`- ${cohortName}: commands=${c.commands}, failureRate=${formatPct(c.failureRate)}, sendRate=${formatPct(c.sendShipsRate)}, avgShipsPerSend=${c.avgShipsPerSendOrder.toFixed(2)}, captures=${c.capturedPlanets}, losses=${c.lostPlanets}, trades=${c.tradesSubmitted}, researchAdjust=${c.researchPercentAdjusted}`);
+ }
+ lines.push('');
+
+ lines.push('## AI Command Mix');
+ for (const [type, count] of Object.entries(report.summary.byCohort.AI.commandMix).slice(0, report.topN)) {
+ lines.push(`- ${type}: ${count}`);
+ }
+ lines.push('');
+
+ lines.push('## Human Command Mix');
+ for (const [type, count] of Object.entries(report.summary.byCohort.Human.commandMix).slice(0, report.topN)) {
+ lines.push(`- ${type}: ${count}`);
+ }
+ lines.push('');
+
+ lines.push('## Top Aggressive Players (SEND_SHIPS ratio)');
+ for (const row of report.summary.topAggressivePlayers) {
+ lines.push(`- ${row.playerName} (${row.cohort}) game ${row.gameId}: aggression=${formatPct(row.aggression)}, commands=${row.totalCommands}, failures=${formatPct(row.failureRate)}`);
+ }
+ lines.push('');
+
+ lines.push('## Top Trading Players');
+ for (const row of report.summary.topTradingPlayers) {
+ lines.push(`- ${row.playerName} (${row.cohort}) game ${row.gameId}: trading=${formatPct(row.trading)}, commands=${row.totalCommands}`);
+ }
+ lines.push('');
+
+ lines.push('## Top Research-Focused Players');
+ for (const row of report.summary.topResearchPlayers) {
+ lines.push(`- ${row.playerName} (${row.cohort}) game ${row.gameId}: research=${formatPct(row.research)}, commands=${row.totalCommands}`);
+ }
+ lines.push('');
+
+ return lines.join('\n');
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ fs.mkdirSync(args.outputDir, { recursive: true });
+
+ await mongoose.connect(args.mongoUrl);
+ try {
+ const db = mongoose.connection.db;
+ const gameQuery = args.gameId ? { _id: args.gameId } : {};
+ const games = await db.collection('games').find(gameQuery).toArray();
+ if (games.length === 0) {
+ console.log('No matching games found.');
+ return;
+ }
+
+ const gameIds = games.map((g) => toId(g._id));
+ const commands = await db.collection('gamecommandlogs').find({ gameId: { $in: gameIds } }).toArray();
+ const events = await db.collection('gameevents').find({ gameId: { $in: gameIds } }).toArray();
+
+ const summary = summarize(games, commands, events, args.topN, args.cycleBucketSize);
+ const report = {
+ generatedAt: new Date().toISOString(),
+ database: args.mongoUrl,
+ topN: args.topN,
+ filters: {
+ gameId: args.gameId,
+ cycleBucketSize: args.cycleBucketSize,
+ },
+ scopeDescription: `${games.length} game(s), ${commands.length} commands, ${events.length} events`,
+ summary,
+ };
+
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const scope = args.gameId ? `game-${args.gameId}` : 'all-games';
+ const jsonPath = path.join(args.outputDir, `${stamp}-${scope}.json`);
+ const mdPath = path.join(args.outputDir, `${stamp}-${scope}.md`);
+
+ fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
+ fs.writeFileSync(mdPath, buildMarkdown(report));
+
+ console.log(buildMarkdown(report));
+ console.log('');
+ console.log(`JSON report written to ${jsonPath}`);
+ console.log(`Markdown report written to ${mdPath}`);
+ } finally {
+ await mongoose.disconnect();
+ }
+}
+
+main().catch((error) => {
+ console.error('[analyze-ai-human-strategy] failed:', error);
+ process.exit(1);
+});
diff --git a/apps/astriarch-backend/scripts/analyze-production-failures.js b/apps/astriarch-backend/scripts/analyze-production-failures.js
new file mode 100644
index 00000000..62251e69
--- /dev/null
+++ b/apps/astriarch-backend/scripts/analyze-production-failures.js
@@ -0,0 +1,330 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+
+const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/astriarch_v2';
+const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), 'analysis', 'production-failures');
+const DEFAULT_TOP_N = 15;
+
+function parseArgs(argv) {
+ const args = {
+ mongoUrl: process.env.MONGODB_CONNECTION_STRING || DEFAULT_MONGO_URL,
+ outputDir: process.env.ASTRIARCH_ANALYSIS_OUTPUT_DIR || DEFAULT_OUTPUT_DIR,
+ topN: DEFAULT_TOP_N,
+ includeNonProduction: false,
+ gameId: null,
+ writeJson: true,
+ writeMarkdown: true,
+ };
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === '--mongo-url' && argv[i + 1]) {
+ args.mongoUrl = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--mongo-url=')) {
+ args.mongoUrl = arg.slice('--mongo-url='.length);
+ } else if (arg === '--output-dir' && argv[i + 1]) {
+ args.outputDir = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--output-dir=')) {
+ args.outputDir = arg.slice('--output-dir='.length);
+ } else if (arg === '--top' && argv[i + 1]) {
+ args.topN = Number(argv[i + 1]);
+ i += 1;
+ } else if (arg.startsWith('--top=')) {
+ args.topN = Number(arg.slice('--top='.length));
+ } else if (arg === '--game-id' && argv[i + 1]) {
+ args.gameId = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--game-id=')) {
+ args.gameId = arg.slice('--game-id='.length);
+ } else if (arg === '--include-non-production') {
+ args.includeNonProduction = true;
+ } else if (arg === '--json-only') {
+ args.writeMarkdown = false;
+ } else if (arg === '--markdown-only') {
+ args.writeJson = false;
+ }
+ }
+
+ if (!Number.isFinite(args.topN) || args.topN <= 0) {
+ args.topN = DEFAULT_TOP_N;
+ }
+
+ return args;
+}
+
+function toId(value) {
+ if (value == null) return '';
+ return typeof value === 'string' ? value : String(value);
+}
+
+function inc(map, key, by = 1) {
+ map.set(key, (map.get(key) || 0) + by);
+}
+
+function topEntries(map, topN) {
+ return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN);
+}
+
+function percent(part, total) {
+ if (!total) return '0.0%';
+ return `${((part / total) * 100).toFixed(1)}%`;
+}
+
+function shipTypeLabel(type) {
+ const labels = {
+ 1: 'SystemDefense',
+ 2: 'Scout',
+ 3: 'Destroyer',
+ 4: 'Cruiser',
+ 5: 'Battleship',
+ 6: 'SpacePlatform',
+ };
+ return labels[type] || `Type${String(type ?? 'Unknown')}`;
+}
+
+function playerIndex(games) {
+ const idx = new Map();
+ for (const game of games) {
+ const gameId = toId(game._id);
+ const players = Array.isArray(game.players) ? game.players : [];
+ for (const p of players) {
+ const playerId = p.Id || p.id;
+ if (!playerId) continue;
+ idx.set(`${gameId}:${playerId}`, {
+ playerName: p.name || playerId,
+ isAI: Boolean(p.isAI),
+ gameName: game.name || gameId,
+ });
+ }
+ }
+ return idx;
+}
+
+function summarize(failures, pIndex, topN) {
+ const byCommandType = new Map();
+ const byError = new Map();
+ const byGame = new Map();
+ const byPlayer = new Map();
+ const byAiBucket = new Map([
+ ['AI', 0],
+ ['Human', 0],
+ ['Unknown', 0],
+ ]);
+ const byPlanet = new Map();
+ const byItemType = new Map();
+ const byShipType = new Map();
+ const byImprovementType = new Map();
+ const byClientCycleBucket = new Map();
+
+ for (const f of failures) {
+ const gameId = f.gameId;
+ const key = `${gameId}:${f.playerId}`;
+ const playerMeta = pIndex.get(key);
+ const aiBucket = playerMeta == null ? 'Unknown' : playerMeta.isAI ? 'AI' : 'Human';
+
+ inc(byCommandType, f.commandType || 'UNKNOWN');
+ inc(byError, `${f.errorCode || 'NO_CODE'}::${f.errorMessage || 'No message'}`);
+ inc(byGame, `${playerMeta?.gameName || gameId}::${gameId}`);
+ inc(byPlayer, `${playerMeta?.playerName || f.playerId}::${f.playerId}::${gameId}`);
+ inc(byAiBucket, aiBucket);
+
+ const planetId = f.command?.planetId;
+ if (planetId != null) {
+ inc(byPlanet, `${gameId}::planet_${planetId}`);
+ }
+
+ const itemType = f.command?.productionItem?.itemType;
+ if (itemType != null) {
+ const itemLabel = itemType === 1 ? 'Improvement' : itemType === 2 ? 'Ship' : `ItemType${itemType}`;
+ inc(byItemType, itemLabel);
+ }
+
+ const starshipType = f.command?.productionItem?.starshipData?.type;
+ if (starshipType != null) {
+ inc(byShipType, shipTypeLabel(starshipType));
+ }
+
+ const improvementType = f.command?.productionItem?.improvementData?.type;
+ if (improvementType != null) {
+ inc(byImprovementType, `ImprovementType${String(improvementType)}`);
+ }
+
+ const cycle = Number(f.command?.clientCycle);
+ if (Number.isFinite(cycle)) {
+ const bucketStart = Math.floor(cycle / 10) * 10;
+ const bucketLabel = `${bucketStart}-${bucketStart + 9.99}`;
+ inc(byClientCycleBucket, bucketLabel);
+ }
+ }
+
+ return {
+ totalFailures: failures.length,
+ byCommandType: Object.fromEntries([...byCommandType.entries()].sort((a, b) => b[1] - a[1])),
+ byError: Object.fromEntries([...byError.entries()].sort((a, b) => b[1] - a[1])),
+ byGame: topEntries(byGame, topN).map(([key, count]) => ({ key, count })),
+ byPlayer: topEntries(byPlayer, topN).map(([key, count]) => ({ key, count })),
+ byAiBucket: Object.fromEntries(byAiBucket.entries()),
+ byPlanet: topEntries(byPlanet, topN).map(([key, count]) => ({ key, count })),
+ byItemType: Object.fromEntries([...byItemType.entries()].sort((a, b) => b[1] - a[1])),
+ byShipType: Object.fromEntries([...byShipType.entries()].sort((a, b) => b[1] - a[1])),
+ byImprovementType: Object.fromEntries([...byImprovementType.entries()].sort((a, b) => b[1] - a[1])),
+ byClientCycleBucket: Object.fromEntries([...byClientCycleBucket.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
+ };
+}
+
+function buildMarkdown(report) {
+ const lines = [];
+ lines.push('# Production Failure Deep Dive');
+ lines.push('');
+ lines.push(`Generated: ${report.generatedAt}`);
+ lines.push(`Database: ${report.database}`);
+ lines.push(`Scope: ${report.scopeDescription}`);
+ lines.push('');
+
+ lines.push('## Summary');
+ lines.push(`- Total failed commands analyzed: ${report.summary.totalFailures}`);
+ lines.push(`- AI failures: ${report.summary.byAiBucket.AI || 0} (${percent(report.summary.byAiBucket.AI || 0, report.summary.totalFailures)})`);
+ lines.push(`- Human failures: ${report.summary.byAiBucket.Human || 0} (${percent(report.summary.byAiBucket.Human || 0, report.summary.totalFailures)})`);
+ lines.push(`- Unknown actor failures: ${report.summary.byAiBucket.Unknown || 0}`);
+ lines.push('');
+
+ lines.push('## Failed Command Types');
+ for (const [type, count] of Object.entries(report.summary.byCommandType)) {
+ lines.push(`- ${type}: ${count}`);
+ }
+ lines.push('');
+
+ lines.push('## Top Failure Reasons');
+ for (const [reason, count] of Object.entries(report.summary.byError).slice(0, report.topN)) {
+ lines.push(`- ${reason}: ${count}`);
+ }
+ lines.push('');
+
+ lines.push('## Production Item Detail');
+ for (const [itemType, count] of Object.entries(report.summary.byItemType)) {
+ lines.push(`- ${itemType}: ${count}`);
+ }
+ for (const [shipType, count] of Object.entries(report.summary.byShipType)) {
+ lines.push(`- Ship ${shipType}: ${count}`);
+ }
+ for (const [improvementType, count] of Object.entries(report.summary.byImprovementType)) {
+ lines.push(`- ${improvementType}: ${count}`);
+ }
+ lines.push('');
+
+ lines.push('## Top Games By Failures');
+ for (const row of report.summary.byGame) {
+ lines.push(`- ${row.key}: ${row.count}`);
+ }
+ lines.push('');
+
+ lines.push('## Top Players By Failures');
+ for (const row of report.summary.byPlayer) {
+ lines.push(`- ${row.key}: ${row.count}`);
+ }
+ lines.push('');
+
+ lines.push('## Cycle Buckets (Client Cycle)');
+ for (const [bucket, count] of Object.entries(report.summary.byClientCycleBucket)) {
+ lines.push(`- ${bucket}: ${count}`);
+ }
+ lines.push('');
+
+ lines.push('## Top Planets With Failures');
+ for (const row of report.summary.byPlanet) {
+ lines.push(`- ${row.key}: ${row.count}`);
+ }
+ lines.push('');
+
+ lines.push('## Example Failure Commands');
+ for (const sample of report.samples) {
+ lines.push(`- game ${sample.gameId}, seq ${sample.sequenceNumber}, player ${sample.playerId}, ${sample.commandType}, ${sample.errorCode || 'NO_CODE'}: ${sample.errorMessage || 'No message'}`);
+ }
+ lines.push('');
+
+ return lines.join('\n');
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ fs.mkdirSync(args.outputDir, { recursive: true });
+
+ await mongoose.connect(args.mongoUrl);
+ try {
+ const db = mongoose.connection.db;
+ const gameQuery = args.gameId ? { _id: args.gameId } : {};
+ const games = await db.collection('games').find(gameQuery).toArray();
+ const gameIds = games.map((g) => toId(g._id));
+
+ if (gameIds.length === 0) {
+ console.log('No matching games found.');
+ return;
+ }
+
+ const failureQuery = {
+ gameId: { $in: gameIds },
+ resultSuccess: false,
+ ...(args.includeNonProduction ? {} : { commandType: 'QUEUE_PRODUCTION_ITEM' }),
+ };
+
+ const failures = await db.collection('gamecommandlogs').find(failureQuery).toArray();
+ const pIdx = playerIndex(games);
+ const summary = summarize(failures, pIdx, args.topN);
+
+ const report = {
+ generatedAt: new Date().toISOString(),
+ database: args.mongoUrl,
+ topN: args.topN,
+ scopeDescription: args.includeNonProduction
+ ? `${gameIds.length} game(s), all failed command types`
+ : `${gameIds.length} game(s), failed QUEUE_PRODUCTION_ITEM only`,
+ filters: {
+ gameId: args.gameId,
+ includeNonProduction: args.includeNonProduction,
+ },
+ summary,
+ samples: failures.slice(0, args.topN).map((f) => ({
+ gameId: f.gameId,
+ sequenceNumber: f.sequenceNumber,
+ playerId: f.playerId,
+ commandType: f.commandType,
+ errorCode: f.errorCode,
+ errorMessage: f.errorMessage,
+ command: f.command,
+ })),
+ };
+
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const scope = args.gameId ? `game-${args.gameId}` : 'all-games';
+ const mode = args.includeNonProduction ? 'all-failures' : 'production-only';
+ const jsonPath = path.join(args.outputDir, `${stamp}-${scope}-${mode}.json`);
+ const mdPath = path.join(args.outputDir, `${stamp}-${scope}-${mode}.md`);
+
+ if (args.writeJson) {
+ fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
+ }
+ if (args.writeMarkdown) {
+ fs.writeFileSync(mdPath, buildMarkdown(report));
+ }
+
+ console.log(buildMarkdown(report));
+ if (args.writeJson) {
+ console.log(`\nJSON report written to ${jsonPath}`);
+ }
+ if (args.writeMarkdown) {
+ console.log(`Markdown report written to ${mdPath}`);
+ }
+ } finally {
+ await mongoose.disconnect();
+ }
+}
+
+main().catch((error) => {
+ console.error('[analyze-production-failures] failed:', error);
+ process.exit(1);
+});
diff --git a/apps/astriarch-backend/scripts/analyze-production-history.js b/apps/astriarch-backend/scripts/analyze-production-history.js
new file mode 100644
index 00000000..c2b7903f
--- /dev/null
+++ b/apps/astriarch-backend/scripts/analyze-production-history.js
@@ -0,0 +1,594 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+
+const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/astriarch_v2';
+const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), 'analysis', 'production-history');
+const DEFAULT_TOP_N = 10;
+
+function parseArgs(argv) {
+ const args = {
+ mongoUrl: process.env.MONGODB_CONNECTION_STRING || DEFAULT_MONGO_URL,
+ outputDir: process.env.ASTRIARCH_ANALYSIS_OUTPUT_DIR || DEFAULT_OUTPUT_DIR,
+ gameId: null,
+ topN: DEFAULT_TOP_N,
+ writeJson: true,
+ writeMarkdown: true,
+ pretty: true,
+ };
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === '--mongo-url' && argv[i + 1]) {
+ args.mongoUrl = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--mongo-url=')) {
+ args.mongoUrl = arg.slice('--mongo-url='.length);
+ } else if (arg === '--output-dir' && argv[i + 1]) {
+ args.outputDir = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--output-dir=')) {
+ args.outputDir = arg.slice('--output-dir='.length);
+ } else if (arg === '--game-id' && argv[i + 1]) {
+ args.gameId = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--game-id=')) {
+ args.gameId = arg.slice('--game-id='.length);
+ } else if (arg === '--top' && argv[i + 1]) {
+ args.topN = Number(argv[i + 1]);
+ i += 1;
+ } else if (arg.startsWith('--top=')) {
+ args.topN = Number(arg.slice('--top='.length));
+ } else if (arg === '--json-only') {
+ args.writeMarkdown = false;
+ } else if (arg === '--markdown-only') {
+ args.writeJson = false;
+ } else if (arg === '--compact') {
+ args.pretty = false;
+ }
+ }
+
+ if (!Number.isFinite(args.topN) || args.topN <= 0) {
+ args.topN = DEFAULT_TOP_N;
+ }
+
+ return args;
+}
+
+function toObjectIdString(value) {
+ if (value == null) return '';
+ if (typeof value === 'string') return value;
+ if (typeof value === 'object' && value.toString) return value.toString();
+ return String(value);
+}
+
+function groupBy(items, keyFn) {
+ const map = new Map();
+ for (const item of items) {
+ const key = keyFn(item);
+ if (!map.has(key)) {
+ map.set(key, []);
+ }
+ map.get(key).push(item);
+ }
+ return map;
+}
+
+function sum(values) {
+ return values.reduce((total, value) => total + value, 0);
+}
+
+function average(values) {
+ return values.length === 0 ? 0 : sum(values) / values.length;
+}
+
+function median(values) {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ const middle = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
+}
+
+function percentile(values, percentileValue) {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((percentileValue / 100) * sorted.length) - 1));
+ return sorted[index];
+}
+
+function formatNumber(value, digits = 1) {
+ return Number(value).toFixed(digits);
+}
+
+function formatPercent(value, digits = 1) {
+ return `${formatNumber(value * 100, digits)}%`;
+}
+
+function topEntries(map, limit = DEFAULT_TOP_N, comparator = (a, b) => b[1] - a[1]) {
+ return [...map.entries()].sort(comparator).slice(0, limit);
+}
+
+function commandLabel(commandType) {
+ const labels = {
+ ADJUST_RESEARCH_PERCENT: 'Research %',
+ CLEAR_WAYPOINT: 'Clear waypoint',
+ DEMOLISH_IMPROVEMENT: 'Demolish improvement',
+ QUEUE_PRODUCTION_ITEM: 'Queue production',
+ REMOVE_PRODUCTION_ITEM: 'Remove production',
+ SEND_SHIPS: 'Send ships',
+ SET_WAYPOINT: 'Set waypoint',
+ SUBMIT_RESEARCH_ITEM: 'Queue research',
+ SUBMIT_TRADE: 'Submit trade',
+ UPDATE_PLANET_WORKER_ASSIGNMENTS: 'Update workers',
+ };
+
+ return labels[commandType] || commandType;
+}
+
+function errorLabel(command) {
+ if (!command.resultSuccess) {
+ return command.errorCode || command.errorMessage || 'Unknown failure';
+ }
+
+ return 'success';
+}
+
+function buildPlayerIndex(gameDocs) {
+ const players = new Map();
+
+ for (const game of gameDocs) {
+ const gameId = toObjectIdString(game._id);
+ const sourcePlayers = Array.isArray(game.players) ? game.players : [];
+ const gameStatePlayers = Array.isArray(game.gameState?.players) ? game.gameState.players : [];
+
+ for (const player of sourcePlayers) {
+ const playerId = player.Id || player.id;
+ if (!playerId) continue;
+
+ players.set(`${gameId}:${playerId}`, {
+ gameId,
+ playerId,
+ name: player.name || playerId,
+ isAI: Boolean(player.isAI),
+ destroyed: Boolean(player.destroyed),
+ position: player.position,
+ });
+ }
+
+ for (const player of gameStatePlayers) {
+ const playerId = player.id || player.Id;
+ if (!playerId) continue;
+
+ const key = `${gameId}:${playerId}`;
+ const existing = players.get(key) || {
+ gameId,
+ playerId,
+ name: player.name || playerId,
+ isAI: Boolean(player.type === 1 || player.type === 2 || player.type === 3 || player.type === 4),
+ destroyed: Boolean(player.destroyed),
+ };
+
+ players.set(key, {
+ ...existing,
+ name: existing.name || player.name || playerId,
+ destroyed: Boolean(player.destroyed),
+ points: player.points,
+ type: player.type,
+ });
+ }
+ }
+
+ return players;
+}
+
+function buildReport(gameDocs, commandDocs, eventDocs, options) {
+ const gamesById = new Map(gameDocs.map((game) => [toObjectIdString(game._id), game]));
+ const playerIndex = buildPlayerIndex(gameDocs);
+ const commandsByGame = groupBy(commandDocs, (command) => command.gameId);
+ const eventsByGame = groupBy(eventDocs, (event) => event.gameId);
+
+ const allGameIds = [...gamesById.keys()];
+ const completedGames = gameDocs.filter((game) => game.status === 'completed');
+ const activeGames = gameDocs.filter((game) => game.status !== 'completed');
+
+ const commandCountByType = new Map();
+ const eventCountByType = new Map();
+ const commandCountByPlayer = new Map();
+ const eventCountBySourcePlayer = new Map();
+ const commandFailureByType = new Map();
+ const commandFailureByPlayer = new Map();
+ const commandFailureByError = new Map();
+ const commandTypeByPlayer = new Map();
+ const gameCommandStats = [];
+ const gameEventStats = [];
+ const integrityIssues = [];
+
+ let totalSuccessCommands = 0;
+ let totalFailedCommands = 0;
+ let totalCommandsWithEvents = 0;
+ let totalCommandsWithoutEvents = 0;
+ let totalCommandEvents = 0;
+ let totalServerEvents = 0;
+
+ for (const gameId of allGameIds) {
+ const game = gamesById.get(gameId);
+ const commands = commandsByGame.get(gameId) || [];
+ const events = eventsByGame.get(gameId) || [];
+ const sequenceMap = new Map();
+
+ const commandSequenceNumbers = commands.map((command) => command.sequenceNumber).filter(Number.isFinite);
+ const eventSequenceNumbers = events.map((event) => event.sequenceNumber).filter(Number.isFinite);
+ const combinedSequenceNumbers = [...commandSequenceNumbers, ...eventSequenceNumbers].sort((a, b) => a - b);
+
+ for (const command of commands) {
+ const playerKey = `${gameId}:${command.playerId}`;
+ commandCountByType.set(command.commandType, (commandCountByType.get(command.commandType) || 0) + 1);
+ commandCountByPlayer.set(playerKey, (commandCountByPlayer.get(playerKey) || 0) + 1);
+ if (!commandTypeByPlayer.has(playerKey)) {
+ commandTypeByPlayer.set(playerKey, new Map());
+ }
+ const perPlayerType = commandTypeByPlayer.get(playerKey);
+ perPlayerType.set(command.commandType, (perPlayerType.get(command.commandType) || 0) + 1);
+
+ if (command.resultSuccess) {
+ totalSuccessCommands += 1;
+ } else {
+ totalFailedCommands += 1;
+ commandFailureByType.set(command.commandType, (commandFailureByType.get(command.commandType) || 0) + 1);
+ commandFailureByPlayer.set(playerKey, (commandFailureByPlayer.get(playerKey) || 0) + 1);
+ const failureKey = `${command.commandType}::${errorLabel(command)}`;
+ commandFailureByError.set(failureKey, (commandFailureByError.get(failureKey) || 0) + 1);
+ }
+
+ const eventCount = events.filter((event) => event.sourceCommandId === command.commandId).length;
+ totalCommandEvents += eventCount;
+ if (eventCount > 0) {
+ totalCommandsWithEvents += 1;
+ } else {
+ totalCommandsWithoutEvents += 1;
+ }
+
+ sequenceMap.set(command.sequenceNumber, (sequenceMap.get(command.sequenceNumber) || 0) + 1);
+ }
+
+ for (const event of events) {
+ eventCountByType.set(event.eventType, (eventCountByType.get(event.eventType) || 0) + 1);
+ eventCountBySourcePlayer.set(event.sourcePlayerId, (eventCountBySourcePlayer.get(event.sourcePlayerId) || 0) + 1);
+ if (event.sourcePlayerId === 'server') {
+ totalServerEvents += 1;
+ }
+
+ sequenceMap.set(event.sequenceNumber, (sequenceMap.get(event.sequenceNumber) || 0) + 1);
+ }
+
+ const minSequence = combinedSequenceNumbers.length > 0 ? combinedSequenceNumbers[0] : null;
+ const maxSequence = combinedSequenceNumbers.length > 0 ? combinedSequenceNumbers[combinedSequenceNumbers.length - 1] : null;
+ const expectedSequenceCount = minSequence == null || maxSequence == null ? 0 : maxSequence - minSequence + 1;
+ const uniqueSequenceCount = sequenceMap.size;
+ const duplicateSequenceNumbers = [...sequenceMap.entries()]
+ .filter(([, count]) => count > 1)
+ .map(([sequenceNumber, count]) => ({ sequenceNumber, count }));
+ const missingSequenceCount = Math.max(0, expectedSequenceCount - uniqueSequenceCount);
+
+ if (duplicateSequenceNumbers.length > 0 || missingSequenceCount > 0) {
+ integrityIssues.push({
+ gameId,
+ name: game?.name || gameId,
+ duplicateSequenceNumbers,
+ missingSequenceCount,
+ minSequence,
+ maxSequence,
+ });
+ }
+
+ const commandFailureCount = commands.filter((command) => !command.resultSuccess).length;
+ const eventCount = events.length;
+ gameCommandStats.push({
+ gameId,
+ name: game?.name || gameId,
+ status: game?.status || 'unknown',
+ commandCount: commands.length,
+ failureCount: commandFailureCount,
+ failureRate: commands.length > 0 ? commandFailureCount / commands.length : 0,
+ eventCount,
+ eventsPerCommand: commands.length > 0 ? eventCount / commands.length : 0,
+ eventFanOutPerSuccessfulCommand:
+ commands.filter((command) => command.resultSuccess).length > 0
+ ? eventCount / commands.filter((command) => command.resultSuccess).length
+ : 0,
+ currentCycle: game?.gameState?.currentCycle ?? null,
+ });
+
+ gameEventStats.push({
+ gameId,
+ name: game?.name || gameId,
+ eventCount,
+ serverEventCount: events.filter((event) => event.sourcePlayerId === 'server').length,
+ sourcePlayers: new Set(events.map((event) => event.sourcePlayerId)).size,
+ });
+ }
+
+ const commandTotals = commandDocs.length;
+ const eventTotals = eventDocs.length;
+
+ const playerSummaries = [];
+ for (const [playerKey, count] of commandCountByPlayer.entries()) {
+ const [gameId, playerId] = playerKey.split(':');
+ const playerMeta = playerIndex.get(playerKey) || { gameId, playerId, name: playerId, isAI: null, destroyed: null };
+ const byType = commandTypeByPlayer.get(playerKey) || new Map();
+ const failed = commandFailureByPlayer.get(playerKey) || 0;
+
+ playerSummaries.push({
+ gameId,
+ playerId,
+ playerName: playerMeta.name,
+ isAI: playerMeta.isAI,
+ destroyed: playerMeta.destroyed,
+ totalCommands: count,
+ failedCommands: failed,
+ failureRate: count > 0 ? failed / count : 0,
+ dominantCommands: [...byType.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([type, value]) => ({ type, count: value })),
+ });
+ }
+
+ const aiCommandCounts = playerSummaries.filter((summary) => summary.isAI === true).map((summary) => summary.totalCommands);
+ const humanCommandCounts = playerSummaries.filter((summary) => summary.isAI === false).map((summary) => summary.totalCommands);
+
+ const finalGameOutcomes = completedGames.map((game) => {
+ const players = Array.isArray(game.gameState?.players) ? game.gameState.players : [];
+ const survivingPlayers = players.filter((player) => !player.destroyed);
+ const sortedByPoints = [...players].sort((a, b) => (b.points || 0) - (a.points || 0));
+ const topPlayer = sortedByPoints[0] || null;
+ const survivingLeader = [...survivingPlayers].sort((a, b) => (b.points || 0) - (a.points || 0))[0] || null;
+
+ return {
+ gameId: toObjectIdString(game._id),
+ name: game.name,
+ currentCycle: game.gameState?.currentCycle ?? null,
+ playerCount: players.length,
+ survivingPlayers: survivingPlayers.length,
+ aiPlayers: (game.players || []).filter((player) => player.isAI).length,
+ humanPlayers: (game.players || []).filter((player) => !player.isAI).length,
+ topPlayer: topPlayer
+ ? {
+ id: topPlayer.id,
+ name: topPlayer.name,
+ points: topPlayer.points,
+ destroyed: Boolean(topPlayer.destroyed),
+ }
+ : null,
+ leadingAlivePlayer: survivingLeader
+ ? {
+ id: survivingLeader.id,
+ name: survivingLeader.name,
+ points: survivingLeader.points,
+ }
+ : null,
+ };
+ });
+
+ return {
+ generatedAt: new Date().toISOString(),
+ database: options.mongoUrl,
+ filters: {
+ gameId: options.gameId,
+ topN: options.topN,
+ },
+ overview: {
+ games: gameDocs.length,
+ completedGames: completedGames.length,
+ activeGames: activeGames.length,
+ commands: commandTotals,
+ events: eventTotals,
+ commandSuccessRate: commandTotals > 0 ? totalSuccessCommands / commandTotals : 0,
+ commandFailureRate: commandTotals > 0 ? totalFailedCommands / commandTotals : 0,
+ commandsWithEvents: totalCommandsWithEvents,
+ commandsWithoutEvents: totalCommandsWithoutEvents,
+ eventCoveragePerCommand: commandTotals > 0 ? totalCommandEvents / commandTotals : 0,
+ serverEvents: totalServerEvents,
+ },
+ integrity: {
+ duplicateSequenceGames: integrityIssues.length,
+ duplicateSequenceGamesSample: integrityIssues.slice(0, options.topN),
+ commandSequenceCollisions: integrityIssues.reduce((total, issue) => total + issue.duplicateSequenceNumbers.length, 0),
+ expectedSequenceContinuityGaps: integrityIssues.reduce((total, issue) => total + issue.missingSequenceCount, 0),
+ },
+ commandAnalysis: {
+ byType: Object.fromEntries([...commandCountByType.entries()].sort((a, b) => b[1] - a[1])),
+ byPlayer: Object.fromEntries([...commandCountByPlayer.entries()].sort((a, b) => b[1] - a[1])),
+ failuresByType: Object.fromEntries([...commandFailureByType.entries()].sort((a, b) => b[1] - a[1])),
+ failuresByError: Object.fromEntries([...commandFailureByError.entries()].sort((a, b) => b[1] - a[1])),
+ topFailedPlayers: playerSummaries
+ .filter((summary) => summary.failedCommands > 0)
+ .sort((a, b) => b.failedCommands - a.failedCommands)
+ .slice(0, options.topN),
+ playerSummaries: playerSummaries.sort((a, b) => b.totalCommands - a.totalCommands),
+ },
+ eventAnalysis: {
+ byType: Object.fromEntries([...eventCountByType.entries()].sort((a, b) => b[1] - a[1])),
+ bySourcePlayer: Object.fromEntries([...eventCountBySourcePlayer.entries()].sort((a, b) => b[1] - a[1])),
+ topEventTypes: topEntries(eventCountByType, options.topN).map(([type, count]) => ({ type, count })),
+ topSourcePlayers: topEntries(eventCountBySourcePlayer, options.topN).map(([sourcePlayerId, count]) => ({ sourcePlayerId, count })),
+ },
+ replayReadiness: {
+ sequenceIntegrityProblems: integrityIssues,
+ gamesWithSequenceProblems: integrityIssues.length,
+ commandsWithNoEvents: totalCommandsWithoutEvents,
+ commandsWithEvents: totalCommandsWithEvents,
+ commandsWithoutEventsShare: commandTotals > 0 ? totalCommandsWithoutEvents / commandTotals : 0,
+ },
+ strategySignals: {
+ completedGames: finalGameOutcomes,
+ aiCommandDistribution: {
+ averageCommandsPerPlayer: aiCommandCounts.length > 0 ? average(aiCommandCounts) : 0,
+ medianCommandsPerPlayer: aiCommandCounts.length > 0 ? median(aiCommandCounts) : 0,
+ p90CommandsPerPlayer: aiCommandCounts.length > 0 ? percentile(aiCommandCounts, 90) : 0,
+ },
+ humanCommandDistribution: {
+ averageCommandsPerPlayer: humanCommandCounts.length > 0 ? average(humanCommandCounts) : 0,
+ medianCommandsPerPlayer: humanCommandCounts.length > 0 ? median(humanCommandCounts) : 0,
+ p90CommandsPerPlayer: humanCommandCounts.length > 0 ? percentile(humanCommandCounts, 90) : 0,
+ },
+ topGamesByCommands: [...gameCommandStats].sort((a, b) => b.commandCount - a.commandCount).slice(0, options.topN),
+ highestFailureGames: [...gameCommandStats]
+ .filter((game) => game.failureCount > 0)
+ .sort((a, b) => b.failureRate - a.failureRate)
+ .slice(0, options.topN),
+ longestGames: [...gameCommandStats]
+ .filter((game) => game.currentCycle != null)
+ .sort((a, b) => (b.currentCycle || 0) - (a.currentCycle || 0))
+ .slice(0, options.topN),
+ },
+ datasetNotes: {
+ gamesWithMixedHumanAI: gameDocs.filter((game) => {
+ const players = Array.isArray(game.players) ? game.players : [];
+ const hasAI = players.some((player) => player.isAI);
+ const hasHuman = players.some((player) => !player.isAI);
+ return hasAI && hasHuman;
+ }).length,
+ completedGamesWithTopPlayerData: finalGameOutcomes.filter((game) => game.topPlayer != null).length,
+ commandStatsByGame: gameCommandStats,
+ eventStatsByGame: gameEventStats,
+ },
+ };
+}
+
+function buildMarkdown(report) {
+ const lines = [];
+
+ lines.push('# Astriarch Production History Analysis');
+ lines.push('');
+ lines.push(`Generated: ${report.generatedAt}`);
+ lines.push(`Database: ${report.database}`);
+ lines.push('');
+
+ lines.push('## Overview');
+ lines.push(`- Games: ${report.overview.games}`);
+ lines.push(`- Completed games: ${report.overview.completedGames}`);
+ lines.push(`- Active games: ${report.overview.activeGames}`);
+ lines.push(`- Commands: ${report.overview.commands}`);
+ lines.push(`- Events: ${report.overview.events}`);
+ lines.push(`- Command success rate: ${formatPercent(report.overview.commandSuccessRate)}`);
+ lines.push(`- Command failure rate: ${formatPercent(report.overview.commandFailureRate)}`);
+ lines.push(`- Commands with events: ${report.overview.commandsWithEvents}`);
+ lines.push(`- Commands without events: ${report.overview.commandsWithoutEvents}`);
+ lines.push(`- Event coverage per command: ${formatNumber(report.overview.eventCoveragePerCommand, 2)}`);
+ lines.push(`- Server-generated events: ${report.overview.serverEvents}`);
+ lines.push('');
+
+ lines.push('## Replay Readiness');
+ lines.push(`- Games with sequence problems: ${report.replayReadiness.gamesWithSequenceProblems}`);
+ lines.push(`- Commands without events share: ${formatPercent(report.replayReadiness.commandsWithoutEventsShare)}`);
+ lines.push(`- Sequence continuity gaps: ${report.integrity.expectedSequenceContinuityGaps}`);
+ lines.push(`- Sequence collisions: ${report.integrity.commandSequenceCollisions}`);
+ lines.push('');
+
+ lines.push('## Command Mix');
+ for (const [type, count] of Object.entries(report.commandAnalysis.byType).slice(0, 10)) {
+ lines.push(`- ${commandLabel(type)} (${type}): ${count}`);
+ }
+ lines.push('');
+
+ lines.push('## Command Failures');
+ const failureEntries = Object.entries(report.commandAnalysis.failuresByType);
+ if (failureEntries.length === 0) {
+ lines.push('- No command failures recorded');
+ } else {
+ for (const [type, count] of failureEntries.slice(0, 10)) {
+ lines.push(`- ${commandLabel(type)} (${type}): ${count}`);
+ }
+ }
+ lines.push('');
+
+ lines.push('## Event Mix');
+ for (const [type, count] of Object.entries(report.eventAnalysis.byType).slice(0, 10)) {
+ lines.push(`- ${type}: ${count}`);
+ }
+ lines.push('');
+
+ lines.push('## Strategy Signals');
+ lines.push(`- AI players: avg ${formatNumber(report.strategySignals.aiCommandDistribution.averageCommandsPerPlayer, 1)} commands/player, median ${formatNumber(report.strategySignals.aiCommandDistribution.medianCommandsPerPlayer, 1)}, p90 ${formatNumber(report.strategySignals.aiCommandDistribution.p90CommandsPerPlayer, 1)}`);
+ lines.push(`- Human players: avg ${formatNumber(report.strategySignals.humanCommandDistribution.averageCommandsPerPlayer, 1)} commands/player, median ${formatNumber(report.strategySignals.humanCommandDistribution.medianCommandsPerPlayer, 1)}, p90 ${formatNumber(report.strategySignals.humanCommandDistribution.p90CommandsPerPlayer, 1)}`);
+ lines.push(`- Games with both human and AI players: ${report.datasetNotes.gamesWithMixedHumanAI}`);
+ lines.push('');
+
+ lines.push('## High-Signal Games');
+ for (const game of report.strategySignals.highestFailureGames.slice(0, 5)) {
+ lines.push(`- ${game.name}: ${game.failureCount} failures, ${formatPercent(game.failureRate)} failure rate, ${game.commandCount} commands, ${game.eventCount} events`);
+ }
+ if (report.strategySignals.highestFailureGames.length === 0) {
+ lines.push('- No games with command failures in the selected scope');
+ }
+ lines.push('');
+
+ lines.push('## Completed Games');
+ for (const game of report.strategySignals.completedGames.slice(0, 5)) {
+ const topPlayer = game.topPlayer ? `${game.topPlayer.name} (${game.topPlayer.points ?? 'n/a'} pts)` : 'n/a';
+ lines.push(`- ${game.name}: cycle ${game.currentCycle ?? 'n/a'}, players ${game.playerCount}, surviving ${game.survivingPlayers}, top player ${topPlayer}`);
+ }
+ lines.push('');
+
+ if (report.replayReadiness.sequenceIntegrityProblems.length > 0) {
+ lines.push('## Sequence Issues');
+ for (const issue of report.replayReadiness.sequenceIntegrityProblems.slice(0, 5)) {
+ lines.push(`- ${issue.name}: ${issue.missingSequenceCount} missing, ${issue.duplicateSequenceNumbers.length} duplicate sequence numbers`);
+ }
+ lines.push('');
+ }
+
+ return lines.join('\n');
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ fs.mkdirSync(options.outputDir, { recursive: true });
+
+ await mongoose.connect(options.mongoUrl);
+
+ try {
+ const db = mongoose.connection.db;
+ const gameFilter = options.gameId ? { _id: options.gameId } : {};
+ const games = await db.collection('games').find(gameFilter).toArray();
+
+ if (games.length === 0) {
+ console.log(`No games matched filter ${options.gameId ? options.gameId : '(all games)'}.`);
+ return;
+ }
+
+ const gameIds = games.map((game) => toObjectIdString(game._id));
+ const commandDocs = await db.collection('gamecommandlogs').find({ gameId: { $in: gameIds } }).toArray();
+ const eventDocs = await db.collection('gameevents').find({ gameId: { $in: gameIds } }).toArray();
+
+ const report = buildReport(games, commandDocs, eventDocs, options);
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const scope = options.gameId ? `game-${options.gameId}` : 'all-games';
+ const jsonPath = path.join(options.outputDir, `${stamp}-${scope}.json`);
+ const markdownPath = path.join(options.outputDir, `${stamp}-${scope}.md`);
+
+ if (options.writeJson) {
+ fs.writeFileSync(jsonPath, JSON.stringify(report, null, options.pretty ? 2 : 0));
+ }
+
+ if (options.writeMarkdown) {
+ fs.writeFileSync(markdownPath, buildMarkdown(report));
+ }
+
+ console.log(buildMarkdown(report));
+ console.log('');
+ if (options.writeJson) {
+ console.log(`JSON report written to ${jsonPath}`);
+ }
+ if (options.writeMarkdown) {
+ console.log(`Markdown report written to ${markdownPath}`);
+ }
+ } finally {
+ await mongoose.disconnect();
+ }
+}
+
+main().catch((error) => {
+ console.error('[analyze-production-history] failed:', error);
+ process.exit(1);
+});
\ No newline at end of file
diff --git a/apps/astriarch-backend/scripts/export-replay-timeline.js b/apps/astriarch-backend/scripts/export-replay-timeline.js
new file mode 100644
index 00000000..1a6ec7a5
--- /dev/null
+++ b/apps/astriarch-backend/scripts/export-replay-timeline.js
@@ -0,0 +1,267 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+
+const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/astriarch_v2';
+const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), 'analysis', 'replay-timelines');
+
+function parseArgs(argv) {
+ const args = {
+ mongoUrl: process.env.MONGODB_CONNECTION_STRING || DEFAULT_MONGO_URL,
+ outputDir: process.env.ASTRIARCH_ANALYSIS_OUTPUT_DIR || DEFAULT_OUTPUT_DIR,
+ gameId: null,
+ topGamesFallback: 10,
+ };
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === '--mongo-url' && argv[i + 1]) {
+ args.mongoUrl = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--mongo-url=')) {
+ args.mongoUrl = arg.slice('--mongo-url='.length);
+ } else if (arg === '--output-dir' && argv[i + 1]) {
+ args.outputDir = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--output-dir=')) {
+ args.outputDir = arg.slice('--output-dir='.length);
+ } else if (arg === '--game-id' && argv[i + 1]) {
+ args.gameId = argv[i + 1];
+ i += 1;
+ } else if (arg.startsWith('--game-id=')) {
+ args.gameId = arg.slice('--game-id='.length);
+ } else if (arg === '--top-games-fallback' && argv[i + 1]) {
+ args.topGamesFallback = Number(argv[i + 1]) || args.topGamesFallback;
+ i += 1;
+ } else if (arg.startsWith('--top-games-fallback=')) {
+ args.topGamesFallback = Number(arg.slice('--top-games-fallback='.length)) || args.topGamesFallback;
+ }
+ }
+
+ return args;
+}
+
+function toId(value) {
+ if (value == null) return '';
+ return typeof value === 'string' ? value : String(value);
+}
+
+function buildIdCandidates(id) {
+ const candidates = [id];
+ if (typeof id === 'string' && mongoose.Types.ObjectId.isValid(id)) {
+ candidates.push(new mongoose.Types.ObjectId(id));
+ }
+ return candidates;
+}
+
+function bySeqThenKind(a, b) {
+ if (a.sequenceNumber !== b.sequenceNumber) {
+ return a.sequenceNumber - b.sequenceNumber;
+ }
+ if (a.kind === b.kind) return 0;
+ return a.kind === 'command' ? -1 : 1;
+}
+
+function percent(part, total) {
+ if (!total) return '0.0%';
+ return `${((part / total) * 100).toFixed(1)}%`;
+}
+
+function detectGaps(sequenceNumbers) {
+ if (sequenceNumbers.length === 0) {
+ return { min: null, max: null, missingCount: 0, duplicates: [] };
+ }
+ const sorted = [...sequenceNumbers].sort((a, b) => a - b);
+ const min = sorted[0];
+ const max = sorted[sorted.length - 1];
+ const seen = new Map();
+ for (const seq of sorted) {
+ seen.set(seq, (seen.get(seq) || 0) + 1);
+ }
+ const duplicates = [...seen.entries()].filter(([, count]) => count > 1).map(([sequenceNumber, count]) => ({ sequenceNumber, count }));
+ const missingCount = Math.max(0, max - min + 1 - seen.size);
+ return { min, max, missingCount, duplicates };
+}
+
+function summarizeTimeline(game, timelineEntries, commands, events) {
+ const integrity = detectGaps(timelineEntries.map((e) => e.sequenceNumber));
+ const commandSuccess = commands.filter((c) => c.resultSuccess).length;
+ const commandFailures = commands.length - commandSuccess;
+ const commandIdsWithEvents = new Set(events.filter((e) => e.sourceCommandId != null).map((e) => e.sourceCommandId));
+ const commandsWithoutEvents = commands.filter((c) => !commandIdsWithEvents.has(c.commandId)).length;
+ const serverEvents = events.filter((e) => e.sourcePlayerId === 'server').length;
+
+ return {
+ gameId: toId(game._id),
+ gameName: game.name || toId(game._id),
+ status: game.status || 'unknown',
+ currentCycle: game.gameState?.currentCycle ?? null,
+ commands: commands.length,
+ events: events.length,
+ commandSuccess,
+ commandFailures,
+ commandFailureRate: commands.length > 0 ? commandFailures / commands.length : 0,
+ commandsWithoutEvents,
+ commandsWithoutEventsRate: commands.length > 0 ? commandsWithoutEvents / commands.length : 0,
+ serverEvents,
+ sequence: integrity,
+ firstTimestamp: timelineEntries[0]?.timestamp || null,
+ lastTimestamp: timelineEntries[timelineEntries.length - 1]?.timestamp || null,
+ firstEntries: timelineEntries.slice(0, 20),
+ lastEntries: timelineEntries.slice(-20),
+ };
+}
+
+function buildMarkdown(summary) {
+ const lines = [];
+ lines.push('# Replay Timeline Export');
+ lines.push('');
+ lines.push(`Generated: ${new Date().toISOString()}`);
+ lines.push(`Game: ${summary.gameName} (${summary.gameId})`);
+ lines.push(`Status: ${summary.status}`);
+ lines.push('');
+
+ lines.push('## Summary');
+ lines.push(`- Current cycle: ${summary.currentCycle ?? 'n/a'}`);
+ lines.push(`- Commands: ${summary.commands}`);
+ lines.push(`- Events: ${summary.events}`);
+ lines.push(`- Successful commands: ${summary.commandSuccess}`);
+ lines.push(`- Failed commands: ${summary.commandFailures} (${percent(summary.commandFailures, summary.commands)})`);
+ lines.push(`- Commands without events: ${summary.commandsWithoutEvents} (${percent(summary.commandsWithoutEvents, summary.commands)})`);
+ lines.push(`- Server events: ${summary.serverEvents}`);
+ lines.push(`- Sequence min/max: ${summary.sequence.min ?? 'n/a'} / ${summary.sequence.max ?? 'n/a'}`);
+ lines.push(`- Sequence gaps: ${summary.sequence.missingCount}`);
+ lines.push(`- Sequence duplicates: ${summary.sequence.duplicates.length}`);
+ lines.push('');
+
+ lines.push('## First Timeline Entries');
+ for (const entry of summary.firstEntries) {
+ lines.push(`- seq ${entry.sequenceNumber}: ${entry.kind} ${entry.type} (player ${entry.playerId || entry.sourcePlayerId || 'n/a'})`);
+ }
+ lines.push('');
+
+ lines.push('## Last Timeline Entries');
+ for (const entry of summary.lastEntries) {
+ lines.push(`- seq ${entry.sequenceNumber}: ${entry.kind} ${entry.type} (player ${entry.playerId || entry.sourcePlayerId || 'n/a'})`);
+ }
+ lines.push('');
+
+ return lines.join('\n');
+}
+
+async function chooseGameId(db, args) {
+ if (args.gameId) {
+ return args.gameId;
+ }
+
+ const topGames = await db.collection('gamecommandlogs').aggregate([
+ { $group: { _id: '$gameId', commandCount: { $sum: 1 } } },
+ { $sort: { commandCount: -1 } },
+ { $limit: args.topGamesFallback },
+ ]).toArray();
+
+ if (topGames.length === 0) {
+ return null;
+ }
+
+ const ids = topGames.map((g) => toId(g._id));
+ const idCandidates = ids.flatMap((id) => buildIdCandidates(id));
+ const existingGames = await db.collection('games').find({ _id: { $in: idCandidates } }, { projection: { _id: 1 } }).toArray();
+ const existingSet = new Set(existingGames.map((g) => toId(g._id)));
+ const preferred = topGames.find((g) => existingSet.has(toId(g._id)));
+
+ return toId(preferred?._id || topGames[0]._id);
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ fs.mkdirSync(args.outputDir, { recursive: true });
+
+ await mongoose.connect(args.mongoUrl);
+ try {
+ const db = mongoose.connection.db;
+ const gameId = await chooseGameId(db, args);
+ if (!gameId) {
+ console.log('No gamecommandlogs found; cannot export timeline.');
+ return;
+ }
+
+ const gameDoc = await db.collection('games').findOne({ _id: { $in: buildIdCandidates(gameId) } });
+
+ const commands = await db.collection('gamecommandlogs').find({ gameId }).sort({ sequenceNumber: 1 }).toArray();
+ const events = await db.collection('gameevents').find({ gameId }).sort({ sequenceNumber: 1 }).toArray();
+
+ const timelineEntries = [
+ ...commands.map((c) => ({
+ kind: 'command',
+ sequenceNumber: c.sequenceNumber,
+ timestamp: c.timestamp,
+ gameCycle: c.gameCycle,
+ commandId: c.commandId,
+ playerId: c.playerId,
+ type: c.commandType,
+ resultSuccess: c.resultSuccess,
+ errorCode: c.errorCode,
+ errorMessage: c.errorMessage,
+ payload: c.command,
+ })),
+ ...events.map((e) => ({
+ kind: 'event',
+ sequenceNumber: e.sequenceNumber,
+ timestamp: e.timestamp,
+ gameCycle: e.gameCycle,
+ eventId: e.eventId,
+ sourcePlayerId: e.sourcePlayerId,
+ sourceCommandId: e.sourceCommandId,
+ type: e.eventType,
+ affectedPlayerIds: e.affectedPlayerIds,
+ payload: e.payload,
+ })),
+ ].sort(bySeqThenKind);
+
+ const game =
+ gameDoc ||
+ {
+ _id: gameId,
+ name: '(orphaned logs)',
+ status: 'unknown',
+ gameState: {
+ currentCycle: null,
+ },
+ };
+
+ const summary = summarizeTimeline(game, timelineEntries, commands, events);
+ const report = {
+ generatedAt: new Date().toISOString(),
+ database: args.mongoUrl,
+ gameId,
+ summary,
+ timeline: timelineEntries,
+ };
+
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const baseName = `${stamp}-game-${gameId}`;
+ const jsonPath = path.join(args.outputDir, `${baseName}.timeline.json`);
+ const ndjsonPath = path.join(args.outputDir, `${baseName}.timeline.ndjson`);
+ const mdPath = path.join(args.outputDir, `${baseName}.summary.md`);
+
+ fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
+ fs.writeFileSync(ndjsonPath, timelineEntries.map((entry) => JSON.stringify(entry)).join('\n'));
+ fs.writeFileSync(mdPath, buildMarkdown(summary));
+
+ console.log(buildMarkdown(summary));
+ console.log('');
+ console.log(`Timeline JSON written to ${jsonPath}`);
+ console.log(`Timeline NDJSON written to ${ndjsonPath}`);
+ console.log(`Summary markdown written to ${mdPath}`);
+ } finally {
+ await mongoose.disconnect();
+ }
+}
+
+main().catch((error) => {
+ console.error('[export-replay-timeline] failed:', error);
+ process.exit(1);
+});
diff --git a/apps/astriarch-backend/src/app.ts b/apps/astriarch-backend/src/app.ts
index 4ea9d9e1..6e3abbb9 100644
--- a/apps/astriarch-backend/src/app.ts
+++ b/apps/astriarch-backend/src/app.ts
@@ -7,9 +7,9 @@ import morgan from "morgan";
import cookieParser from "cookie-parser";
import session from "express-session";
import MongoStore from "connect-mongo";
-import config from "config";
import { connectDatabase } from "./database/connection";
+import { getBackendConfig, validateBackendConfig } from "./config/environment";
import { WebSocketServer } from "./websocket";
import { healthRoutes } from "./routes/healthRoutes";
import { highScoreRoutes } from "./routes/highScoreRoutes";
@@ -19,12 +19,14 @@ import { logger } from "./utils/logger";
const app = express();
+const backendConfig = getBackendConfig();
+
// Server configuration
const serverConfig = {
- host: process.env.HOST || config.get("server.host") || "localhost",
- port: process.env.PORT || config.get("server.port") || 8001,
- wsPort: process.env.WS_PORT || config.get("websocket.port") || 8001,
- wsProtocol: process.env.WS_PROTOCOL || config.get("websocket.protocol") || "ws",
+ host: backendConfig.server.host,
+ port: backendConfig.server.port,
+ wsPort: backendConfig.websocket.port,
+ wsProtocol: backendConfig.websocket.protocol,
};
// Security middleware
@@ -35,16 +37,9 @@ app.use(
);
// CORS configuration
-const baseCorsOptions = config.get("cors") as cors.CorsOptions;
-const allowedOrigins = (process.env.CORS_ORIGIN_LIST || "")
- .split(",")
- .map((origin) => origin.trim())
- .filter((origin) => origin);
-
-// Create a new CORS options object without mutating the config
const corsOptions: cors.CorsOptions = {
- ...baseCorsOptions,
- ...(allowedOrigins.length > 0 && { origin: allowedOrigins }),
+ origin: backendConfig.cors.origin,
+ credentials: backendConfig.cors.credentials,
};
app.use(cors(corsOptions));
@@ -56,17 +51,28 @@ app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// Cookie parsing
-const cookieSecret = process.env.COOKIE_SECRET || (config.get("cookie.secret") as string);
+const cookieSecret = backendConfig.cookie.secret;
app.use(cookieParser(cookieSecret));
+function buildSessionMongoUrl(): string {
+ if (backendConfig.mongodb.connectionString) {
+ return backendConfig.mongodb.connectionString;
+ }
+
+ const auth =
+ backendConfig.mongodb.username && backendConfig.mongodb.password
+ ? `${backendConfig.mongodb.username}:${backendConfig.mongodb.password}@`
+ : "";
+
+ return `mongodb://${auth}${backendConfig.mongodb.host}:${backendConfig.mongodb.port}/${backendConfig.mongodb.sessionDbName}`;
+}
+
// Session configuration
app.use(
session({
secret: cookieSecret,
store: MongoStore.create({
- mongoUrl:
- process.env.MONGODB_CONNECTION_STRING ||
- `mongodb://${config.get("mongodb.host")}:${config.get("mongodb.port")}/${config.get("mongodb.sessiondb_name")}`,
+ mongoUrl: buildSessionMongoUrl(),
touchAfter: 24 * 3600, // lazy session update
}),
resave: false,
@@ -105,6 +111,9 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express.
// Start server
async function startServer() {
try {
+ // Validate env-backed configuration before startup side-effects.
+ validateBackendConfig();
+
// Connect to database
await connectDatabase();
diff --git a/apps/astriarch-backend/src/config/environment.spec.ts b/apps/astriarch-backend/src/config/environment.spec.ts
new file mode 100644
index 00000000..2025a6f0
--- /dev/null
+++ b/apps/astriarch-backend/src/config/environment.spec.ts
@@ -0,0 +1,105 @@
+import { getBackendConfig, resetBackendConfigCacheForTests, validateBackendConfig } from "./environment";
+
+describe("backend environment config", () => {
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ resetBackendConfigCacheForTests();
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ resetBackendConfigCacheForTests();
+ });
+
+ it("uses expected defaults when optional env vars are missing", () => {
+ delete process.env.NODE_ENV;
+ delete process.env.LOGLEVEL;
+ delete process.env.HOST;
+ delete process.env.PORT;
+ delete process.env.WS_PORT;
+ delete process.env.WS_PROTOCOL;
+ delete process.env.WS_PING_FREQUENCY_MS;
+ delete process.env.CORS_ORIGIN_LIST;
+ delete process.env.CORS_CREDENTIALS;
+ delete process.env.GAME_CLEANUP_ENABLED;
+ delete process.env.GAME_CLEANUP_INTERVAL_SECONDS;
+ delete process.env.GAME_CLEANUP_MAX_AGE_HOURS;
+ delete process.env.MONGODB_DATABASE;
+ delete process.env.SESSION_DATABASE;
+ delete process.env.MONGODB_CONNECTION_STRING;
+
+ const config = getBackendConfig();
+
+ expect(config.nodeEnv).toBe("development");
+ expect(config.loglevel).toBe("INFO");
+ expect(config.server).toEqual({ host: "localhost", port: 8001 });
+ expect(config.websocket).toEqual({
+ port: 8001,
+ protocol: "ws",
+ pingFrequencyMs: 30000,
+ });
+ expect(config.cors.origin).toEqual(["http://localhost:5173", "http://localhost:3000"]);
+ expect(config.cors.credentials).toBe(true);
+ expect(config.mongodb.gameDbName).toBe("astriarch_v2_dev");
+ expect(config.mongodb.sessionDbName).toBe("astriarch_v2_dev");
+ expect(config.game.cleanupOldGames).toEqual({
+ enabled: true,
+ checkIntervalSeconds: 7200,
+ maxAgeHours: 24,
+ });
+ });
+
+ it("parses and applies env overrides", () => {
+ process.env.NODE_ENV = "test";
+ process.env.LOGLEVEL = "debug";
+ process.env.HOST = "0.0.0.0";
+ process.env.PORT = "9001";
+ process.env.WS_PORT = "9002";
+ process.env.WS_PROTOCOL = "wss";
+ process.env.WS_PING_FREQUENCY_MS = "15000";
+ process.env.CORS_ORIGIN_LIST = "https://one.example.com, https://two.example.com";
+ process.env.CORS_CREDENTIALS = "false";
+ process.env.MONGODB_CONNECTION_STRING = "mongodb://mongo:27017/custom";
+ process.env.MONGODB_DATABASE = "custom_game";
+ process.env.SESSION_DATABASE = "custom_session";
+ process.env.GAME_CLEANUP_ENABLED = "0";
+ process.env.GAME_CLEANUP_INTERVAL_SECONDS = "120";
+ process.env.GAME_CLEANUP_MAX_AGE_HOURS = "48";
+
+ const config = getBackendConfig();
+
+ expect(config.nodeEnv).toBe("test");
+ expect(config.loglevel).toBe("DEBUG");
+ expect(config.server).toEqual({ host: "0.0.0.0", port: 9001 });
+ expect(config.websocket).toEqual({
+ port: 9002,
+ protocol: "wss",
+ pingFrequencyMs: 15000,
+ });
+ expect(config.cors.origin).toEqual(["https://one.example.com", "https://two.example.com"]);
+ expect(config.cors.credentials).toBe(false);
+ expect(config.mongodb.connectionString).toBe("mongodb://mongo:27017/custom");
+ expect(config.mongodb.gameDbName).toBe("custom_game");
+ expect(config.mongodb.sessionDbName).toBe("custom_session");
+ expect(config.game.cleanupOldGames).toEqual({
+ enabled: false,
+ checkIntervalSeconds: 120,
+ maxAgeHours: 48,
+ });
+ });
+
+ it("throws on invalid integer env values", () => {
+ process.env.PORT = "not-a-number";
+
+ expect(() => getBackendConfig()).toThrow("Invalid integer for PORT");
+ });
+
+ it("fails production validation when COOKIE_SECRET is missing", () => {
+ process.env.NODE_ENV = "production";
+ delete process.env.COOKIE_SECRET;
+
+ expect(() => validateBackendConfig()).toThrow("Missing required environment variable in production: COOKIE_SECRET");
+ });
+});
diff --git a/apps/astriarch-backend/src/config/environment.ts b/apps/astriarch-backend/src/config/environment.ts
new file mode 100644
index 00000000..c7ef938c
--- /dev/null
+++ b/apps/astriarch-backend/src/config/environment.ts
@@ -0,0 +1,189 @@
+export type NodeEnv = "development" | "production" | "test";
+export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
+export type WsProtocol = "ws" | "wss";
+
+export interface BackendConfig {
+ nodeEnv: NodeEnv;
+ loglevel: LogLevel;
+ mongodb: {
+ connectionString?: string;
+ host: string;
+ port: number;
+ username?: string;
+ password?: string;
+ gameDbName: string;
+ sessionDbName: string;
+ };
+ cookie: {
+ secret: string;
+ };
+ server: {
+ host: string;
+ port: number;
+ };
+ websocket: {
+ port: number;
+ protocol: WsProtocol;
+ pingFrequencyMs: number;
+ };
+ cors: {
+ origin: string[];
+ credentials: boolean;
+ };
+ game: {
+ cleanupOldGames: {
+ enabled: boolean;
+ checkIntervalSeconds: number;
+ maxAgeHours: number;
+ };
+ };
+}
+
+const DEFAULT_CORS_ORIGINS = ["http://localhost:5173", "http://localhost:3000"];
+const DEFAULT_COOKIE_SECRET = "astriarch-v2-node-secret";
+
+let cachedConfig: BackendConfig | null = null;
+
+function parseInteger(value: string | undefined, key: string, fallback: number): number {
+ if (!value || value.trim() === "") {
+ return fallback;
+ }
+
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) {
+ throw new Error(`Invalid integer for ${key}: ${value}`);
+ }
+
+ return parsed;
+}
+
+function parseBoolean(value: string | undefined, fallback: boolean): boolean {
+ if (!value || value.trim() === "") {
+ return fallback;
+ }
+
+ const normalized = value.trim().toLowerCase();
+ if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
+ return true;
+ }
+
+ if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
+ return false;
+ }
+
+ throw new Error(`Invalid boolean value: ${value}`);
+}
+
+function parseCsvList(value: string | undefined, fallback: string[]): string[] {
+ if (!value || value.trim() === "") {
+ return fallback;
+ }
+
+ const items = value
+ .split(",")
+ .map((item) => item.trim())
+ .filter((item) => item.length > 0);
+
+ return items.length > 0 ? items : fallback;
+}
+
+function normalizeNodeEnv(value: string | undefined): NodeEnv {
+ if (value === "production" || value === "test") {
+ return value;
+ }
+ return "development";
+}
+
+function normalizeLogLevel(value: string | undefined): LogLevel {
+ const normalized = (value || "").toUpperCase();
+ if (normalized === "DEBUG" || normalized === "INFO" || normalized === "WARN" || normalized === "ERROR") {
+ return normalized;
+ }
+
+ return "INFO";
+}
+
+function normalizeWsProtocol(value: string | undefined): WsProtocol {
+ const normalized = (value || "").toLowerCase();
+ return normalized === "wss" ? "wss" : "ws";
+}
+
+function buildBackendConfig(): BackendConfig {
+ const nodeEnv = normalizeNodeEnv(process.env.NODE_ENV);
+ const defaultDbName = nodeEnv === "development" ? "astriarch_v2_dev" : "astriarch_v2";
+
+ return {
+ nodeEnv,
+ loglevel: normalizeLogLevel(process.env.LOGLEVEL),
+ mongodb: {
+ connectionString: process.env.MONGODB_CONNECTION_STRING || undefined,
+ host: process.env.MONGODB_HOST || "127.0.0.1",
+ port: parseInteger(process.env.MONGODB_PORT, "MONGODB_PORT", 27017),
+ username: process.env.MONGODB_USERNAME || undefined,
+ password: process.env.MONGODB_PASSWORD || undefined,
+ gameDbName: process.env.MONGODB_DATABASE || defaultDbName,
+ sessionDbName: process.env.SESSION_DATABASE || defaultDbName,
+ },
+ cookie: {
+ secret: process.env.COOKIE_SECRET || DEFAULT_COOKIE_SECRET,
+ },
+ server: {
+ host: process.env.HOST || "localhost",
+ port: parseInteger(process.env.PORT, "PORT", 8001),
+ },
+ websocket: {
+ port: parseInteger(process.env.WS_PORT, "WS_PORT", 8001),
+ protocol: normalizeWsProtocol(process.env.WS_PROTOCOL),
+ pingFrequencyMs: parseInteger(process.env.WS_PING_FREQUENCY_MS, "WS_PING_FREQUENCY_MS", 30000),
+ },
+ cors: {
+ origin: parseCsvList(process.env.CORS_ORIGIN_LIST, DEFAULT_CORS_ORIGINS),
+ credentials: parseBoolean(process.env.CORS_CREDENTIALS, true),
+ },
+ game: {
+ cleanupOldGames: {
+ enabled: parseBoolean(process.env.GAME_CLEANUP_ENABLED, true),
+ checkIntervalSeconds: parseInteger(
+ process.env.GAME_CLEANUP_INTERVAL_SECONDS,
+ "GAME_CLEANUP_INTERVAL_SECONDS",
+ 7200,
+ ),
+ maxAgeHours: parseInteger(process.env.GAME_CLEANUP_MAX_AGE_HOURS, "GAME_CLEANUP_MAX_AGE_HOURS", 24),
+ },
+ },
+ };
+}
+
+export function getBackendConfig(): BackendConfig {
+ if (!cachedConfig) {
+ cachedConfig = buildBackendConfig();
+ }
+ return cachedConfig;
+}
+
+function validateRequiredConfig(config: BackendConfig): void {
+ if (config.nodeEnv === "production") {
+ if (!process.env.COOKIE_SECRET || process.env.COOKIE_SECRET.trim() === "") {
+ throw new Error("Missing required environment variable in production: COOKIE_SECRET");
+ }
+
+ if (
+ (!config.mongodb.connectionString || config.mongodb.connectionString.trim() === "") &&
+ (!config.mongodb.host || !config.mongodb.gameDbName)
+ ) {
+ throw new Error(
+ "MongoDB configuration is incomplete. Set MONGODB_CONNECTION_STRING or provide MONGODB_HOST and MONGODB_DATABASE",
+ );
+ }
+ }
+}
+
+export function validateBackendConfig(): BackendConfig {
+ const config = getBackendConfig();
+ validateRequiredConfig(config);
+ return config;
+}
+
+export function resetBackendConfigCacheForTests(): void {
+ cachedConfig = null;
+}
diff --git a/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts b/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts
index 87ab5ae1..b998f5b5 100644
--- a/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts
+++ b/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts
@@ -1,6 +1,9 @@
-import config from "config";
import { ServerGameModel, IGame, IPlayer } from "../models/Game";
import { SessionModel } from "../models/Session";
+import { GameEvent } from "../models/GameEvent";
+import { GameCommandLog } from "../models/GameCommandLog";
+import { SequenceCounter } from "../models/SequenceCounter";
+import { getBackendConfig } from "../config/environment";
import { logger } from "../utils/logger";
import { persistGame, saveGameWithConcurrencyProtection } from "../database/DocumentPersistence";
import { eventPersistenceService } from "../services/EventPersistenceService";
@@ -936,17 +939,39 @@ export class GameController {
static async cleanupOldGames(): Promise {
try {
- const cleanupConfig = config.get("game.cleanup") as any;
- const maxAge = cleanupConfig?.max_age_hours || 24;
+ const cleanupConfig = getBackendConfig().game.cleanupOldGames;
+ const maxAge = cleanupConfig.maxAgeHours;
const cutoffDate = new Date(Date.now() - maxAge * 60 * 60 * 1000);
- const result = await ServerGameModel.deleteMany({
- lastActivity: { $lt: cutoffDate },
- status: { $ne: "in_progress" },
- });
+ // Find stale, non-active games first so we can cascade delete related event-sourcing data.
+ const staleGames = await ServerGameModel.find(
+ {
+ lastActivity: { $lt: cutoffDate },
+ status: { $ne: "in_progress" },
+ },
+ { _id: 1 },
+ ).lean();
- if (result.deletedCount > 0) {
- logger.info(`Cleaned up ${result.deletedCount} old games`);
+ const staleGameIds = staleGames.map((g) => g._id.toString());
+
+ if (staleGameIds.length > 0) {
+ const sequenceCounterIds = staleGameIds.map((id) => `game:${id}`);
+
+ const [deleteGameResult, deleteEventResult, deleteCommandResult, deleteSequenceCounterResult] =
+ await Promise.all([
+ ServerGameModel.deleteMany({ _id: { $in: staleGameIds } }),
+ GameEvent.deleteMany({ gameId: { $in: staleGameIds } }),
+ GameCommandLog.deleteMany({ gameId: { $in: staleGameIds } }),
+ SequenceCounter.deleteMany({ _id: { $in: sequenceCounterIds } }),
+ ]);
+
+ logger.info(
+ `Cleaned up ${deleteGameResult.deletedCount} old games and related data (events=${deleteEventResult.deletedCount}, commands=${deleteCommandResult.deletedCount}, sequenceCounters=${deleteSequenceCounterResult.deletedCount})`,
+ );
+ }
+
+ if (staleGameIds.length === 0) {
+ logger.debug("No old games found for cleanup");
}
} catch (error) {
logger.error("Error during game cleanup:", error);
@@ -955,15 +980,12 @@ export class GameController {
static startGameCleanup(): void {
try {
- const cleanupConfig = config.get("game.cleanup") as any;
+ const cleanupConfig = getBackendConfig().game.cleanupOldGames;
- if (cleanupConfig && cleanupConfig.enabled) {
- setInterval(
- async () => {
- await GameController.cleanupOldGames();
- },
- (cleanupConfig.check_interval_seconds || 3600) * 1000,
- );
+ if (cleanupConfig.enabled) {
+ setInterval(async () => {
+ await GameController.cleanupOldGames();
+ }, cleanupConfig.checkIntervalSeconds * 1000);
logger.info("Game cleanup scheduler started");
}
diff --git a/apps/astriarch-backend/src/database/connection.ts b/apps/astriarch-backend/src/database/connection.ts
index ab1d21c2..25aff0a5 100644
--- a/apps/astriarch-backend/src/database/connection.ts
+++ b/apps/astriarch-backend/src/database/connection.ts
@@ -1,22 +1,20 @@
import mongoose from "mongoose";
-import config from "config";
+import { getBackendConfig } from "../config/environment";
import { logger } from "../utils/logger";
export async function connectDatabase(): Promise {
let connectionString = "";
try {
+ const backendConfig = getBackendConfig();
+
// Build connection string
- const username = process.env.MONGODB_USERNAME || config.get("mongodb.username");
- const password = process.env.MONGODB_PASSWORD || config.get("mongodb.password");
- const host = process.env.MONGODB_HOST || config.get("mongodb.host");
- const port = process.env.MONGODB_PORT || config.get("mongodb.port");
- const database = process.env.MONGODB_DATABASE || config.get("mongodb.gamedb_name");
+ const { username, password, host, port, gameDbName } = backendConfig.mongodb;
- connectionString = process.env.MONGODB_CONNECTION_STRING || "";
+ connectionString = backendConfig.mongodb.connectionString || "";
if (!connectionString) {
const auth = username && password ? `${username}:${password}@` : "";
- connectionString = `mongodb://${auth}${host}:${port}/${database}`;
+ connectionString = `mongodb://${auth}${host}:${port}/${gameDbName}`;
}
logger.info(
@@ -32,7 +30,7 @@ export async function connectDatabase(): Promise {
await mongoose.connect(connectionString, options);
- logger.info(`Connected to MongoDB: ${database}`);
+ logger.info(`Connected to MongoDB: ${gameDbName}`);
// Connection event handlers
mongoose.connection.on("error", (error) => {
diff --git a/apps/astriarch-backend/src/utils/logger.ts b/apps/astriarch-backend/src/utils/logger.ts
index 69c4a63a..f1c526c6 100644
--- a/apps/astriarch-backend/src/utils/logger.ts
+++ b/apps/astriarch-backend/src/utils/logger.ts
@@ -1,12 +1,12 @@
-import config from "config";
+import { getBackendConfig, type LogLevel } from "../config/environment";
-type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
+const DEFAULT_LOG_LEVEL: LogLevel = "INFO";
class Logger {
private logLevel: LogLevel;
constructor() {
- this.logLevel = (config.get("loglevel") as LogLevel) || "INFO";
+ this.logLevel = getBackendConfig().loglevel || DEFAULT_LOG_LEVEL;
}
private shouldLog(level: LogLevel): boolean {
diff --git a/apps/astriarch-backend/src/websocket/WebSocketServer.ts b/apps/astriarch-backend/src/websocket/WebSocketServer.ts
index 68fa6e74..9d7b249e 100644
--- a/apps/astriarch-backend/src/websocket/WebSocketServer.ts
+++ b/apps/astriarch-backend/src/websocket/WebSocketServer.ts
@@ -2,7 +2,7 @@ import WebSocket from "ws";
import { Server } from "http";
import { parse as parseCookie } from "cookie";
import * as signature from "cookie-signature";
-import config from "config";
+import { getBackendConfig } from "../config/environment";
import { logger } from "../utils/logger";
import { Session, Game, IGame } from "../models";
import { ServerGameModel } from "../models/Game";
@@ -122,7 +122,7 @@ export class WebSocketServer {
if (req.headers.cookie) {
logger.info("Raw cookies received:", req.headers.cookie);
- const cookieSecret = process.env.COOKIE_SECRET || (config.get("cookie.secret") as string);
+ const cookieSecret = getBackendConfig().cookie.secret;
// Parse the cookies first
const cookies = parseCookie(req.headers.cookie);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4b1cbd4b..671ad225 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,9 +21,6 @@ importers:
async:
specifier: ^3.2.4
version: 3.2.6
- config:
- specifier: ^3.3.9
- version: 3.3.12
connect-mongo:
specifier: ^5.1.0
version: 5.1.0(express-session@1.18.1)(mongodb@6.17.0)
@@ -67,9 +64,6 @@ importers:
'@types/async':
specifier: ^3.2.24
version: 3.2.24
- '@types/config':
- specifier: ^3.3.3
- version: 3.3.5
'@types/cookie':
specifier: ^1.0.0
version: 1.0.0
@@ -1979,9 +1973,6 @@ packages:
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
- '@types/config@3.3.5':
- resolution: {integrity: sha512-itq2HtXQBrNUKwMNZnb9mBRE3T99VYCdl1gjST9rq+9kFaB1iMMGuDeZnP88qid73DnpAMKH9ZolqDpS1Lz7+w==}
-
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -2681,10 +2672,6 @@ packages:
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
- config@3.3.12:
- resolution: {integrity: sha512-Vmx389R/QVM3foxqBzXO8t2tUikYZP64Q6vQxGrsMpREeJc/aWRnPRERXWsYzOHAumx/AOoILWe6nU3ZJL+6Sw==}
- engines: {node: '>= 10.0.0'}
-
connect-mongo@5.1.0:
resolution: {integrity: sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==}
engines: {node: '>=12.9.0'}
@@ -6906,8 +6893,6 @@ snapshots:
dependencies:
'@types/deep-eql': 4.0.2
- '@types/config@3.3.5': {}
-
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.0.10
@@ -7690,10 +7675,6 @@ snapshots:
confbox@0.1.8: {}
- config@3.3.12:
- dependencies:
- json5: 2.2.3
-
connect-mongo@5.1.0(express-session@1.18.1)(mongodb@6.17.0):
dependencies:
debug: 4.4.1