From 727c7918673ac9bb350ad87ed782b50a857f670b Mon Sep 17 00:00:00 2001 From: Matt Palmerlee Date: Wed, 1 Jul 2026 20:23:06 -0700 Subject: [PATCH 1/4] adding game analysis scripts --- .gitignore | 1 + README.md | 38 +- apps/astriarch-backend/package.json | 5 + .../scripts/analyze-ai-human-strategy.js | 387 ++++++++++++ .../scripts/analyze-production-failures.js | 330 ++++++++++ .../scripts/analyze-production-history.js | 594 ++++++++++++++++++ .../scripts/export-replay-timeline.js | 267 ++++++++ .../controllers/GameControllerWebSocket.ts | 41 +- 8 files changed, 1643 insertions(+), 20 deletions(-) create mode 100644 apps/astriarch-backend/scripts/analyze-ai-human-strategy.js create mode 100644 apps/astriarch-backend/scripts/analyze-production-failures.js create mode 100644 apps/astriarch-backend/scripts/analyze-production-history.js create mode 100644 apps/astriarch-backend/scripts/export-replay-timeline.js diff --git a/.gitignore b/.gitignore index 1023ee8..ba81c5d 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 a1ee6ad..6d24d2b 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, 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 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) and ruler (arkhos) + + ## Quickstart `docker-compose up` @@ -47,20 +65,16 @@ 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 - -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 +## DevOps Notes -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 diff --git a/apps/astriarch-backend/package.json b/apps/astriarch-backend/package.json index 992cb9d..4f1768e 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", 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 0000000..a9a85e0 --- /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 0000000..62251e6 --- /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 0000000..c2b7903 --- /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 0000000..1a6ec7a --- /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/controllers/GameControllerWebSocket.ts b/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts index 87ab5ae..9fc0f7f 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 { 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 cleanupConfig = config.get("game.cleanup_old_games") as any; const maxAge = cleanupConfig?.max_age_hours || 24; 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,7 +980,7 @@ export class GameController { static startGameCleanup(): void { try { - const cleanupConfig = config.get("game.cleanup") as any; + const cleanupConfig = config.get("game.cleanup_old_games") as any; if (cleanupConfig && cleanupConfig.enabled) { setInterval( From 19a85f2376b7a9b78213d712790c4d1abcb4c569 Mon Sep 17 00:00:00 2001 From: Matt Palmerlee Date: Wed, 1 Jul 2026 20:36:55 -0700 Subject: [PATCH 2/4] remove config dependency in favor of typed config --- apps/astriarch-backend/.env.example | 9 + apps/astriarch-backend/config/default.json | 34 ---- .../astriarch-backend/config/development.json | 10 - apps/astriarch-backend/package.json | 2 - apps/astriarch-backend/src/app.ts | 45 +++-- .../src/config/environment.ts | 185 ++++++++++++++++++ .../controllers/GameControllerWebSocket.ts | 12 +- .../src/database/connection.ts | 16 +- apps/astriarch-backend/src/utils/logger.ts | 6 +- .../src/websocket/WebSocketServer.ts | 4 +- pnpm-lock.yaml | 19 -- 11 files changed, 239 insertions(+), 103 deletions(-) delete mode 100644 apps/astriarch-backend/config/default.json delete mode 100644 apps/astriarch-backend/config/development.json create mode 100644 apps/astriarch-backend/src/config/environment.ts diff --git a/apps/astriarch-backend/.env.example b/apps/astriarch-backend/.env.example index a9667d3..c777263 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 3da2cd2..0000000 --- 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 2dd0285..0000000 --- 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 4f1768e..64143e7 100644 --- a/apps/astriarch-backend/package.json +++ b/apps/astriarch-backend/package.json @@ -24,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", @@ -41,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/src/app.ts b/apps/astriarch-backend/src/app.ts index 4ea9d9e..6e3abbb 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.ts b/apps/astriarch-backend/src/config/environment.ts new file mode 100644 index 0000000..e97283a --- /dev/null +++ b/apps/astriarch-backend/src/config/environment.ts @@ -0,0 +1,185 @@ +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 9fc0f7f..bdbb1be 100644 --- a/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts +++ b/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts @@ -1,9 +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"; @@ -939,8 +939,8 @@ export class GameController { static async cleanupOldGames(): Promise { try { - const cleanupConfig = config.get("game.cleanup_old_games") 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); // Find stale, non-active games first so we can cascade delete related event-sourcing data. @@ -980,14 +980,14 @@ export class GameController { static startGameCleanup(): void { try { - const cleanupConfig = config.get("game.cleanup_old_games") as any; + const cleanupConfig = getBackendConfig().game.cleanupOldGames; - if (cleanupConfig && cleanupConfig.enabled) { + if (cleanupConfig.enabled) { setInterval( async () => { await GameController.cleanupOldGames(); }, - (cleanupConfig.check_interval_seconds || 3600) * 1000, + 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 ab1d21c..25aff0a 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 69c4a63..f1c526c 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 68fa6e7..9d7b249 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 4b1cbd4..671ad22 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 From 05a463f6a3c62ced8ccec0095fc6d851adbda815 Mon Sep 17 00:00:00 2001 From: Matt Palmerlee Date: Thu, 2 Jul 2026 11:18:35 -0700 Subject: [PATCH 3/4] environment test --- .../src/config/environment.spec.ts | 105 ++++++++++++++++++ .../src/config/environment.ts | 6 +- .../controllers/GameControllerWebSocket.ts | 9 +- 3 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 apps/astriarch-backend/src/config/environment.spec.ts 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 0000000..2025a6f --- /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 index e97283a..c7ef938 100644 --- a/apps/astriarch-backend/src/config/environment.ts +++ b/apps/astriarch-backend/src/config/environment.ts @@ -143,7 +143,11 @@ function buildBackendConfig(): BackendConfig { game: { cleanupOldGames: { enabled: parseBoolean(process.env.GAME_CLEANUP_ENABLED, true), - checkIntervalSeconds: parseInteger(process.env.GAME_CLEANUP_INTERVAL_SECONDS, "GAME_CLEANUP_INTERVAL_SECONDS", 7200), + 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), }, }, diff --git a/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts b/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts index bdbb1be..b998f5b 100644 --- a/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts +++ b/apps/astriarch-backend/src/controllers/GameControllerWebSocket.ts @@ -983,12 +983,9 @@ export class GameController { const cleanupConfig = getBackendConfig().game.cleanupOldGames; if (cleanupConfig.enabled) { - setInterval( - async () => { - await GameController.cleanupOldGames(); - }, - cleanupConfig.checkIntervalSeconds * 1000, - ); + setInterval(async () => { + await GameController.cleanupOldGames(); + }, cleanupConfig.checkIntervalSeconds * 1000); logger.info("Game cleanup scheduler started"); } From 796b4e02b0296fa732d2468d2bd4aebb974bb061 Mon Sep 17 00:00:00 2001 From: Matt Palmerlee Date: Thu, 2 Jul 2026 11:41:41 -0700 Subject: [PATCH 4/4] cleanup README --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6d24d2b..bcf8ad1 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ Your ultimate goal is to become the master of the known universe, and earn the t ## Background -Development started 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. +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) and ruler (arkhos) +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 @@ -78,9 +78,8 @@ 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. -