require('dotenv').config(); const express = require('express'); const sqlite3 = require('sqlite3').verbose(); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const seasonsUtil = require('./web/utils/seasons'); const http = require('http'); /** Parse a JSON column that may be gzip-compressed (Buffer) or plain text (string). */ function parseJsonColumn(data) { if (data === null || data === undefined) return null; if (Buffer.isBuffer(data)) return JSON.parse(zlib.gunzipSync(data).toString('utf-8')); return JSON.parse(data); } const STORAGE_ROOT = (process.env.STORAGE_VOL_PATH || '').trim(); if (!STORAGE_ROOT) { throw new Error('STORAGE_VOL_PATH must be set'); } const REPLAYS_PATH = path.join(STORAGE_ROOT, 'REPLAYS'); const COMPS_PATH = path.join(STORAGE_ROOT, 'COMPS'); const WL_DB_PATH = path.join(STORAGE_ROOT, 'wl.db'); const POINTS_DB_PATH = path.join(STORAGE_ROOT, 'points.db'); fs.mkdirSync(REPLAYS_PATH, { recursive: true }); function replayDataPath(sessionId) { const sid = String(sessionId).toLowerCase(); const candidates = [ path.join(REPLAYS_PATH, 'SRE', sid, 'replay_data.json.gz'), path.join(REPLAYS_PATH, sid, 'replay_data.json.gz'), ]; for (const p of candidates) { if (fs.existsSync(p)) return p; } return candidates[0]; } const app = express(); const PORT = process.env.SREBOT_API_PORT || 6000; const API_BEARER_TOKEN = process.env.SREBOT_API_BEARER_TOKEN || ''; const ADMIN_BEARER_TOKEN = process.env.SREBOT_ADMIN_BEARER_TOKEN || null; const wlDb = new sqlite3.Database(WL_DB_PATH); const pointsDb = new sqlite3.Database(POINTS_DB_PATH); const log = { info: (message, extra = {}) => { console.log(`[${new Date().toISOString()}] [INFO] ${message}`, extra && Object.keys(extra).length > 0 ? JSON.stringify(extra) : ''); }, error: (message, error = null, extra = {}) => { console.error(`[${new Date().toISOString()}] [ERROR] ${message}`, error ? error.message || error : '', extra && Object.keys(extra).length > 0 ? JSON.stringify(extra) : ''); }, warn: (message, extra = {}) => { console.warn(`[${new Date().toISOString()}] [WARN] ${message}`, extra && Object.keys(extra).length > 0 ? JSON.stringify(extra) : ''); }, debug: (message, extra = {}) => { if (process.env.NODE_ENV === 'development') { console.log(`[${new Date().toISOString()}] [DEBUG] ${message}`, extra && Object.keys(extra).length > 0 ? JSON.stringify(extra) : ''); } } }; app.use(cors()); app.use(express.json()); function requireApiBearer(req, res, next) { if (!API_BEARER_TOKEN) return next(); const auth = req.get('authorization') || ''; if (auth === `Bearer ${API_BEARER_TOKEN}`) { return next(); } return res.status(401).json({ error: 'Unauthorized' }); } function requireAdminBearer(req, res, next) { if (!ADMIN_BEARER_TOKEN) return res.status(403).json({ error: 'Admin access not configured' }); const auth = req.get('authorization') || ''; if (auth === `Bearer ${ADMIN_BEARER_TOKEN}`) return next(); return res.status(403).json({ error: 'Forbidden' }); } app.use('/api', requireApiBearer); // Readiness gate: heavy aggregation endpoints sit behind this so cold-start // requests don't pile up on the read connection while the DB is still opening // indexes and the vehicle-list cache is warming. Resolves when boot work // completes; falls back to a 15s safety timer. let serverReady = false; const STARTUP_GRACE_MS = 15000; const startupReadyAt = Date.now(); let resolveReady; const serverReadyPromise = new Promise((resolve) => { resolveReady = resolve; }); function markServerReady(reason) { if (serverReady) return; serverReady = true; log.info('Server ready', { reason, ms: Date.now() - startupReadyAt }); resolveReady(); } setTimeout(() => markServerReady('grace_timer'), STARTUP_GRACE_MS); const HEAVY_PATH_PATTERN = /^\/api\/(leaderboard|analytics)\b/; app.use((req, res, next) => { if (serverReady || !HEAVY_PATH_PATTERN.test(req.path)) return next(); const waitStart = Date.now(); const onReady = () => { if (res.headersSent) return; next(); }; const timer = setTimeout(() => { if (res.headersSent) return; res.set('Retry-After', '5'); res.status(503).json({ error: 'API warming up, retry shortly', errorCode: 'API_NOT_READY', waited_ms: Date.now() - waitStart, }); }, 8000); serverReadyPromise.then(() => { clearTimeout(timer); onReady(); }); }); const responseCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; const STATS_CACHE_TTL = 30 * 60 * 1000; // Global stats change slowly — cache longer to avoid frequent 6s+ rebuilds const inflightRequests = new Map(); // dedup: key -> Promise let squadronLookupCache = null; let squadronLookupCacheTime = 0; const SQUADRON_CACHE_TTL = 30 * 60 * 1000; let hasSquadronColumn = false; let nickLookupCache = null; let nickLookupCacheTime = 0; const performanceBenchmarkCache = new Map(); const performanceBenchmarkInFlight = new Map(); const PERFORMANCE_BENCHMARK_CACHE_TTL = 60 * 60 * 1000; function getCachedResponse(key, ttl = CACHE_TTL) { const cached = responseCache.get(key); if (cached && Date.now() - cached.timestamp < ttl) { return cached.data; } responseCache.delete(key); return null; } function setCachedResponse(key, data) { responseCache.set(key, { data, timestamp: Date.now() }); if (responseCache.size > 100) { const oldestKey = responseCache.keys().next().value; responseCache.delete(oldestKey); } } function runWalCheckpoint(mode, successMessage, errorMessage, logMethod = 'info') { db.run('PRAGMA busy_timeout = 10000;', (busyErr) => { if (busyErr) { log.error(`${errorMessage} (busy timeout setup failed):`, busyErr); return; } db.run(`PRAGMA wal_checkpoint(${mode});`, (err) => { if (err) { log.error(errorMessage, err); } else { log[logMethod](successMessage); } }); }); } // Dedup wrapper: if a query for this key is already in flight, wait for it function dedup(key, worker) { const existing = inflightRequests.get(key); if (existing) { log.info(`Dedup: waiting on in-flight request for ${key}`); return existing; } const promise = worker().finally(() => inflightRequests.delete(key)); inflightRequests.set(key, promise); return promise; } // ─── Heavy-aggregate cache (leaderboards) ─────────────────────────────── // Leaderboard payloads cost 13-53 s to build (full season-window scans on the // shared heavyDb connection). Give them their own cache -- isolated from the // 100-entry responseCache churn -- with stale-while-revalidate serving + a // background warmer so requests never block on a cold/stale leaderboard. const aggregateCache = new Map(); const aggregateInFlight = new Map(); const LIVE_AGG_TTL = 5 * 60 * 1000; const COMPLETED_AGG_TTL = 24 * 60 * 60 * 1000; function aggregateCacheTtl(dateFilters) { const now = Math.floor(Date.now() / 1000); if (dateFilters && dateFilters.endTimestamp && dateFilters.endTimestamp < now - 24 * 3600) { return COMPLETED_AGG_TTL; } return LIVE_AGG_TTL; } function aggregateKey(prefix, dateFilters) { return `${prefix}_${dateFilters.startTimestamp ?? 'all'}_${dateFilters.endTimestamp ?? 'all'}`; } function refreshAggregate(key, compute) { const existing = aggregateInFlight.get(key); if (existing) return existing; const promise = Promise.resolve().then(compute) .then(data => { aggregateCache.set(key, { data, timestamp: Date.now() }); return data; }) .finally(() => aggregateInFlight.delete(key)); aggregateInFlight.set(key, promise); return promise; } function serveAggregateCached(key, dateFilters, compute, res, shape, opts = {}) { const entry = aggregateCache.get(key); if (entry) { const fresh = Date.now() - entry.timestamp < aggregateCacheTtl(dateFilters); res.json(shape(entry.data)); if (!fresh) refreshAggregate(key, compute).catch(err => log.warn('Background aggregate refresh failed', { key, error: err && err.message })); return; } if (opts.allowCompute === false) { return res.status(400).json({ error: opts.filterError || 'A date filter (start_date/end_date/season/week) is required.', errorCode: 'FILTER_REQUIRED', }); } refreshAggregate(key, compute) .then(data => { if (!res.headersSent) res.json(shape(data)); }) .catch(err => { if (res.headersSent) return; if (err && err.status && err.body) return res.status(err.status).json(err.body); log.error('Aggregate computation failed', err, { key }); res.status(500).json({ error: 'Database error', errorCode: opts.errorCode || 'DB_LEADERBOARD_FAILED' }); }); } // ─── Leaderboard warmer (Fix 3) ───────────────────────────────────────── function warmOneAggregate(routePath, startTs, endTs) { const path = `${routePath}?start_date=${startTs}&end_date=${endTs}&limit=1`; const req = http.get({ host: '127.0.0.1', port: PORT, path }, r => { r.resume(); }); req.on('error', err => log.warn('Leaderboard warm request failed', { path, error: err.message })); req.setTimeout(120000, () => req.destroy()); } function warmLeaderboards() { let seasons; try { seasons = seasonsUtil.getSeasons(); } catch (e) { return log.warn('Warmer: getSeasons failed', { error: e.message }); } const entries = Object.entries(seasons) .map(([name, r]) => ({ name, start: r.start, end: r.end, status: r.status })) .sort((a, b) => a.start - b.start); const current = entries.filter(s => s.status === 'in_progress'); const completed = entries.filter(s => s.status === 'completed'); const lastCompleted = completed.length ? [completed[completed.length - 1]] : []; const targets = [...current, ...lastCompleted]; for (const s of targets) { warmOneAggregate('/api/leaderboard/players', s.start, s.end); warmOneAggregate('/api/leaderboard/vehicles', s.start, s.end); warmOneAggregate('/api/leaderboard/squadrons', s.start, s.end); } log.info('Leaderboard warmer tick', { windows: targets.map(t => t.name) }); } // Best-effort sync lookup of a squadron name → clan_id using the warm cache. // Returns null if the cache isn't populated yet or the input doesn't resolve. // Callers fall back to text matching when this returns null. function resolveClanIdSync(input) { if (!squadronLookupCache || !input) return null; const direct = squadronLookupCache[input] || squadronLookupCache[String(input)] || squadronLookupCache[String(input).toLowerCase()]; if (direct && direct.clan_id != null) return Number(direct.clan_id); return null; } // Resolve a squadron URL parameter to { clanId, variants } using the warm // cache. variants is the union of (current long_name, short_name, tag_name, // the raw input) — used for text-fallback matching against pre-migration // rows whose clan_id wasn't backfilled. clanId is the canonical id when // known. Both fields may be null/empty if the cache hasn't loaded yet. function resolveSquadronFilter(input) { const variants = new Set(); const raw = input == null ? '' : String(input).trim(); if (raw) variants.add(raw); let clanId = null; if (squadronLookupCache && raw) { const row = squadronLookupCache[raw] || squadronLookupCache[raw.toLowerCase()]; if (row) { if (row.clan_id != null) clanId = Number(row.clan_id); if (row.long_name) variants.add(row.long_name); if (row.short_name) variants.add(row.short_name); if (row.tag_name) variants.add(row.tag_name); } } return { clanId, variants: Array.from(variants) }; } // Build a WHERE clause for match_summary that matches matches involving the // given squadron. Prefers winning_clan_id/losing_clan_id when available; // falls back to text variants for rows whose clan_id wasn't backfilled. function matchSummarySquadronWhere(filter) { const parts = []; const params = []; if (filter.clanId != null) { parts.push('winning_clan_id = ?'); params.push(filter.clanId); parts.push('losing_clan_id = ?'); params.push(filter.clanId); } if (filter.variants.length) { const ph = filter.variants.map(() => '?').join(','); const fallbackGuard = filter.clanId != null ? '(winning_clan_id IS NULL OR losing_clan_id IS NULL) AND ' : ''; parts.push(`(${fallbackGuard}(winning_sq IN (${ph}) OR losing_sq IN (${ph})))`); params.push(...filter.variants, ...filter.variants); } if (!parts.length) { return { clause: '0', params: [] }; } return { clause: `(${parts.join(' OR ')})`, params }; } // True if the row (with winning_sq/losing_sq/winning_clan_id/losing_clan_id) // represents a win for the resolved squadron. function rowIsWinFor(row, filter, variantSet) { if (filter.clanId != null && row.winning_clan_id != null) { return Number(row.winning_clan_id) === filter.clanId; } return !!(row.winning_sq && variantSet.has(row.winning_sq)); } function rowIsLossFor(row, filter, variantSet) { if (filter.clanId != null && row.losing_clan_id != null) { return Number(row.losing_clan_id) === filter.clanId; } return !!(row.losing_sq && variantSet.has(row.losing_sq)); } // Build a WHERE fragment for player_games_hist `p` that selects rows for the // resolved squadron. Prefers p.clan_id when known; falls back to variant text. function playerGamesHistSquadronWhere(filter, alias = 'p') { const a = alias ? `${alias}.` : ''; const parts = []; const params = []; if (filter.clanId != null) { parts.push(`${a}clan_id = ?`); params.push(filter.clanId); } if (filter.variants.length) { const ph = filter.variants.map(() => '?').join(','); const fallbackGuard = filter.clanId != null ? `${a}clan_id IS NULL AND ` : ''; parts.push(`(${fallbackGuard}${a}squadron_name IN (${ph}))`); params.push(...filter.variants); } if (!parts.length) return { clause: '0', params: [] }; return { clause: `(${parts.join(' OR ')})`, params }; } function loadSquadronLookupCached(callback) { if (squadronLookupCache && Date.now() - squadronLookupCacheTime < SQUADRON_CACHE_TTL) { return callback(squadronLookupCache); } const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); if (!fs.existsSync(squadronsDbPath)) { squadronLookupCache = {}; squadronLookupCacheTime = Date.now(); return callback({}); } const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { log.error('Failed to open squadrons database', err); squadronLookupCache = {}; squadronLookupCacheTime = Date.now(); return callback({}); } squadronsDb.all(`SELECT clan_id, long_name, short_name, tag_name, members, clanrating FROM squadrons_data`, [], (err, rows) => { if (err) { squadronsDb.close(); log.error('Error loading squadron lookup data', err); squadronLookupCache = {}; squadronLookupCacheTime = Date.now(); return callback(squadronLookupCache); } const lookup = {}; const addAlias = (key, row) => { if (key === undefined || key === null || key === '') return; lookup[key] = row; const lower = String(key).toLowerCase(); if (!(lower in lookup)) { lookup[lower] = row; } }; rows.forEach(row => { if (row.long_name) addAlias(row.long_name, row); if (row.short_name) addAlias(row.short_name, row); if (row.tag_name) addAlias(row.tag_name, row); if (row.clan_id !== null && row.clan_id !== undefined) { addAlias(String(row.clan_id), row); } }); squadronLookupCache = lookup; squadronLookupCacheTime = Date.now(); // set immediately — prevents thundering herd of concurrent writes log.info('Squadron lookup cache updated', { entries: rows.length }); // Detect renames: any (clan_id, name) pair we've never seen before // is recorded in squadron_name_history so old-name web URLs can // resolve to the canonical clan_id via 301 redirect, and so the // squadron profile can include pre-rename history. We track all // three name forms (long_name, short_name, tag_name) since // short tags rename more often than long names. const now = Math.floor(Date.now() / 1000); const historyRows = []; for (const r of rows) { if (r.clan_id == null) continue; if (r.long_name) historyRows.push([r.clan_id, 'long', r.long_name, now, now]); if (r.short_name) historyRows.push([r.clan_id, 'short', r.short_name, now, now]); if (r.tag_name) historyRows.push([r.clan_id, 'tag', r.tag_name, now, now]); } // Call callback immediately — the write below is best-effort name-history // tracking and must never block callers waiting for the lookup data. squadronsDb.close(); callback(squadronLookupCache); if (historyRows.length) { // Background write: record rename history in squadrons.db. // Runs fully detached from the caller — errors are logged and swallowed. const writeDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READWRITE, (werr) => { if (werr) { log.warn('squadron_name_history: could not open for writing', { error: werr.message }); return; } let finished = false; const finalize = () => { if (finished) return; finished = true; try { writeDb.close(); } catch (_) {} }; writeDb.on('error', (e) => { log.warn('squadron_name_history write failed (likely BUSY); skipping', { error: e.message }); finalize(); }); writeDb.serialize(() => { writeDb.run('PRAGMA busy_timeout = 5000', () => {}); writeDb.run( `CREATE TABLE IF NOT EXISTS squadron_name_history ( clan_id INTEGER NOT NULL, long_name TEXT NOT NULL, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY (clan_id, long_name) )`, () => {} ); writeDb.run(`CREATE INDEX IF NOT EXISTS idx_snh_long_name ON squadron_name_history(long_name COLLATE NOCASE)`, () => {}); writeDb.run( `CREATE TABLE IF NOT EXISTS squadron_name_aliases ( clan_id INTEGER NOT NULL, kind TEXT NOT NULL, name TEXT NOT NULL, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY (clan_id, kind, name) )`, () => {} ); writeDb.run(`CREATE INDEX IF NOT EXISTS idx_sna_name ON squadron_name_aliases(name COLLATE NOCASE)`, () => {}); writeDb.run(`CREATE INDEX IF NOT EXISTS idx_sna_clanid ON squadron_name_aliases(clan_id)`, () => {}); const longStmt = writeDb.prepare( `INSERT INTO squadron_name_history (clan_id, long_name, first_seen, last_seen) VALUES (?, ?, ?, ?) ON CONFLICT(clan_id, long_name) DO UPDATE SET last_seen = excluded.last_seen` ); for (const r of rows) { if (r.clan_id != null && r.long_name) { longStmt.run([r.clan_id, r.long_name, now, now], () => {}); } } longStmt.finalize(() => {}); const aliasStmt = writeDb.prepare( `INSERT INTO squadron_name_aliases (clan_id, kind, name, first_seen, last_seen) VALUES (?, ?, ?, ?, ?) ON CONFLICT(clan_id, kind, name) DO UPDATE SET last_seen = excluded.last_seen` ); historyRows.forEach(args => aliasStmt.run(args, () => {})); aliasStmt.finalize(() => { finalize(); }); }); }); } }); }); } function loadNickLookupCached(callback) { if (nickLookupCache && Date.now() - nickLookupCacheTime < SQUADRON_CACHE_TTL) { return callback(nickLookupCache); } const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); if (!fs.existsSync(squadronsDbPath)) { nickLookupCache = {}; nickLookupCacheTime = Date.now(); return callback({}); } const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { log.error('Failed to open squadrons database for nick lookup', err); nickLookupCache = {}; nickLookupCacheTime = Date.now(); return callback({}); } squadronsDb.all(` SELECT sm.uid, sm.nick, sm.clan_id AS sm_clan_id, sd.clan_id AS sd_clan_id, sd.tag_name, sd.short_name FROM squadron_members sm LEFT JOIN squadrons_data sd ON sm.clan_id = sd.clan_id `, [], (err, rows) => { squadronsDb.close(); if (err) { log.error('Error loading nick lookup data', err); nickLookupCache = {}; } else { const lookup = {}; rows.forEach(row => { lookup[row.uid] = { nick: row.nick, clan_id: row.sd_clan_id || row.sm_clan_id || null, tag_name: row.tag_name, short_name: row.short_name }; }); nickLookupCache = lookup; log.info('Nick lookup cache updated', { entries: rows.length }); } nickLookupCacheTime = Date.now(); callback(nickLookupCache); }); }); } function dbAll(query, params = []) { return new Promise((resolve, reject) => { db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || [])); }); } function dbAllHeavy(query, params = []) { return new Promise((resolve, reject) => { heavyDb.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || [])); }); } function dbGet(query, params = []) { return new Promise((resolve, reject) => { db.get(query, params, (err, row) => err ? reject(err) : resolve(row || null)); }); } function dateFilterParams(dateFilters) { return [ dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp ]; } function buildDateClause(alias, dateFilters) { const prefix = alias ? `${alias}.` : ''; const parts = []; const params = []; if (dateFilters.startTimestamp !== null && dateFilters.startTimestamp !== undefined) { parts.push(`${prefix}endtime_unix >= ?`); params.push(dateFilters.startTimestamp); } if (dateFilters.endTimestamp !== null && dateFilters.endTimestamp !== undefined) { parts.push(`${prefix}endtime_unix <= ?`); params.push(dateFilters.endTimestamp); } return { clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', params }; } function performanceBenchmarkKey(dateFilters) { return `${dateFilters.startTimestamp || 'all'}_${dateFilters.endTimestamp || 'all'}`; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function normalizeRatingStats(row) { const games = Math.max(0, Number(row && (row.games ?? row.total_battles)) || 0); const kills = Math.max(0, Number(row && row.total_kills) || 0); const deaths = Math.max(0, Number(row && (row.total_deaths ?? row.deaths)) || 0); const assists = Math.max(0, Number(row && (row.total_assists ?? row.assists)) || 0); const captures = Math.max(0, Number(row && (row.total_captures ?? row.captures)) || 0); const wins = Math.max(0, Number(row && row.wins) || 0); const heavyweight = Math.max(0, Number(row && row.heavy_score) || 0); return { games, kdr: deaths > 0 ? kills / deaths : kills, kills_per_game: games > 0 ? kills / games : 0, heavy_rate: games > 0 ? heavyweight / games : 0, win_rate: games > 0 ? (wins / games) * 100 : 0, assists_per_game: games > 0 ? assists / games : 0, captures_per_game: games > 0 ? captures / games : 0, deaths_per_game: games > 0 ? deaths / games : 0, games_log: Math.log1p(games) }; } function sortedMetric(values, metric) { return values.map(value => Number(value[metric]) || 0).sort((a, b) => a - b); } function buildRatingBenchmark(rows) { const normalized = (rows || []).map(normalizeRatingStats); return { metrics: { kdr: sortedMetric(normalized, 'kdr'), kills_per_game: sortedMetric(normalized, 'kills_per_game'), heavy_rate: sortedMetric(normalized, 'heavy_rate'), win_rate: sortedMetric(normalized, 'win_rate'), assists_per_game: sortedMetric(normalized, 'assists_per_game'), captures_per_game: sortedMetric(normalized, 'captures_per_game'), deaths_per_game: sortedMetric(normalized, 'deaths_per_game'), games_log: sortedMetric(normalized, 'games_log') } }; } function resolveSquadronRatingKey(name, squadronLookup) { if (!name) return null; const resolved = resolveSquadronIdentity(null, { squadron_name: name }, squadronLookup); if (resolved && resolved.clan_id) { return `clan:${resolved.clan_id}`; } const fallbackName = resolved?.tag_name || resolved?.short_name || name; return `name:${String(fallbackName).toLowerCase()}`; } function aggregateSquadronBenchmarkRows(rows, squadronLookup) { const grouped = new Map(); for (const row of rows || []) { const ratingName = row?.squadron_name || row?.entity_key; const key = resolveSquadronRatingKey(ratingName, squadronLookup); if (!key) continue; if (!grouped.has(key)) { grouped.set(key, { clan_id: null, squadron_name: ratingName || null, games: 0, total_kills: 0, total_assists: 0, total_captures: 0, total_deaths: 0, wins: 0, heavy_score: 0 }); } const acc = grouped.get(key); const resolved = resolveSquadronIdentity(null, { squadron_name: ratingName }, squadronLookup); if (!acc.clan_id && resolved?.clan_id) { acc.clan_id = resolved.clan_id; } acc.games += Number(row.games) || Number(row.total_battles) || 0; acc.total_kills += Number(row.total_kills) || 0; acc.total_assists += Number(row.total_assists) || 0; acc.total_captures += Number(row.total_captures) || 0; acc.total_deaths += Number(row.total_deaths) || 0; acc.wins += Number(row.wins) || 0; acc.heavy_score += Number(row.heavy_score) || 0; } return Array.from(grouped.values()); } function upperBound(values, value) { let lo = 0; let hi = values.length; while (lo < hi) { const mid = Math.floor((lo + hi) / 2); if (values[mid] <= value) lo = mid + 1; else hi = mid; } return lo; } function lowerBound(values, value) { let lo = 0; let hi = values.length; while (lo < hi) { const mid = Math.floor((lo + hi) / 2); if (values[mid] < value) lo = mid + 1; else hi = mid; } return lo; } function metricPercentile(value, values, lowerIsBetter = false) { if (!Array.isArray(values) || !values.length) return 0; if (lowerIsBetter) { return (values.length - lowerBound(values, value)) / values.length; } return upperBound(values, value) / values.length; } function computePerformanceScore(row, benchmarkSet) { const stats = normalizeRatingStats(row); const metrics = (benchmarkSet && benchmarkSet.metrics) || {}; const weighted = (0.32 * metricPercentile(stats.kdr, metrics.kdr)) + (0.23 * metricPercentile(stats.heavy_rate, metrics.heavy_rate)) + (0.14 * metricPercentile(stats.kills_per_game, metrics.kills_per_game)) + (0.10 * metricPercentile(stats.games_log, metrics.games_log)) + (0.06 * metricPercentile(stats.win_rate, metrics.win_rate)) + (0.06 * metricPercentile(stats.assists_per_game, metrics.assists_per_game)) + (0.04 * metricPercentile(stats.captures_per_game, metrics.captures_per_game)) + (0.05 * metricPercentile(stats.deaths_per_game, metrics.deaths_per_game, true)); const sample = clamp(Math.sqrt(stats.games / 10), 0, 1); return Number(clamp(5 * weighted * sample, 0, 5).toFixed(2)); } function ratingCtes(dateFilters, entityExpression, targetSessionsCte = '') { const dateClause = buildDateClause('p', dateFilters).clause; return ` WITH ${targetSessionsCte ? `${targetSessionsCte},` : ''} player_sessions AS ( SELECT ${entityExpression} AS entity_key, p.UID, p.session_id, SUM(p.ground_kills + p.air_kills) AS kills, SUM(p.assists) AS assists, SUM(p.captures) AS captures, SUM(p.deaths) AS deaths, MAX(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) AS win FROM player_games_hist p ${targetSessionsCte ? 'JOIN target_sessions ts ON ts.session_id = p.session_id' : ''} WHERE p.UID IS NOT NULL AND p.nick NOT LIKE 'coop/%' ${dateClause} GROUP BY entity_key, p.UID, p.session_id ), leader_stats AS ( SELECT ps.session_id, MAX(ps.kills) AS max_kills FROM player_sessions ps GROUP BY ps.session_id ), leader_counts AS ( SELECT ps.session_id, COUNT(*) AS top_count FROM player_sessions ps JOIN leader_stats ls ON ls.session_id = ps.session_id AND ls.max_kills = ps.kills GROUP BY ps.session_id ), second_stats AS ( SELECT ps.session_id, MAX(ps.kills) AS second_kills FROM player_sessions ps JOIN leader_stats ls ON ls.session_id = ps.session_id WHERE ps.kills < ls.max_kills GROUP BY ps.session_id ), scored_sessions AS ( SELECT ps.*, CASE WHEN ps.kills >= 3 AND ps.kills = ls.max_kills AND lc.top_count = 1 AND (ps.kills - COALESCE(ss.second_kills, 0)) >= 2 THEN MIN(1.0, (ps.kills - 2) / 2.0) WHEN ps.kills >= 3 AND ps.kills = ls.max_kills AND lc.top_count = 1 THEN 0.6 * MIN(1.0, (ps.kills - 2) / 2.0) ELSE 0 END AS heavy_score FROM player_sessions ps JOIN leader_stats ls ON ls.session_id = ps.session_id JOIN leader_counts lc ON lc.session_id = ps.session_id LEFT JOIN second_stats ss ON ss.session_id = ps.session_id ) `; } function playerBenchmarkQuery(dateFilters) { const dateClause = buildDateClause('p', dateFilters); return { query: ` ${ratingCtes(dateFilters, 'p.UID')} SELECT entity_key, entity_key AS squadron_name, COUNT(*) AS games, SUM(kills) AS total_kills, SUM(assists) AS total_assists, SUM(captures) AS total_captures, SUM(deaths) AS total_deaths, SUM(win) AS wins, SUM(heavy_score) AS heavy_score FROM scored_sessions GROUP BY entity_key HAVING COUNT(*) >= 10 `, params: dateClause.params }; } function squadronBenchmarkQuery(dateFilters) { const dateClause = buildDateClause('p', dateFilters); return { query: ` ${ratingCtes(dateFilters, 'p.squadron_name')}, squadron_sessions AS ( SELECT entity_key, session_id, SUM(kills) AS kills, SUM(assists) AS assists, SUM(captures) AS captures, SUM(deaths) AS deaths, MAX(win) AS win, SUM(heavy_score) AS heavy_score FROM scored_sessions WHERE entity_key IS NOT NULL AND entity_key != 'UNKNOWN' GROUP BY entity_key, session_id ) SELECT entity_key, COUNT(*) AS games, SUM(kills) AS total_kills, SUM(assists) AS total_assists, SUM(captures) AS total_captures, SUM(deaths) AS total_deaths, SUM(win) AS wins, SUM(heavy_score) AS heavy_score FROM squadron_sessions GROUP BY entity_key HAVING COUNT(*) >= 10 `, params: dateClause.params }; } function queryPlayerRatingStats(uid, dateFilters) { const sessionDate = buildDateClause('src', dateFilters); const playerDate = buildDateClause('p', dateFilters); const targetSessionsCte = ` target_sessions AS ( SELECT DISTINCT session_id FROM player_games_hist src WHERE src.UID = ? AND src.nick NOT LIKE 'coop/%' ${sessionDate.clause} ) `; const query = ` ${ratingCtes(dateFilters, 'p.UID', targetSessionsCte)} SELECT entity_key, COUNT(*) AS games, SUM(kills) AS total_kills, SUM(assists) AS total_assists, SUM(captures) AS total_captures, SUM(deaths) AS total_deaths, SUM(win) AS wins, SUM(heavy_score) AS heavy_score FROM scored_sessions WHERE entity_key = ? GROUP BY entity_key `; return dbGet(query, [uid, ...sessionDate.params, ...playerDate.params, uid]).catch(err => { log.error('Failed to query player performance stats', err, { uid }); return null; }); } function queryPlayerRatingStatsForUids(uids, dateFilters) { const ids = [...new Set((uids || []).map(uid => String(uid)).filter(Boolean))]; if (!ids.length) return Promise.resolve(new Map()); const placeholders = ids.map(() => '?').join(','); const sessionDate = buildDateClause('src', dateFilters); const playerDate = buildDateClause('p', dateFilters); const targetSessionsCte = ` target_sessions AS ( SELECT DISTINCT session_id FROM player_games_hist src WHERE src.UID IN (${placeholders}) AND src.nick NOT LIKE 'coop/%' ${sessionDate.clause} ) `; const query = ` ${ratingCtes(dateFilters, 'p.UID', targetSessionsCte)} SELECT entity_key, COUNT(*) AS games, SUM(kills) AS total_kills, SUM(assists) AS total_assists, SUM(captures) AS total_captures, SUM(deaths) AS total_deaths, SUM(win) AS wins, SUM(heavy_score) AS heavy_score FROM scored_sessions WHERE entity_key IN (${placeholders}) GROUP BY entity_key `; return dbAll(query, [...ids, ...sessionDate.params, ...playerDate.params, ...ids]) .then(rows => { const map = new Map(); rows.forEach(row => map.set(String(row.entity_key), row)); return map; }) .catch(err => { log.error('Failed to query roster player performance stats', err, { count: ids.length }); return new Map(); }); } function querySquadronRatingStats(variants, dateFilters, clanId = null) { const names = [...new Set((variants || []).filter(Boolean))]; if (!names.length) return Promise.resolve(null); const placeholders = names.map(() => '?').join(','); const sessionDate = buildDateClause('src', dateFilters); const playerDate = buildDateClause('p', dateFilters); const targetSessionsCte = ` target_sessions AS ( SELECT DISTINCT session_id FROM player_games_hist src WHERE src.squadron_name IN (${placeholders}) AND src.nick NOT LIKE 'coop/%' ${sessionDate.clause} ) `; const query = ` ${ratingCtes(dateFilters, 'p.squadron_name', targetSessionsCte)}, squadron_sessions AS ( SELECT entity_key, session_id, SUM(kills) AS kills, SUM(assists) AS assists, SUM(captures) AS captures, SUM(deaths) AS deaths, MAX(win) AS win, SUM(heavy_score) AS heavy_score FROM scored_sessions WHERE entity_key IN (${placeholders}) GROUP BY entity_key, session_id ) SELECT 'squadron' AS entity_key, COUNT(*) AS games, SUM(kills) AS total_kills, SUM(assists) AS total_assists, SUM(captures) AS total_captures, SUM(deaths) AS total_deaths, SUM(win) AS wins, SUM(heavy_score) AS heavy_score FROM squadron_sessions `; return dbGet(query, [...names, ...sessionDate.params, ...playerDate.params]).then(row => { if (!row) return null; return { ...row, clan_id: clanId }; }).catch(err => { log.error('Failed to query squadron performance stats', err, { variants: names }); return null; }); } function loadPerformanceBenchmarksCached(dateFilters, callback) { const key = performanceBenchmarkKey(dateFilters); const cached = performanceBenchmarkCache.get(key); const isFresh = cached && Date.now() - cached.timestamp < PERFORMANCE_BENCHMARK_CACHE_TTL; if (isFresh) { return callback(cached.data); } // Stale-while-revalidate: return immediately (stale or empty), refresh in background. // The all-time benchmark query takes ~47s and must not block response time. const emptyBenchmarks = { players: buildRatingBenchmark([]), squadrons: buildRatingBenchmark([]) }; callback(cached ? cached.data : emptyBenchmarks); if (performanceBenchmarkInFlight.has(key)) return; const loadSquadronLookupPromise = () => new Promise(resolve => loadSquadronLookupCached(resolve)); const promise = Promise.all([ loadSquadronLookupPromise(), (() => { const benchmarkQuery = playerBenchmarkQuery(dateFilters); return dbAllHeavy(benchmarkQuery.query, benchmarkQuery.params).catch(err => { log.error('Failed to load player performance benchmarks', err); return []; }); })(), (() => { const benchmarkQuery = squadronBenchmarkQuery(dateFilters); return dbAllHeavy(benchmarkQuery.query, benchmarkQuery.params).catch(err => { log.error('Failed to load squadron performance benchmarks', err); return []; }); })() ]).then(([squadronLookup, playerRows, squadronRows]) => { const data = { players: buildRatingBenchmark(playerRows), squadrons: buildRatingBenchmark(aggregateSquadronBenchmarkRows(squadronRows, squadronLookup)) }; performanceBenchmarkCache.set(key, { data, timestamp: Date.now() }); if (performanceBenchmarkCache.size > 12) { performanceBenchmarkCache.delete(performanceBenchmarkCache.keys().next().value); } performanceBenchmarkInFlight.delete(key); log.info('Performance benchmarks refreshed in background', { key }); return data; }).catch(err => { performanceBenchmarkInFlight.delete(key); log.error('Failed to load performance benchmarks', err); }); performanceBenchmarkInFlight.set(key, promise); } function normalizeVehicleName(name) { if (!name) return name; // Keep visible decoration glyphs (◢ ▄ ◊ ␗ etc.) — those are country / // event indicators and the website is expected to display them. Only strip // the Private Use Area, where older WT variants stored sprite refs that // render as tofu in any browser font. return name.replace(/[\uE000-\uF8FF]/g, '').trim(); } function normalizeQueryList(value) { if (value === undefined || value === null) return []; const rawValues = Array.isArray(value) ? value : [value]; return rawValues .flatMap(item => String(item).split(',')) .map(item => item.trim()) .filter(Boolean); } // ─── Vehicle meta cache ──────────────────────────────────────────────── // Loaded from BOT/utils.py-generated cache files. `vehicle_data_cache.json` // is an array of [cdk, english_name, icon, tags{}] entries; we use it for // type abbreviations (T/F/B/AA/L/H) and English fallback names. // Tags pulled directly from unittags.blk via vehicle_data_cache.json. Helicopters // in particular can show up under type_attack_helicopter / type_utility_helicopter, // not the generic type_helicopter the bot's data_parser injects, so we list every // concrete type tag we've observed in the cache. const TAG_TO_ABBREV = { // SPAA type_spaa: 'AA', // Light tanks type_light_tank: 'L', // Tanks (heavy/medium/TD/missile) type_heavy_tank: 'T', type_medium_tank: 'T', type_tank_destroyer: 'T', type_missile_tank: 'T', type_football_tank: 'T', type_assault: 'T', tank: 'T', // Fighters (incl. interceptors / strike) type_fighter: 'F', type_jet_fighter: 'F', type_interceptor: 'F', type_aa_fighter: 'F', type_strike_aircraft: 'F', type_strike_ucav: 'F', // Bombers type_bomber: 'B', type_jet_bomber: 'B', type_dive_bomber: 'B', type_light_bomber: 'B', type_frontline_bomber: 'B', type_longrange_bomber: 'B', // Helicopters type_attack_helicopter: 'H', type_utility_helicopter: 'H', type_helicopter: 'H', helicopter: 'H', }; let _vehicleMetaCache = null; // cdk (lower) -> { name, type, cdk } function loadVehicleMetaCache() { if (_vehicleMetaCache) return _vehicleMetaCache; const cachePath = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_data_cache_all.json'); const fallback = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_data_cache.json'); const target = fs.existsSync(cachePath) ? cachePath : (fs.existsSync(fallback) ? fallback : null); if (!target) { log.error(`Vehicle meta cache not found at ${cachePath} or ${fallback}`); _vehicleMetaCache = new Map(); return _vehicleMetaCache; } try { const raw = JSON.parse(fs.readFileSync(target, 'utf-8')); const map = new Map(); for (const entry of raw) { if (!Array.isArray(entry) || entry.length < 4) continue; const cdk = entry[0]; const name = entry[1]; const tags = entry[3] || {}; // Best match: prefer specific type_* tags over generic ones. let abbrev = '?'; let fallbackAbbrev = null; for (const tag of Object.keys(tags)) { if (!(tag in TAG_TO_ABBREV)) continue; if (tag.startsWith('type_')) { abbrev = TAG_TO_ABBREV[tag]; break; } if (fallbackAbbrev == null) fallbackAbbrev = TAG_TO_ABBREV[tag]; } if (abbrev === '?' && fallbackAbbrev) abbrev = fallbackAbbrev; map.set(String(cdk).toLowerCase(), { name, type: abbrev, cdk }); } _vehicleMetaCache = map; log.info(`Loaded vehicle meta for ${map.size} vehicles from ${path.basename(target)}`); } catch (e) { log.error('Failed to load vehicle meta cache', e); _vehicleMetaCache = new Map(); } return _vehicleMetaCache; } function getVehicleType(internal) { if (!internal) return '?'; const map = loadVehicleMetaCache(); const hit = map.get(String(internal).toLowerCase()); return hit ? hit.type : '?'; } // Same order the bot's /comp uses so notation matches across surfaces. const COMP_TYPE_ORDER = ['F', 'B', 'H', 'L', 'T', 'AA', '?']; function buildCompNotation(typeCounts) { const parts = []; for (const code of COMP_TYPE_ORDER) { const n = typeCounts[code] || 0; if (n > 0) parts.push(`${n}${code}`); } return parts.join(' '); } // Canonicalize a player's squadron identity using the squadrons_data lookup. // Prefers clan_id from the squadron_members cache (joined to squadrons_data); otherwise // falls back to a string lookup against long_name / short_name / tag_name. // Always returns the canonical tag_name + short_name for the matched clan_id when possible, // so consumers can group/dedupe by clan_id (or short_name) regardless of which raw value was stored. function resolveSquadronIdentity(cached, fallback, squadronLookup) { let clanId = null; let tagName = null; let shortName = null; if (cached && cached.clan_id) { clanId = cached.clan_id; tagName = cached.tag_name || null; shortName = cached.short_name || null; } const rawTag = tagName || (cached ? cached.tag_name : null) || (fallback ? fallback.squadron_name : null); if ((!clanId || !tagName || !shortName) && rawTag && squadronLookup) { const sq = squadronLookup[rawTag]; if (sq) { clanId = clanId || sq.clan_id || null; tagName = sq.tag_name || tagName || rawTag; shortName = sq.short_name || shortName || null; } else if (!tagName) { tagName = rawTag; } } return { clan_id: clanId, tag_name: tagName, short_name: shortName }; } function formatSquadronResolution(input, row, resolvedBy) { if (!row) { return { input, resolved: false, resolved_by: resolvedBy, clan_id: null, short_name: input, tag_name: input, long_name: '', }; } return { input, resolved: true, resolved_by: resolvedBy, clan_id: row.clan_id ?? null, short_name: row.short_name || input, tag_name: row.tag_name || row.short_name || input, long_name: row.long_name || '', }; } function buildSquadronIdentity(team, lookup) { const keys = [ team?.squadron, team?.squadron_tagged, team?.squadron_long, ].filter(Boolean); let hit = null; for (const key of keys) { hit = lookup[key] || lookup[String(key).trim()] || lookup[String(key).trim().toLowerCase()] || lookup[String(key).trim().toUpperCase()]; if (hit) break; } const clanId = hit?.clan_id ?? null; return { clan_id: clanId, short_name: hit?.short_name || team?.squadron || '', tag_name: hit?.tag_name || team?.squadron_tagged || team?.squadron || '', long_name: hit?.long_name || team?.squadron_long || team?.squadron || '', }; } function jsonError(res, status, message, extra = {}) { return res.status(status).json({ error: message, ...extra }); } function normalizeUid(value) { if (value === null || value === undefined || value === '') return null; return String(value); } function normalizeClanId(value) { if (value === null || value === undefined || value === '') return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function cleanText(value) { if (value === null || value === undefined) return null; const text = String(value).trim(); return text || null; } function resolveCurrentSquadronIdentity(latestRow, squadronLookup, preferredClanId = null) { const effectiveClanId = preferredClanId != null ? Number(preferredClanId) : normalizeClanId(latestRow?.clan_id); const lookupByClanId = effectiveClanId != null ? squadronLookup[`__cid_${effectiveClanId}`] || squadronLookup[String(effectiveClanId)] : null; const lookup = lookupByClanId || squadronLookup[latestRow?.squadron_name]; const squadronName = cleanText(lookup?.tag_name) || cleanText(latestRow?.squadron_name) || ''; const squadronLongName = cleanText(lookup?.long_name) || cleanText(latestRow?.squadron_name) || ''; const currentNames = new Set(); [ latestRow?.squadron_name, lookup?.long_name, lookup?.short_name, lookup?.tag_name, ].forEach((name) => { const cleaned = cleanText(name); if (cleaned) currentNames.add(cleaned.toLowerCase()); }); return { squadron_name: squadronName, squadron_long_name: squadronLongName, squadron_clan_id: lookup?.clan_id != null ? Number(lookup.clan_id) : effectiveClanId, current_names: currentNames, }; } async function loadPlayerIdentitySnapshot(uid, squadronLookup, options = {}) { const latestRow = options.latestRow || await dbGetAsync( db, `SELECT nick, squadron_name, clan_id FROM player_games_hist WHERE UID = ? AND nick NOT LIKE 'coop/%' ORDER BY session_id DESC LIMIT 1`, [uid] ); if (!latestRow) return null; const currentIdentity = resolveCurrentSquadronIdentity(latestRow, squadronLookup, options.preferredClanId ?? null); const [nickRows, squadronRows] = await Promise.all([ dbAllAsync( db, // Pre-2026-01-19 the Spectra API returned auto-generated // placeholder nicks (e.g. "Dietrich3657") for newly-discovered // profiles before Gaijin's backend resolved them. Those rows // pollute previous_nicks with hundreds of garbage entries per // affected UID, so drop any placeholder-shaped nick whose battle // ended before that cutoff. Post-cutoff rows are trusted as-is. `SELECT nick, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE UID = ? AND nick IS NOT NULL AND nick <> '' AND nick NOT LIKE 'coop/%' AND NOT ( endtime_unix < 1768780800 AND nick GLOB '[A-Z][a-z]*[0-9]' AND nick NOT GLOB '*[^A-Za-z0-9]*' AND LENGTH(nick) BETWEEN 5 AND 18 ) GROUP BY nick ORDER BY last_seen DESC, nick`, [uid] ), dbAllAsync( db, `SELECT squadron_name, clan_id, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE UID = ? AND squadron_name IS NOT NULL AND squadron_name <> '' AND squadron_name <> 'UNKNOWN' GROUP BY squadron_name, clan_id ORDER BY last_seen DESC, squadron_name`, [uid] ), ]); const currentNickKey = cleanText(latestRow.nick)?.toLowerCase() || null; const seenNicks = new Set(); const previous_nicks = []; for (const row of nickRows) { const nick = cleanText(row.nick); if (!nick) continue; const key = nick.toLowerCase(); if (key === currentNickKey || seenNicks.has(key)) continue; seenNicks.add(key); previous_nicks.push(nick); } const seenSquadrons = new Set(); const previous_squadron_names = []; for (const row of squadronRows) { const lookup = (row.clan_id != null && squadronLookup[`__cid_${row.clan_id}`]) || squadronLookup[row.squadron_name]; const displayName = cleanText(lookup?.tag_name) || cleanText(row.squadron_name); if (!displayName) continue; const key = displayName.toLowerCase(); if (currentIdentity.current_names.has(key) || seenSquadrons.has(key)) continue; seenSquadrons.add(key); previous_squadron_names.push(displayName); } return { uid: normalizeUid(uid), nick: latestRow.nick, previous_nicks, squadron_name: currentIdentity.squadron_name, squadron_long_name: currentIdentity.squadron_long_name, squadron_clan_id: currentIdentity.squadron_clan_id, previous_squadron_names, }; } function normalizeMatchPlayer(player) { if (!player || typeof player !== 'object') return null; const vehicleInternal = cleanText(player.vehicle_internal) || cleanText(player.vehicle); const vehicleDisplay = cleanText(player.vehicle_new) || cleanText(player.vehicle); const normalized = { uid: normalizeUid(player.uid), nick: cleanText(player.nick) || '', fake_nick: player.fake_nick ?? null, vehicle_internal: vehicleInternal, vehicle: vehicleDisplay ? normalizeVehicleName(vehicleDisplay) : vehicleInternal, air_kills: Number(player.air_kills || 0), ground_kills: Number(player.ground_kills || 0), assists: Number(player.assists || 0), deaths: Number(player.deaths || 0), captures: Number(player.captures || 0), score: Number(player.score || 0), }; if (player.spawn_order !== undefined) normalized.spawn_order = player.spawn_order; return normalized; } function normalizeMatchTeam(team, lookup, fallback = {}) { if (!team || typeof team !== 'object') return null; const seeded = { ...team, squadron: team.squadron ?? fallback.squadron ?? null, squadron_tagged: team.squadron_tagged ?? fallback.squadron_tagged ?? null, squadron_long: team.squadron_long ?? fallback.squadron_long ?? null, clan_id: team.clan_id ?? fallback.clan_id ?? null, }; const identity = buildSquadronIdentity(seeded, lookup); const players = Array.isArray(team.players) ? team.players.map(normalizeMatchPlayer).filter(Boolean) : []; return { team_index: team.team_index ?? null, clan_id: normalizeClanId(identity.clan_id), squadron: cleanText(identity.short_name) || cleanText(seeded.squadron) || '', squadron_tagged: cleanText(identity.tag_name) || cleanText(seeded.squadron_tagged) || cleanText(seeded.squadron) || '', squadron_long: cleanText(identity.long_name), players, }; } function dbAllAsync(database, sql, params = []) { return new Promise((resolve, reject) => { database.all(sql, params, (err, rows) => { if (err) return reject(err); resolve(rows || []); }); }); } function dbGetAsync(database, sql, params = []) { return new Promise((resolve, reject) => { database.get(sql, params, (err, row) => { if (err) return reject(err); resolve(row || null); }); }); } function readReplayJson(sessionId) { const replayPath = replayDataPath(sessionId); if (!fs.existsSync(replayPath)) { return null; } try { return JSON.parse(zlib.gunzipSync(fs.readFileSync(replayPath))); } catch (err) { log.warn('Failed to read replay JSON', { sessionId, error: err.message }); return null; } } function normalizeCompPlayers(players) { const order = { F: 0, B: 1, H: 2, L: 3, T: 4, AA: 5, '?': 6 }; return [...(players || [])].sort((a, b) => { const av = getVehicleType(a?.vehicle_internal); const bv = getVehicleType(b?.vehicle_internal); return (order[av] ?? 99) - (order[bv] ?? 99) || String(a?.nick || '').localeCompare(String(b?.nick || '')); }).map(player => ({ uid: normalizeUid(player?.uid ?? player?.UID), nick: cleanText(player?.nick) || '', vehicle_internal: cleanText(player?.vehicle_internal), vehicle: normalizeVehicleName(cleanText(player?.vehicle) || cleanText(player?.vehicle_internal) || ''), })); } async function loadRecentComps(squadronName, windowSeconds = 3600, limit = 10) { const squadFile = path.join(COMPS_PATH, `${String(squadronName).toUpperCase()}.json`); if (!fs.existsSync(squadFile)) { return null; } let compsData; try { compsData = JSON.parse(fs.readFileSync(squadFile, 'utf-8')); } catch (err) { throw new Error(`Failed to read comp file: ${err.message}`); } const now = Math.floor(Date.now() / 1000); const thresholdSeconds = Math.max(0, Number(windowSeconds) || 3600); const maxComps = Math.max(1, Math.min(50, Number(limit) || 10)); const comps = Object.entries(compsData || {}) .map(([compKey, comp]) => { const regTs = Number(comp?.reg || 0); const updTs = Number(comp?.upd || 0); const lastSeenTs = updTs || regTs; const ageSeconds = now - lastSeenTs; const typeCounts = (comp?.Players || []).reduce((acc, player) => { const code = getVehicleType(player?.vehicle_internal); acc[code] = (acc[code] || 0) + 1; return acc; }, {}); const notation = COMP_TYPE_ORDER .map(code => { const count = typeCounts[code] || 0; return count > 0 ? `${count}${code}` : null; }) .filter(Boolean) .join(' / ') || 'None'; return { comp_key: compKey, reg: regTs, upd: updTs, last_seen: lastSeenTs, age_seconds: ageSeconds, players: normalizeCompPlayers(comp?.Players || []), notation, }; }) .filter(comp => comp.last_seen && comp.age_seconds <= thresholdSeconds) .sort((a, b) => (b.last_seen - a.last_seen) || (b.reg - a.reg)) .slice(0, maxComps); return { squadron: String(squadronName).toUpperCase(), window_seconds: thresholdSeconds, limit: maxComps, total_available: Object.keys(compsData || {}).length, total_recent: comps.length, comps, }; } async function loadScoreboardContext(sessionId) { const replay = readReplayJson(sessionId); const matchRow = await dbGetAsync( db, `SELECT session_id, map_name, endtime_unix, received_unix, winning_sq, losing_sq, winning_team_json, losing_team_json, game_type FROM match_summary WHERE session_id = ?`, [sessionId] ); if (!replay && !matchRow) { return null; } let teams = Array.isArray(replay?.teams) ? replay.teams.slice(0, 2).map(team => ({ ...(team || {}) })) : []; if (!teams.length && matchRow) { try { const winningTeam = matchRow.winning_team_json ? parseJsonColumn(matchRow.winning_team_json) : null; const losingTeam = matchRow.losing_team_json ? parseJsonColumn(matchRow.losing_team_json) : null; teams = [winningTeam, losingTeam].filter(Boolean).map(team => ({ ...(team || {}) })); } catch (err) { log.warn('Failed to synthesize scoreboard teams from match_summary', { sessionId, error: err.message }); } } const squadronIdentities = await new Promise((resolve) => { loadSquadronLookupCached((lookup) => { try { resolve(teams.map(team => buildSquadronIdentity(team, lookup))); } catch (err) { log.warn('Failed to resolve squadron lookup for scoreboard context', { sessionId, error: err.message }); resolve(teams.map(team => ({ clan_id: null, short_name: team?.squadron || '', tag_name: team?.squadron_tagged || team?.squadron || '', long_name: team?.squadron_long || team?.squadron || '', }))); } }); }); const enrichedTeams = teams.map((team, idx) => { const identity = squadronIdentities[idx] || { clan_id: null, short_name: team?.squadron || '', tag_name: team?.squadron_tagged || team?.squadron || '', long_name: team?.squadron_long || team?.squadron || '', }; return { ...(normalizeMatchTeam(team, {}, { squadron: identity.short_name, squadron_tagged: identity.tag_name, squadron_long: identity.long_name, clan_id: identity.clan_id, }) || {}), squadron_identity: identity, }; }); // Prefer clan_id (rename-stable); fall back to long_name text for orphans // whose clan_id wasn't backfilled. const teamClanIds = enrichedTeams .map(team => (team?.clan_id != null ? Number(team.clan_id) : null)) .filter(cid => cid != null); const longNames = enrichedTeams.map(team => String(team?.squadron_long || '').trim()).filter(Boolean); const pointsClauses = []; const pointsParams = [sessionId]; if (teamClanIds.length) { pointsClauses.push(`clan_id IN (${teamClanIds.map(() => '?').join(',')})`); pointsParams.push(...teamClanIds); } if (longNames.length) { pointsClauses.push(`(clan_id IS NULL AND squadron IN (${longNames.map(() => '?').join(',')}))`); pointsParams.push(...longNames); } const pointsRows = pointsClauses.length ? await dbAllAsync( pointsDb, `SELECT clan_id, squadron, diffs_json, diff_total, updated_json FROM game_cache WHERE game_id = ? AND (${pointsClauses.join(' OR ')})`, pointsParams ) : []; const points_diffs = {}; for (const row of pointsRows) { const identity = squadronIdentities.find(entry => (row.clan_id != null && entry.clan_id != null && Number(entry.clan_id) === Number(row.clan_id)) || entry.long_name === row.squadron ) || { clan_id: row.clan_id != null ? Number(row.clan_id) : null, short_name: row.squadron, tag_name: row.squadron, long_name: row.squadron, }; const diffKey = String(identity.short_name || row.squadron); try { points_diffs[diffKey] = { squadron_identity: identity, points_diff: parseJsonColumn(row.diffs_json), diff_total: Number(row.diff_total || 0), current_points: parseJsonColumn(row.updated_json), key: diffKey, }; } catch (err) { points_diffs[diffKey] = { squadron_identity: identity, points_diff: {}, diff_total: 0, current_points: {}, key: diffKey, }; } } for (const team of enrichedTeams) { const identity = team?.squadron_identity || { clan_id: null, short_name: team?.squadron || '', tag_name: team?.squadron_tagged || team?.squadron || '', long_name: team?.squadron_long || team?.squadron || '', }; const diffKey = String(identity.short_name || team?.squadron || ''); if (identity.long_name && !points_diffs[diffKey]) { points_diffs[diffKey] = { squadron_identity: identity, points_diff: {}, diff_total: 0, current_points: {}, key: diffKey, }; } } // Prefer clan_id (rename-stable); the text fallback is gated on a NULL // clan_id so we don't pull a different clan that happens to share a short // tag. Post-migration wl_standings.clan_id is always populated, so the // fallback is only here as a safety net. const wlShortTexts = teams.map(team => String(team?.squadron || '').trim()).filter(Boolean); const wlClauses = []; const wlParams = []; if (teamClanIds.length) { wlClauses.push(`clan_id IN (${teamClanIds.map(() => '?').join(',')})`); wlParams.push(...teamClanIds); } if (wlShortTexts.length) { wlClauses.push(`(clan_id IS NULL AND squadron IN (${wlShortTexts.map(() => '?').join(',')}))`); wlParams.push(...wlShortTexts); } const wlRows = wlClauses.length ? await dbAllAsync( wlDb, `SELECT clan_id, squadron, wins, losses FROM wl_standings WHERE ${wlClauses.join(' OR ')}`, wlParams ) : []; const wl = {}; for (const team of enrichedTeams) { const identity = team?.squadron_identity || { clan_id: null, short_name: team?.squadron || '', tag_name: team?.squadron_tagged || team?.squadron || '', long_name: team?.squadron_long || team?.squadron || '', }; const key = String(identity.short_name || team?.squadron || ''); wl[key] = { squadron_identity: identity, wins: 0, losses: 0, key, }; } for (const row of wlRows) { const identity = squadronIdentities.find(entry => (row.clan_id != null && entry.clan_id != null && Number(entry.clan_id) === Number(row.clan_id)) || entry.short_name === row.squadron || entry.long_name === row.squadron || entry.tag_name === row.squadron ) || { clan_id: row.clan_id != null ? Number(row.clan_id) : null, short_name: row.squadron, tag_name: row.squadron, long_name: row.squadron, }; const key = String(identity.short_name || row.squadron); wl[key] = { squadron_identity: identity, wins: Number(row.wins || 0), losses: Number(row.losses || 0), key, }; } const winner = replay?.winning_team_squadron || matchRow?.winning_sq || null; const loser = replay?.losing_team_squadron || matchRow?.losing_sq || null; const isDraw = Boolean(replay?.draw || replay?.is_draw); const winnerIdentity = squadronIdentities.find(entry => entry.short_name === winner || entry.tag_name === winner || entry.long_name === winner) || null; const loserIdentity = squadronIdentities.find(entry => entry.short_name === loser || entry.tag_name === loser || entry.long_name === loser) || null; return { session_id: String(sessionId), match_details: { utc_timestamp: Number(matchRow?.endtime_unix || replay?.end_ts || 0), session_id: String(sessionId), received_unix: Number(matchRow?.received_unix || 0) || undefined, }, map_name: matchRow?.map_name || replay?.map || null, game_type: matchRow?.game_type || replay?.mode || null, winner, loser, winner_identity: winnerIdentity, loser_identity: loserIdentity, winner_clan_id: winnerIdentity?.clan_id ?? null, loser_clan_id: loserIdentity?.clan_id ?? null, is_draw: isDraw, teams: enrichedTeams, squadrons: squadronIdentities, replay: replay || { available: false, chat_log: [], battle_log: [], teams: enrichedTeams, }, wl, points_diffs, }; } function parseDateFilterValue(rawValue, isEndBoundary = false) { if (rawValue == null || rawValue === '') return null; const value = String(rawValue).trim(); if (!value) return null; // Accept epoch timestamps from frontend filters. 10 digits = seconds, // 13 digits = milliseconds. if (/^\d+$/.test(value)) { const numeric = Number(value); if (Number.isFinite(numeric)) { if (value.length >= 13) return Math.floor(numeric / 1000); return numeric; } return null; } const date = new Date(value); if (isNaN(date.getTime())) return null; // Date-only strings should include the full UTC day for end boundaries. if (isEndBoundary && /^\d{4}-\d{2}-\d{2}$/.test(value)) { date.setUTCHours(23, 59, 59, 999); } return Math.floor(date.getTime() / 1000); } // Helper function to parse date filters from query parameters function parseDateFilters(req) { const { start_date, end_date, season, week } = req.query; let startTimestamp = null; let endTimestamp = null; let filterDescription = null; // Frontend already converts seasons to concrete timestamps/dates, so just // normalize the passed values here. startTimestamp = parseDateFilterValue(start_date, false); endTimestamp = parseDateFilterValue(end_date, true); // Build description if (startTimestamp && endTimestamp) { filterDescription = `${start_date} to ${end_date}`; } else if (startTimestamp) { filterDescription = `from ${start_date}`; } else if (endTimestamp) { filterDescription = `up to ${end_date}`; } return { startTimestamp, endTimestamp, filterDescription, hasFilter: startTimestamp !== null || endTimestamp !== null }; } // Helper function to create date filter metadata for responses function createDateFilterMetadata(filters, start_date, end_date, season, week) { if (!filters.hasFilter) { return { applied: false, description: "All-time stats" }; } return { applied: true, start_date: filters.startTimestamp ? new Date(filters.startTimestamp * 1000).toISOString() : null, end_date: filters.endTimestamp ? new Date(filters.endTimestamp * 1000).toISOString() : null, season: season || null, week: week ? parseInt(week) : null, description: filters.filterDescription || "Custom date range" }; } app.use((req, res, next) => { const start = Date.now(); log.info(`${req.method} ${req.url}`, { ip: req.ip || req.connection.remoteAddress, userAgent: req.get('User-Agent'), params: req.params, query: req.query }); res.on('finish', () => { const duration = Date.now() - start; log.info(`${req.method} ${req.url} - ${res.statusCode}`, { duration: `${duration}ms`, size: res.get('Content-Length') || 'unknown' }); }); next(); }); const DB_PATH = path.join(STORAGE_ROOT, 'sq_battles.db'); if (!fs.existsSync(DB_PATH)) { log.error('Database file does not exist', null, { dbPath: DB_PATH, message: 'Run the main bot first to create the database, or create it manually' }); process.exit(1); } // Dedicated read-only connection for heavy leaderboard aggregations. SQLite // serializes statements per Database object, so without this a 60-90s // leaderboard scan blocks every other API call (player profile, squadron // details, search). WAL mode supports concurrent readers, so a second // connection is the cheapest fix. const heavyDb = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => { if (err) log.warn('Failed to open heavy reader connection, falling back to main db:', err.message); else log.info('Heavy-reader connection open'); }); const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => { if (err) { log.error('Failed to open database', err, { dbPath: DB_PATH }); process.exit(1); } log.info('Connected to SQLite database in read-only mode', { dbPath: DB_PATH }); // Create performance indexes using a separate RW connection (one-time, idempotent) const rwDb = new sqlite3.Database(DB_PATH, (rwErr) => { if (rwErr) { log.warn('Could not open DB for index creation:', rwErr.message); return; } rwDb.serialize(() => { rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_uid_session ON player_games_hist(UID, session_id DESC)', (e) => { if (!e) log.info('Index ensured: idx_pgh_uid_session'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_uid_endtime ON player_games_hist(UID, endtime_unix)', (e) => { if (!e) log.info('Index ensured: idx_pgh_uid_endtime'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_nick ON player_games_hist(nick COLLATE NOCASE)', (e) => { if (!e) log.info('Index ensured: idx_pgh_nick'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_squadron ON player_games_hist(squadron_name)', (e) => { if (!e) log.info('Index ensured: idx_pgh_squadron'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_vehicle_internal_nocase ON player_games_hist(vehicle_internal COLLATE NOCASE)', (e) => { if (!e) log.info('Index ensured: idx_pgh_vehicle_internal_nocase'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_session ON match_summary(session_id)', (e) => { if (!e) log.info('Index ensured: idx_ms_session'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_map_name ON match_summary(map_name)', (e) => { if (!e) log.info('Index ensured: idx_ms_map_name'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_endtime ON match_summary(endtime_unix)', (e) => { if (!e) log.info('Index ensured: idx_ms_endtime'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_winning_sq ON match_summary(winning_sq)', (e) => { if (!e) log.info('Index ensured: idx_ms_winning_sq'); }); rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_losing_sq ON match_summary(losing_sq)', (e) => { if (!e) log.info('Index ensured: idx_ms_losing_sq'); }); // Composite index for the squadron leaderboard's // GROUP BY clan_id, squadron_name aggregation. Without this the // query falls back to a temp B-tree sort over all 4M+ rows. rwDb.run( 'CREATE INDEX IF NOT EXISTS idx_pgh_clanid_squadron ON player_games_hist(clan_id, squadron_name)', (e) => { if (!e) log.info('Index ensured: idx_pgh_clanid_squadron'); } ); rwDb.run( 'CREATE INDEX IF NOT EXISTS idx_pgh_clanid_uid ON player_games_hist(clan_id, UID)', (e) => { if (!e) log.info('Index ensured: idx_pgh_clanid_uid'); } ); rwDb.close(() => log.info('Performance indexes ready')); }); }); // Use TRUNCATE so the WAL is actively checkpointed once readers clear. runWalCheckpoint('TRUNCATE', 'WAL checkpoint completed successfully', 'WAL checkpoint failed:'); db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='player_games_hist'", (err, row) => { if (err) { log.error('Failed to check table existence', err); process.exit(1); } else if (!row) { log.error('CRITICAL: player_games_hist table does not exist', null, { message: 'Database exists but player_games_hist table is missing', dbPath: DB_PATH }); process.exit(1); } else { log.info('Database table verification passed', { table: 'player_games_hist' }); db.all("PRAGMA table_info(player_games_hist)", (err, cols) => { if (!err) hasSquadronColumn = cols.some(c => c.name === 'squadron_name'); log.info('Schema check complete', { hasSquadronColumn }); }); } }); db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='match_summary'", (err, row) => { if (err) { log.error('Failed to check match_summary table existence', err); } else if (!row) { log.warn('match_summary table does not exist - /api/live endpoint will not work', { message: 'Run the bot to generate match data first', dbPath: DB_PATH }); } else { log.info('Database table verification passed', { table: 'match_summary' }); } }); // Pre-warm caches that gate the analytics vehicle search/load flows. // ensureVehicleList does a GROUP BY across player_games_hist; running it // at boot means the first user click pays no cold-cache penalty, and we // use it as the readiness signal for the heavy-path gate. ensureVehicleList((err, list) => { if (err) log.warn('Vehicle list pre-warm failed:', err.message); else log.info('Vehicle list cache pre-warmed', { count: list.length }); markServerReady(err ? 'vehicle_list_error' : 'vehicle_list_warm'); }); try { const t0 = Date.now(); const resp = buildTranslationsResponse(); log.info('Vehicle translations cache pre-warmed', { source: resp.source, count: Object.keys(resp.vehicles || {}).length, ms: Date.now() - t0, }); } catch (e) { log.warn('Vehicle translations pre-warm failed:', e.message); } }); app.get('/api/player/:uid', (req, res) => { const { uid } = req.params; const cacheKey = `player_${uid}_${req.query.start_date || ''}_${req.query.end_date || ''}_${req.query.season || ''}_${req.query.week || ''}`; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); if (!uid) { return res.status(400).json({ error: 'UID parameter is required' }); } // Parse date filters const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; const latestNickQuery = ` SELECT nick, squadron_name, clan_id FROM player_games_hist WHERE UID = ? AND nick NOT LIKE 'coop/%' ORDER BY session_id DESC LIMIT 1 `; const aggregatedStatsQuery = ` SELECT vehicle_internal, vehicle, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses, COUNT(*) as total_battles FROM player_games_hist WHERE UID = ? AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) GROUP BY vehicle_internal ORDER BY vehicle_internal `; const queryParams = [ uid, dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp ]; const nickPromise = new Promise((resolve, reject) => { db.get(latestNickQuery, [uid], (err, row) => { if (err) reject(err); else resolve(row); }); }); const statsPromise = new Promise((resolve, reject) => { db.all(aggregatedStatsQuery, queryParams, (err, rows) => { if (err) reject(err); else resolve(rows); }); }); const lookupPromise = new Promise((resolve) => { loadSquadronLookupCached(resolve); }); // Authoritative current-roster membership. squadron_members is updated // by the periodic squadron-sync, so this reflects "what squadron this // player belongs to right now" — beats the most-recent player_games_hist // row which can be stale post-rename if the player hasn't played a SQB // since the rename. const rosterPromise = new Promise((resolve) => { const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); if (!fs.existsSync(squadronsDbPath)) return resolve(null); const sdb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) return resolve(null); sdb.get( 'SELECT clan_id FROM squadron_members WHERE uid = ? ORDER BY updated_at DESC LIMIT 1', [uid], (qerr, row) => { sdb.close(); if (qerr || !row || row.clan_id == null) return resolve(null); resolve(Number(row.clan_id)); } ); }); }); Promise.all([nickPromise, statsPromise, lookupPromise, rosterPromise]) .then(async ([nickRow, vehicleRows, squadronLookup, rosterClanId]) => { if (!nickRow) { return jsonError(res, 404, 'Player not found', { uid: normalizeUid(uid) }); } const identity = await loadPlayerIdentitySnapshot(uid, squadronLookup, { latestRow: nickRow, preferredClanId: rosterClanId, }); loadPerformanceBenchmarksCached(dateFilters, (benchmarks) => { const vehicles = vehicleRows.map(row => { const wins = row.wins || 0; const losses = row.losses || 0; const totalBattles = row.total_battles || 0; let winRate = '0.0'; if (totalBattles > 0 && wins >= 0) { winRate = ((wins / totalBattles) * 100).toFixed(1); } return { vehicle_internal: row.vehicle_internal, vehicle: normalizeVehicleName(row.vehicle), stats: { ground_kills: row.total_ground_kills, air_kills: row.total_air_kills, assists: row.total_assists, captures: row.total_captures, deaths: row.total_deaths, wins, losses, total_battles: totalBattles, win_rate: winRate } }; }); queryPlayerRatingStats(uid, dateFilters).then(ratingStats => { const playerPerformance = computePerformanceScore(ratingStats || { games: 0 }, benchmarks.players); const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), uid: normalizeUid(uid), nick: identity?.nick || nickRow.nick, previous_nicks: identity?.previous_nicks || [], squadron_name: identity?.squadron_name || '', squadron_long_name: identity?.squadron_long_name || '', squadron_clan_id: identity?.squadron_clan_id ?? null, previous_squadron_names: identity?.previous_squadron_names || [], performance: playerPerformance, vehicles, total_vehicles: vehicleRows.length }; setCachedResponse(cacheKey, response); res.json(response); }); }); }) .catch(err => { log.error('Database error in player query', err, { uid, endpoint: '/api/player/:uid' }); jsonError(res, 500, 'Database error occurred', { errorCode: 'DB_PLAYER_QUERY_FAILED' }); }); }); app.get('/api/player/:uid/games', (req, res) => { const { uid } = req.params; const cacheKey = `games_${uid}_${req.query.start_date || ''}_${req.query.end_date || ''}_${req.query.season || ''}_${req.query.week || ''}`; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); if (!uid) { return res.status(400).json({ error: 'UID parameter is required' }); } // Parse date filters const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; const latestNickQuery = ` SELECT nick, squadron_name, clan_id FROM player_games_hist WHERE UID = ? AND nick NOT LIKE 'coop/%' ORDER BY session_id DESC LIMIT 1 `; const recentGamesQuery = ` SELECT session_id, nick, squadron_name, vehicle_internal, vehicle, ground_kills, air_kills, assists, captures, deaths, victor_bool as result, endtime_unix FROM player_games_hist WHERE UID = ? AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) ORDER BY session_id DESC `; db.get(latestNickQuery, [uid], (err, nickRow) => { if (err) { log.error('Database error in nick query', err, { uid: uid, query: 'latestNickQuery', endpoint: '/api/player/:uid/games' }); return res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_NICK_QUERY_FAILED' }); } if (!nickRow) { return jsonError(res, 404, 'Player not found', { uid: normalizeUid(uid) }); } const playerNick = nickRow.nick; const playerSquadron = nickRow.squadron_name; // Build query parameters with date filtering const queryParams = [ uid, dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp ]; db.all(recentGamesQuery, queryParams, (err, gameRows) => { if (err) { log.error('Database error in games query', err, { uid: uid, endpoint: '/api/player/:uid/games' }); return res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_GAMES_QUERY_FAILED' }); } const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), uid: normalizeUid(uid), nick: playerNick, squadron_name: playerSquadron, games: gameRows.map(row => ({ session_id: row.session_id, vehicle_internal: row.vehicle_internal, vehicle: normalizeVehicleName(row.vehicle), squadron_name: row.squadron_name, timestamp: row.endtime_unix || 0, stats: { ground_kills: row.ground_kills || 0, air_kills: row.air_kills || 0, assists: row.assists || 0, captures: row.captures || 0, deaths: row.deaths || 0 }, result: row.result || 'Unknown' })), total_games_returned: gameRows.length }; setCachedResponse(cacheKey, response); res.json(response); }); }); }); app.get('/api/player/:uid/history', (req, res) => { const { uid } = req.params; const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; const cacheKey = `history_${uid}_${start_date || ''}_${end_date || ''}_${season || ''}_${week || ''}`; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); const historyQuery = ` SELECT date(endtime_unix, 'unixepoch') as period, COUNT(DISTINCT session_id) as battles, ROUND(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1.0 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0), 1) as win_rate, ROUND(CAST(SUM(ground_kills + air_kills) AS REAL) / MAX(SUM(deaths), 1), 2) as kdr FROM player_games_hist WHERE UID = ? AND endtime_unix IS NOT NULL AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) GROUP BY period ORDER BY period ASC `; const params = [ uid, dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp, ]; db.all(historyQuery, params, (err, rows) => { if (err) return jsonError(res, 500, 'DB error'); const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), uid: normalizeUid(uid), days_with_battles_only: true, history: rows, }; setCachedResponse(cacheKey, response); res.json(response); }); }); app.get('/api/search/:nickname', (req, res) => { const { nickname } = req.params; if (!nickname || nickname.trim().length === 0) { return res.status(400).json({ error: 'Nickname parameter is required' }); } const cacheKey = `search_${nickname.toLowerCase()}`; const cached = getCachedResponse(cacheKey); if (cached) { log.info('Returning cached search results'); return res.json(cached); } const searchQuery = ` SELECT UID FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE GROUP BY UID ORDER BY MAX(session_id) DESC, MAX(endtime_unix) DESC LIMIT 50 `; const latestNickQuery = ` SELECT nick, squadron_name, clan_id FROM player_games_hist WHERE UID = ? AND nick NOT LIKE 'coop/%' ORDER BY session_id DESC LIMIT 1 `; const searchTerm = `%${nickname.trim()}%`; db.all(searchQuery, [searchTerm], (err, rows) => { if (err) { log.error('Database error in search query', err, { searchTerm: searchTerm, nickname: nickname, endpoint: '/api/search/:nickname' }); return res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SEARCH_QUERY_FAILED' }); } loadSquadronLookupCached((squadronLookup) => { const lookups = rows.map(row => (async () => { const latestRow = await dbGetAsync(db, latestNickQuery, [row.UID]); if (!latestRow) { return { uid: normalizeUid(row.UID), nick: '', previous_nicks: [], squadron_name: '', squadron_long_name: '', squadron_clan_id: null, previous_squadron_names: [], }; } return loadPlayerIdentitySnapshot(row.UID, squadronLookup, { latestRow }); })()); Promise.all(lookups).then(results => { const finalResults = results.filter(Boolean); const response = { search_term: nickname.trim(), results: finalResults, total_found: finalResults.length, limited_to: 50 }; log.info('Search query executed', { searchTerm: searchTerm, nickname: nickname, resultsFound: finalResults.length, firstFewResults: finalResults.slice(0, 3) }); setCachedResponse(cacheKey, response); res.json(response); }).catch(identityErr => { log.error('Failed to build player identity search response', identityErr, { nickname, endpoint: '/api/search/:nickname' }); jsonError(res, 500, 'Database error occurred', { errorCode: 'DB_SEARCH_QUERY_FAILED' }); }); }); }); }); // Guard debug endpoints - only allow from localhost const debugGuard = (req, res, next) => { const ip = req.ip || req.connection.remoteAddress || ''; const isLocal = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; if (!isLocal) { return res.status(404).json({ error: 'Not found' }); } next(); }; app.use('/api/debug', debugGuard); app.get('/api/debug/stats', requireAdminBearer, (req, res) => { const schemaQuery = `PRAGMA table_info(player_games_hist)`; db.all(schemaQuery, (err, schema) => { if (err) { log.error('Database error in schema query', err); return res.status(500).json({ error: 'Database schema error' }); } const statsQuery = ` SELECT COUNT(*) as total_records, COUNT(DISTINCT UID) as unique_players, COUNT(DISTINCT session_id) as unique_sessions, MIN(session_id) as oldest_session, MAX(session_id) as newest_session FROM player_games_hist `; const sampleQuery = ` SELECT * FROM player_games_hist ORDER BY session_id DESC LIMIT 3 `; db.get(statsQuery, (err, stats) => { if (err) { log.error('Database error in stats query', err); return res.status(500).json({ error: 'Database stats error' }); } db.all(sampleQuery, (err, samples) => { if (err) { log.error('Database error in sample query', err); return res.status(500).json({ error: 'Database sample error' }); } res.json({ table_schema: schema.map(col => ({ name: col.name, type: col.type, notnull: col.notnull, default_value: col.dflt_value })), database_stats: stats, sample_records: samples, timestamp: new Date().toISOString() }); }); }); }); }); app.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString(), database: 'Connected', ready: serverReady, uptime_ms: Date.now() - startupReadyAt, }); }); app.get('/api/live', (req, res) => { const requestStart = Date.now(); const clientIP = req.ip || req.connection.remoteAddress; const userAgent = req.get('User-Agent') || 'Unknown'; log.info('API request received', { endpoint: '/api/live', method: 'GET', clientIP: clientIP, userAgent: userAgent, queryParams: req.query }); // Parse date filters const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; const limit = parseInt(req.query.limit) || 15; const maxLimit = 200; log.debug('Parsed request parameters', { requestedLimit: req.query.limit, parsedLimit: limit, maxLimit: maxLimit }); if (limit > maxLimit) { log.warn('Request rejected - limit too high', { requestedLimit: limit, maxLimit: maxLimit, clientIP: clientIP }); return res.status(400).json({ error: `Limit cannot exceed ${maxLimit}` }); } const query = ` SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq, winning_team_json, losing_team_json, game_type FROM match_summary WHERE (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) ORDER BY endtime_unix DESC LIMIT ? `; // Build query parameters with date filtering const queryParams = [ dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp, limit ]; log.debug('Executing database query', { query: query.replace(/\s+/g, ' ').trim(), parameters: queryParams }); const queryStart = Date.now(); db.all(query, queryParams, (err, rows) => { const queryTime = Date.now() - queryStart; if (err) { log.error('Database error in /api/live', err, { queryTime: `${queryTime}ms`, parameters: [limit], clientIP: clientIP }); return res.status(500).json({ error: 'Database error' }); } log.info('Database query completed', { rowsReturned: rows.length, queryTime: `${queryTime}ms`, limit: limit }); loadSquadronLookupCached((squadronLookup) => { let jsonParseErrors = 0; const matches = rows.map((row, index) => { let winningTeam, losingTeam; try { winningTeam = row.winning_team_json ? parseJsonColumn(row.winning_team_json) : null; log.debug('Parsed winning team JSON', { session_id: row.session_id, hasWinningTeam: !!winningTeam, playerCount: winningTeam?.players?.length || 0 }); } catch (e) { jsonParseErrors++; log.warn('Failed to parse winning_team_json', { session_id: row.session_id, error: e.message, jsonLength: row.winning_team_json?.length || 0 }); winningTeam = null; } try { losingTeam = row.losing_team_json ? parseJsonColumn(row.losing_team_json) : null; log.debug('Parsed losing team JSON', { session_id: row.session_id, hasLosingTeam: !!losingTeam, playerCount: losingTeam?.players?.length || 0 }); } catch (e) { jsonParseErrors++; log.warn('Failed to parse losing_team_json', { session_id: row.session_id, error: e.message, jsonLength: row.losing_team_json?.length || 0 }); losingTeam = null; } const matchData = { session_id: row.session_id, map_name: row.map_name, endtime_unix: row.endtime_unix, endtime_iso: new Date(row.endtime_unix * 1000).toISOString(), winning_squadron: row.winning_sq, losing_squadron: row.losing_sq, winning_tag: cleanText(normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq })?.squadron_tagged) || row.winning_sq, losing_tag: cleanText(normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq })?.squadron_tagged) || row.losing_sq, winning_team: normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq }), losing_team: normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq }), game_type: row.game_type || "", }; log.debug('Processed match record', { index: index + 1, session_id: row.session_id, map: row.map_name, winner: row.winning_sq, loser: row.losing_sq, endtime: matchData.endtime_iso }); return matchData; }); const totalTime = Date.now() - requestStart; const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), total_matches: matches.length, limit: limit, matches: matches }; log.info('API response sent', { endpoint: '/api/live', matchesReturned: matches.length, jsonParseErrors: jsonParseErrors, totalRequestTime: `${totalTime}ms`, queryTime: `${queryTime}ms`, clientIP: clientIP, responseSize: JSON.stringify(response).length }); res.json(response); }); }); }); // ── Single match detail ── app.get('/api/match/:sessionId', (req, res) => { const { sessionId } = req.params; const clientIP = req.headers['x-forwarded-for'] || req.socket.remoteAddress; const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) { return jsonError(res, 400, 'Invalid session ID format. Must be hexadecimal.'); } const cacheKey = `match_${sessionId}_${start_date || ''}_${end_date || ''}_${season || ''}_${week || ''}`; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); const query = ` SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq, winning_team_json, losing_team_json, game_type FROM match_summary WHERE session_id = ? AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) `; const queryParams = [ sessionId, dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp, ]; db.get(query, queryParams, (err, row) => { if (err) { log.error('Database error in /api/match/:sessionId', err, { sessionId }); return jsonError(res, 500, 'Database error'); } if (!row) { return jsonError(res, 404, 'Match not found', { session_id: sessionId }); } let winningTeam = null, losingTeam = null; try { winningTeam = row.winning_team_json ? parseJsonColumn(row.winning_team_json) : null; } catch (e) { log.warn('Failed to parse winning_team_json', { sessionId }); } try { losingTeam = row.losing_team_json ? parseJsonColumn(row.losing_team_json) : null; } catch (e) { log.warn('Failed to parse losing_team_json', { sessionId }); } const replayPath = replayDataPath(sessionId); const replayAvailable = fs.existsSync(replayPath); loadSquadronLookupCached((squadronLookup) => { const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), session_id: row.session_id, map_name: row.map_name, endtime_unix: row.endtime_unix, endtime_iso: new Date(row.endtime_unix * 1000).toISOString(), mode: row.game_type || '', winning_squadron: row.winning_sq, losing_squadron: row.losing_sq, winning_tag: cleanText(normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq })?.squadron_tagged) || row.winning_sq, losing_tag: cleanText(normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq })?.squadron_tagged) || row.losing_sq, winning_team: normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq }), losing_team: normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq }), game_type: row.game_type || '', replay_available: replayAvailable }; setCachedResponse(cacheKey, response); log.info('Match detail served', { sessionId, clientIP }); res.json(response); }); }); }); // ── Match replay data (chat log, battle log, etc.) ── app.get('/api/match/:sessionId/replay', (req, res) => { const { sessionId } = req.params; if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) { return res.status(400).json({ error: 'Invalid session ID format.' }); } const replayPath = replayDataPath(sessionId); if (!fs.existsSync(replayPath)) { return res.json({ available: false, session_id: sessionId }); } try { const data = JSON.parse(zlib.gunzipSync(fs.readFileSync(replayPath))); res.json({ available: true, session_id: sessionId, map: data.map || null, mode: data.mode || null, duration: data.duration || null, draw: data.draw || false, chat_log: data.chat_log || [], battle_log: data.battle_log || [], teams: data.teams || [], winning_team_squadron: data.winning_team_squadron || null, losing_team_squadron: data.losing_team_squadron || null }); } catch (e) { log.error('Failed to read replay data', e, { sessionId }); res.status(500).json({ error: 'Failed to read replay data' }); } }); app.get('/api/match/:sessionId/scoreboard', async (req, res) => { const { sessionId } = req.params; if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) { return jsonError(res, 400, 'Invalid session ID format.'); } try { const context = await loadScoreboardContext(sessionId); if (!context) { return jsonError(res, 404, 'Match not found', { session_id: sessionId }); } res.json({ session_id: context.session_id, match_details: context.match_details, map_name: context.map_name, game_type: context.game_type, mode: context.mode || context.game_type || context.replay?.mode || null, winner: context.winner, loser: context.loser, is_draw: context.is_draw, teams: context.teams, wl: context.wl, points_diffs: context.points_diffs, replay: context.replay, }); } catch (err) { log.error('Failed to load scoreboard context', err, { sessionId }); res.status(500).json({ error: 'Failed to load scoreboard context' }); } }); app.get('/api/squadrons/:squadronname/comps', async (req, res) => { const { squadronname } = req.params; if (!squadronname) { return res.status(400).json({ error: 'Squadron name parameter is required' }); } const windowSeconds = parseInt(req.query.window_seconds) || 3600; const limit = parseInt(req.query.limit) || 10; try { const response = await loadRecentComps(squadronname, windowSeconds, limit); if (!response) { return res.status(404).json({ error: 'Comp history not found', squadron: String(squadronname).toUpperCase(), }); } res.json(response); } catch (err) { log.error('Failed to load squadron comps', err, { squadronname }); res.status(500).json({ error: 'Failed to load squadron comps' }); } }); // ── Search games by player name/UID, map, squadron, and/or time ── app.get('/api/games/search', (req, res) => { const { player, map, squadron, time_from, time_to } = req.query; const clientIP = req.headers['x-forwarded-for'] || req.socket.remoteAddress; let limit = parseInt(req.query.limit) || 50; if (limit > 200) limit = 200; const isUid = player && /^\d+$/.test(player); // Parse time filters (unix seconds) const timeFrom = time_from ? parseInt(time_from) : null; const timeTo = time_to ? parseInt(time_to) : null; // Build the pipeline: find session_ids from player if given, then fetch from match_summary const findSessions = (callback) => { if (!player) return callback(null, null); // null means no player filter if (isUid) { db.all( 'SELECT DISTINCT session_id FROM player_games_hist WHERE UID = ? ORDER BY endtime_unix DESC LIMIT ?', [player, limit], (err, rows) => callback(err, rows ? rows.map(r => r.session_id) : []) ); } else { // Try exact match first (fast, uses index), then prefix, then substring fallback db.all( 'SELECT DISTINCT session_id FROM player_games_hist WHERE nick = ? COLLATE NOCASE ORDER BY endtime_unix DESC LIMIT ?', [player, limit], (err, rows) => { if (err) return callback(err, []); if (rows && rows.length > 0) return callback(null, rows.map(r => r.session_id)); // Fallback: prefix match (can use index) db.all( 'SELECT DISTINCT session_id FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE ORDER BY endtime_unix DESC LIMIT ?', [`${player}%`, limit], (err2, rows2) => { if (err2) return callback(err2, []); if (rows2 && rows2.length > 0) return callback(null, rows2.map(r => r.session_id)); // Last resort: substring match (full scan, but only if exact/prefix found nothing) db.all( 'SELECT DISTINCT session_id FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE ORDER BY endtime_unix DESC LIMIT ?', [`%${player}%`, limit], (err3, rows3) => callback(err3, rows3 ? rows3.map(r => r.session_id) : []) ); } ); } ); } }; findSessions((err, sessionIds) => { if (err) { log.error('Database error searching player sessions', err); return res.status(500).json({ error: 'Database error' }); } // If player filter returned no results if (sessionIds !== null && sessionIds.length === 0) { return res.json({ total_matches: 0, matches: [] }); } let query, params; const selectCols = 'session_id, map_name, endtime_unix, winning_sq, losing_sq, winning_team_json, losing_team_json, game_type'; // Build WHERE conditions and params for match_summary filters const conditions = []; const condParams = []; if (sessionIds !== null) { const placeholders = sessionIds.map(() => '?').join(','); conditions.push(`session_id IN (${placeholders})`); condParams.push(...sessionIds); } if (map) { conditions.push('map_name = ?'); condParams.push(map); } // Squadron filter: text LIKE handles fuzzy/orphan matches; if the input // resolves to a clan_id we also include matches by winning/losing // clan_id so renamed squadrons stay attached. if (squadron) { const sqClanId = resolveClanIdSync(squadron); if (sqClanId != null) { conditions.push( '(winning_sq LIKE ? COLLATE NOCASE OR losing_sq LIKE ? COLLATE NOCASE ' + 'OR winning_clan_id = ? OR losing_clan_id = ?)' ); condParams.push(`%${squadron}%`, `%${squadron}%`, sqClanId, sqClanId); } else { conditions.push('(winning_sq LIKE ? COLLATE NOCASE OR losing_sq LIKE ? COLLATE NOCASE)'); condParams.push(`%${squadron}%`, `%${squadron}%`); } } if (timeFrom) { conditions.push('endtime_unix >= ?'); condParams.push(timeFrom); } if (timeTo) { conditions.push('endtime_unix <= ?'); condParams.push(timeTo); } const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : ''; const limitClause = sessionIds !== null ? '' : ` LIMIT ?`; query = `SELECT ${selectCols} FROM match_summary${where} ORDER BY endtime_unix DESC${limitClause}`; params = sessionIds !== null ? condParams : [...condParams, limit]; db.all(query, params, (err, rows) => { if (err) { log.error('Database error searching matches', err); return res.status(500).json({ error: 'Database error' }); } loadSquadronLookupCached((squadronLookup) => { const matches = (rows || []).map(row => { let winningTeam = null, losingTeam = null; try { winningTeam = row.winning_team_json ? parseJsonColumn(row.winning_team_json) : null; } catch (e) {} try { losingTeam = row.losing_team_json ? parseJsonColumn(row.losing_team_json) : null; } catch (e) {} return { session_id: row.session_id, map_name: row.map_name, endtime_unix: row.endtime_unix, endtime_iso: new Date(row.endtime_unix * 1000).toISOString(), winning_squadron: row.winning_sq, losing_squadron: row.losing_sq, winning_tag: cleanText(normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq })?.squadron_tagged) || row.winning_sq, losing_tag: cleanText(normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq })?.squadron_tagged) || row.losing_sq, winning_team: normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq }), losing_team: normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq }), game_type: row.game_type || '' }; }); log.info('Games search completed', { player, map, squadron, time_from, time_to, results: matches.length, clientIP }); res.json({ total_matches: matches.length, matches }); }); }); }); }); // ── Distinct map names ── app.get('/api/maps', (req, res) => { const cacheKey = 'maps_list'; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); db.all('SELECT DISTINCT map_name FROM match_summary WHERE map_name IS NOT NULL ORDER BY map_name', (err, rows) => { if (err) { log.error('Database error in /api/maps', err); return res.status(500).json({ error: 'Database error' }); } // Deduplicate: strip mode prefix like "[Conquest #1] ", normalize case, filter blanks const seen = new Map(); // lowercase clean name -> first original clean name for (const r of rows) { const raw = (r.map_name || '').trim(); if (!raw) continue; const clean = raw.replace(/^\s*\[[^\]]+\]\s*/, '').trim(); if (!clean) continue; const key = clean.toLowerCase(); if (!seen.has(key)) seen.set(key, clean); } const maps = [...seen.values()].sort((a, b) => a.localeCompare(b)); const response = { maps }; setCachedResponse(cacheKey, response); res.json(response); }); }); app.get('/api/seasons', (req, res) => { try { res.json(seasonsUtil.getSeasonDetails()); } catch (err) { log.error('Error in /api/seasons', err); res.status(500).json({ error: 'Failed to load season data' }); } }); app.get('/api/squadrons/resolve', (req, res) => { const shorts = normalizeQueryList(req.query.short ?? req.query.shorts); const tags = normalizeQueryList(req.query.tag ?? req.query.tags); const longs = normalizeQueryList(req.query.long ?? req.query.longs); // `name=` is alias-aware: tries the live cache first, then squadron_name_aliases // (covers long/short/tag renames), squadron_name_history (legacy long_name), // and finally player_games_hist (catches short-tag renames not in the alias // table). Use it when the input could be a historical name. const names = normalizeQueryList(req.query.name ?? req.query.names); if (!shorts.length && !tags.length && !longs.length && !names.length) { return res.status(400).json({ error: 'At least one short, tag, long, or name value is required' }); } loadSquadronLookupCached((squadronLookup) => { const results = []; const seen = new Set(); const append = (resolved) => { const dedupeKey = resolved.resolved ? `clan:${String(resolved.clan_id ?? resolved.short_name ?? resolved.tag_name).toLowerCase()}` : `input:${resolved.input.toLowerCase()}`; if (seen.has(dedupeKey)) return; seen.add(dedupeKey); results.push(resolved); }; const appendTyped = (value, resolvedBy) => { const key = String(value || '').trim(); if (!key) return; const row = squadronLookup[key] || squadronLookup[key.toLowerCase()] || null; append(formatSquadronResolution(key, row, resolvedBy)); }; shorts.forEach(value => appendTyped(value, 'short_name')); tags.forEach(value => appendTyped(value, 'tag_name')); longs.forEach(value => appendTyped(value, 'long_name')); const respond = () => { res.json({ requested: { shorts, tags, longs, names }, total_requested: shorts.length + tags.length + longs.length + names.length, total_found: results.filter(row => row.resolved).length, results }); }; if (!names.length) return respond(); const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); const rowForClanId = (cid) => squadronLookup[`__cid_${cid}`] || squadronLookup[String(cid)] || null; const longNameForClanId = (cid) => { const row = rowForClanId(cid); return row ? row.long_name : null; }; const buildFromClanId = (key, cid, via) => { const row = rowForClanId(cid); if (row) { const r = formatSquadronResolution(key, row, 'name'); r.via = via; return r; } const r = formatSquadronResolution(key, null, 'name'); r.resolved = true; r.clan_id = cid; r.long_name = longNameForClanId(cid); r.via = via; return r; }; const resolveOneName = (rawName) => new Promise((done) => { const key = String(rawName || '').trim(); if (!key) return done(null); const hit = squadronLookup[key] || squadronLookup[key.toLowerCase()]; if (hit && hit.clan_id != null) { const r = formatSquadronResolution(key, hit, 'name'); r.via = 'squadrons_data'; return done(r); } const tryPlayerGamesHist = () => { db.get( `SELECT clan_id FROM player_games_hist WHERE squadron_name = ? COLLATE NOCASE AND clan_id IS NOT NULL ORDER BY endtime_unix DESC LIMIT 1`, [key], (qerr, row) => { if (qerr || !row || row.clan_id == null) { return done(formatSquadronResolution(key, null, 'name')); } done(buildFromClanId(key, row.clan_id, 'player_games_hist')); } ); }; if (!fs.existsSync(squadronsDbPath)) return tryPlayerGamesHist(); const sdb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (oerr) => { if (oerr) return tryPlayerGamesHist(); sdb.get( `SELECT clan_id FROM squadron_name_aliases WHERE LOWER(name) = LOWER(?) ORDER BY last_seen DESC LIMIT 1`, [key], (aerr, arow) => { if (!aerr && arow && arow.clan_id != null) { try { sdb.close(); } catch (_) {} return done(buildFromClanId(key, arow.clan_id, 'name_aliases')); } sdb.get( `SELECT clan_id FROM squadron_name_history WHERE LOWER(long_name) = LOWER(?) ORDER BY last_seen DESC LIMIT 1`, [key], (herr, hrow) => { try { sdb.close(); } catch (_) {} if (!herr && hrow && hrow.clan_id != null) { return done(buildFromClanId(key, hrow.clan_id, 'name_history')); } tryPlayerGamesHist(); } ); } ); }); }); Promise.all(names.map(resolveOneName)).then((nameResults) => { for (const r of nameResults) { if (r) append(r); } respond(); }); }); }); const API_INFO = { name: "SREBOT Player API", version: "2.3.0", endpoints: { "GET /api/player/:uid": "Get player totals and per-vehicle stats by UID", "GET /api/player/:uid/games": "Get individual game rows for a player", "GET /api/player/:uid/history": "Get day-by-day player history", "GET /api/search/:nickname": "Search for players by nickname", "GET /api/live": "Get recent match summaries", "GET /api/match/:sessionId": "Get a single match summary by session ID", "GET /api/match/:sessionId/replay": "Get replay data for a match if available", "GET /api/match/:sessionId/scoreboard": "Get the full scoreboard context for a match", "GET /api/games/search": "Search matches by player, map, squadron, or time range", "GET /api/maps": "List distinct map names from match history", "GET /api/seasons": "Return season schedule data for filtering", "GET /api/squadrons/resolve": "Resolve squadron short/tag/long (current only) or name= (alias-aware: includes renamed squadrons) to canonical metadata", "GET /api/squadrons/:squadronname/comps": "Get recent comp snapshots for a squadron", "GET /api/leaderboard/players": "Get global player leaderboards", "GET /api/leaderboard/squadrons": "Get squadron leaderboards", "GET /api/leaderboard/vehicles": "Get vehicle-specific leaderboards", "GET /api/leaderboard/stats": "Get overall leaderboard statistics and top vehicles", "GET /api/squadrons/:squadronname": "Get squadron roster stats and summary", "GET /api/squadrons/:squadronname/history": "Get squadron battle and rating history", "GET /api/squadrons/:squadronname/games": "Get squadron match list", "GET /health": "Health check endpoint", "GET /api/info": "API information" }, filtering: { core_query_params: { start_date: "ISO date string or Unix timestamp, inclusive lower bound", end_date: "ISO date string or Unix timestamp, inclusive upper bound" }, season_filtering: "Use GET /api/seasons to map season/week windows to timestamps before querying the data endpoints.", response_metadata: "Filtered responses include a date_filter object describing the applied range" }, databases: [ { file: "sq_battles.db", purpose: "Primary battle history store and match summary cache", tables: [ { name: "player_games_hist", description: "One row per player per battle / vehicle. This is the main source for player stats, games, history, search, leaderboards, squadron stats, and battle debug endpoints.", used_by: [ "GET /api/player/:uid", "GET /api/player/:uid/games", "GET /api/player/:uid/history", "GET /api/search/:nickname", "GET /api/games/search", "GET /api/leaderboard/players", "GET /api/leaderboard/vehicles", "GET /api/leaderboard/squadrons", "GET /api/leaderboard/stats", "GET /api/squadrons/:squadronname", "GET /api/squadrons/:squadronname/history", "GET /api/squadrons/:squadronname/games", "GET /api/debug/stats", "GET /api/debug/schema", "GET /api/debug/player-sample", "GET /api/debug/player-count/:uid", "GET /api/debug/migration-status" ] }, { name: "match_summary", description: "One row per match. Stores the per-session map, winner/loser, team blobs, timestamps, and replay availability metadata.", used_by: [ "GET /api/live", "GET /api/match/:sessionId", "GET /api/match/:sessionId/replay", "GET /api/games/search", "GET /api/maps", "GET /api/squadrons/:squadronname/games", "GET /api/debug/stats" ] } ] }, { file: "squadrons.db", purpose: "Squadron roster cache, live squadron metadata, and historical squadron point snapshots", tables: [ { name: "squadrons_data", description: "Canonical squadron directory and latest leaderboard snapshot. Stores clan IDs, names, tags, membership counts, and current rating fields.", used_by: [ "GET /api/leaderboard/squadrons", "GET /api/leaderboard/stats", "GET /api/squadrons/resolve", "GET /api/squadrons/:squadronname", "GET /api/squadrons/:squadronname/history", "GET /api/squadrons/:squadronname/games", "GET /api/debug/squadrons-db-schema", "background squadron sync jobs" ] }, { name: "squadron_members", description: "Current per-squadron member roster with cached nick and points values.", used_by: [ "GET /api/squadrons/:squadronname", "GET /api/squadrons/:squadronname/games", "GET /api/leaderboard/squadrons", "background squadron member sync jobs" ] }, { name: "squadrons_points", description: "Historical squadron point snapshots keyed by unix time. Used for historical squadron point lookups and charts.", used_by: [ "GET /api/squadrons/:squadronname", "GET /api/squadrons/:squadronname/history", "background squadron points sync jobs" ] } ] }, { file: "wl.db", purpose: "Read-only win/loss standings for scoreboard rendering and recent match context", tables: [ { name: "wl_events", description: "Idempotent log of processed battle result events.", used_by: [ "GET /api/match/:sessionId/scoreboard" ] }, { name: "wl_standings", description: "Current win/loss totals per squadron.", used_by: [ "GET /api/match/:sessionId/scoreboard" ] } ] }, { file: "points.db", purpose: "Read-only cached point diffs for scoreboard rendering", tables: [ { name: "profile_member_points", description: "Current cached points per squadron member.", used_by: [ "internal replay processing", "scoreboard point diff caching" ] }, { name: "profile_totals", description: "Current cached total points per squadron.", used_by: [ "internal replay processing", "scoreboard point diff caching" ] }, { name: "game_cache", description: "Per-session cached point diffs and updated snapshots.", used_by: [ "GET /api/match/:sessionId/scoreboard", "internal replay processing" ] } ] } ] }; app.get('/api/info', (req, res) => { res.json(API_INFO); }); app.get('/api/debug/player-sample', requireAdminBearer, (req, res) => { const sampleQuery = ` SELECT UID, nick, session_id, vehicle, ground_kills, air_kills, assists, captures, deaths, victor_bool FROM player_games_hist WHERE UID IS NOT NULL LIMIT 10 `; db.all(sampleQuery, [], (err, rows) => { if (err) { return res.status(500).json({ error: err.message }); } res.json({ sample_data: rows }); }); }); app.get('/api/debug/player-count/:uid', requireAdminBearer, (req, res) => { const { uid } = req.params; const countQuery = ` SELECT UID, COUNT(*) as total_records, COUNT(DISTINCT session_id) as unique_sessions, COUNT(DISTINCT vehicle) as unique_vehicles, GROUP_CONCAT(DISTINCT vehicle) as vehicles_used FROM player_games_hist WHERE UID = ? GROUP BY UID `; db.get(countQuery, [uid], (err, row) => { if (err) { return res.status(500).json({ error: err.message }); } res.json({ player_analysis: row }); }); }); app.get('/api/debug/schema', requireAdminBearer, (req, res) => { const schemaQuery = `PRAGMA table_info(player_games_hist)`; const sampleQuery = `SELECT * FROM player_games_hist LIMIT 3`; db.all(schemaQuery, (err, schema) => { if (err) { log.error('Database error in schema query', err); return res.status(500).json({ error: 'Database schema error', details: err.message }); } db.all(sampleQuery, (err, samples) => { if (err) { log.error('Database error in sample query', err); return res.status(500).json({ error: 'Database sample error', details: err.message }); } const columnNames = samples.length > 0 ? Object.keys(samples[0]) : []; res.json({ table_name: 'player_games_hist', schema: schema.map(col => ({ name: col.name, type: col.type, notnull: col.notnull, default_value: col.dflt_value, primary_key: col.pk })), column_names: columnNames, sample_records: samples, total_columns: schema.length, timestamp: new Date().toISOString() }); }); }); }); app.get('/api/debug/squadron-names', requireAdminBearer, (req, res) => { const squadronNamesQuery = ` SELECT DISTINCT squadron_name, COUNT(*) as record_count FROM player_games_hist WHERE squadron_name IS NOT NULL AND squadron_name != 'UNKNOWN' GROUP BY squadron_name ORDER BY squadron_name LIMIT 50 `; db.all(squadronNamesQuery, [], (err, rows) => { if (err) { log.error('Database error in squadron names debug query', err); return res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SQUADRON_NAMES_DEBUG_FAILED' }); } const analysis = { total_unique_names: rows.length, squadron_names: rows, patterns: { bracketed: rows.filter(row => row.squadron_name.includes('[') && row.squadron_name.includes(']')), non_bracketed: rows.filter(row => !row.squadron_name.includes('[') && !row.squadron_name.includes(']')), mixed: rows.filter(row => (row.squadron_name.includes('[') || row.squadron_name.includes(']')) && !(row.squadron_name.includes('[') && row.squadron_name.includes(']'))) } }; analysis.potential_duplicates = []; for (let i = 0; i < rows.length; i++) { for (let j = i + 1; j < rows.length; j++) { const name1 = rows[i].squadron_name; const name2 = rows[j].squadron_name; const normalized1 = name1.replace(/[\[\]]/g, '').trim(); const normalized2 = name2.replace(/[\[\]]/g, '').trim(); if (normalized1.toLowerCase() === normalized2.toLowerCase()) { analysis.potential_duplicates.push({ name1: name1, name2: name2, normalized: normalized1, records1: rows[i].record_count, records2: rows[j].record_count }); } } } res.json(analysis); }); }); app.get('/api/debug/migration-status', requireAdminBearer, (req, res) => { const schemaQuery = `PRAGMA table_info(player_games_hist)`; db.all(schemaQuery, (err, schema) => { if (err) { log.error('Database error in migration status query', err); return res.status(500).json({ error: 'Database schema error', details: err.message }); } const columns = schema.map(col => col.name); const hasSquadronColumn = columns.includes('squadron_name'); res.json({ table_name: 'player_games_hist', has_squadron_column: hasSquadronColumn, missing_columns: hasSquadronColumn ? [] : ['squadron_name'], all_columns: columns, migration_needed: !hasSquadronColumn, migration_sql: hasSquadronColumn ? null : "ALTER TABLE player_games_hist ADD COLUMN squadron_name TEXT NOT NULL DEFAULT 'UNKNOWN'", timestamp: new Date().toISOString() }); }); }); app.get('/api/debug/squadrons-db-schema', requireAdminBearer, (req, res) => { const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); if (!fs.existsSync(squadronsDbPath)) { return res.status(404).json({ error: 'Squadrons database not found', path: squadronsDbPath }); } const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { return res.status(500).json({ error: 'Failed to open squadrons database', details: err.message }); } // Get all table names first const tablesQuery = `SELECT name FROM sqlite_master WHERE type='table'`; squadronsDb.all(tablesQuery, (err, tables) => { if (err) { squadronsDb.close(); return res.status(500).json({ error: 'Failed to get table list', details: err.message }); } const tableSchemas = {}; let completedTables = 0; if (tables.length === 0) { squadronsDb.close(); return res.json({ database_path: squadronsDbPath, tables: {}, message: 'No tables found in squadrons database' }); } tables.forEach(table => { const tableName = table.name; const schemaQuery = `PRAGMA table_info(${tableName})`; const sampleQuery = `SELECT * FROM ${tableName} LIMIT 3`; squadronsDb.all(schemaQuery, (err, schema) => { if (err) { tableSchemas[tableName] = { error: err.message }; completedTables++; if (completedTables === tables.length) { squadronsDb.close(); res.json({ database_path: squadronsDbPath, tables: tableSchemas, timestamp: new Date().toISOString() }); } return; } squadronsDb.all(sampleQuery, (err, samples) => { if (err) samples = []; tableSchemas[tableName] = { schema: schema, sample_records: samples, record_count: samples.length }; completedTables++; if (completedTables === tables.length) { squadronsDb.close(); res.json({ database_path: squadronsDbPath, tables: tableSchemas, timestamp: new Date().toISOString() }); } }); }); }); }); }); }); app.get('/api/leaderboard/players', (req, res) => { const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; // `limit` trims the response array; the full sorted set is cached under a // limit-agnostic key so different callers share work. Omit `limit` to get // every player. Cap at 10k to keep payloads sane on accidental large values. const rawLimit = parseInt(req.query.limit, 10); const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 10000) : null; log.info('Player leaderboard request received', { limit: limit ?? 'all' }); const applyLimit = (full) => { if (limit === null) return full; return { ...full, players: full.players.slice(0, limit), limit, returned: Math.min(limit, full.players.length) }; }; const cacheKey = aggregateKey('leaderboard_players', dateFilters); const compute = () => new Promise((resolve, reject) => { // Step 1: Pure aggregation — no nick lookups, single scan const statsQuery = ` SELECT p.UID as uid, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(ground_kills + air_kills) as total_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, COUNT(DISTINCT session_id) as total_battles, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, SUM(ground_kills + air_kills + assists * 0.5 + captures * 2) as total_score FROM player_games_hist p WHERE p.UID IS NOT NULL AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) GROUP BY p.UID HAVING SUM(ground_kills + air_kills) > 0 ORDER BY total_score DESC `; const queryParams = [ dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp ]; const queryStart = Date.now(); heavyDb.all(statsQuery, queryParams, (err, statsRows) => { if (err) { log.error('Database error in player leaderboard aggregation', err); return reject(err); } log.info('Player leaderboard aggregation done', { rows: statsRows.length, ms: Date.now() - queryStart }); // Step 2: Nick/squadron lookup from squadron_members cache (instant, no heavy SQL) loadNickLookupCached((nickCache) => { loadSquadronLookupCached((squadronLookup) => { const uncoveredUids = statsRows.filter(r => !nickCache[r.uid]).map(r => r.uid); const buildResponse = (fallbackMap) => { const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), timeframe: dateFilters.hasFilter ? "filtered" : "all-time", total_players: statsRows.length, players: statsRows.map(row => { const totalKills = row.total_kills || 0; const deaths = row.total_deaths || 0; const wins = row.wins || 0; const totalBattles = row.total_battles || 0; const kdr = deaths > 0 ? (totalKills / deaths) : totalKills; const winRate = totalBattles > 0 ? (wins / totalBattles) * 100 : 0; const cached = nickCache[row.uid]; const fb = fallbackMap[row.uid]; const nick = cached ? cached.nick : (fb ? fb.nick : row.uid); const sqId = resolveSquadronIdentity(cached, fb, squadronLookup); return { uid: row.uid, nick, squadron_name: sqId.tag_name, squadron_short_name: sqId.short_name, squadron_clan_id: sqId.clan_id, total_kills: totalKills, ground_kills: row.total_ground_kills || 0, air_kills: row.total_air_kills || 0, total_battles: totalBattles, wins, win_rate: parseFloat(winRate.toFixed(1)), kdr: parseFloat(kdr.toFixed(1)), deaths, assists: row.total_assists || 0, captures: row.total_captures || 0, total_score: Math.round(row.total_score || 0) }; }) }; log.info('Player leaderboard complete', { playersReturned: statsRows.length, cachedNicks: statsRows.length - uncoveredUids.length, fallbackNicks: Object.keys(fallbackMap).length, uncoveredNicks: uncoveredUids.length - Object.keys(fallbackMap).length, totalMs: Date.now() - queryStart }); resolve(response); }; // Fallback for players not in any squadron — uses (UID, endtime_unix) index if (uncoveredUids.length > 0) { const fbPlaceholders = uncoveredUids.map(() => '?').join(','); db.all(` SELECT UID as uid, nick, squadron_name, MAX(endtime_unix) FROM player_games_hist WHERE UID IN (${fbPlaceholders}) AND nick NOT LIKE 'coop/%' GROUP BY UID `, uncoveredUids, (err, fbRows) => { const fbMap = {}; if (!err && fbRows) fbRows.forEach(r => { fbMap[r.uid] = r; }); if (err) log.warn('Fallback nick lookup failed', { error: err.message }); buildResponse(fbMap); }); } else { buildResponse({}); } }); }); }); }); serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, { allowCompute: dateFilters.hasFilter, filterError: 'A date filter (start_date/end_date/season/week) is required for uncached all-time player leaderboard queries.', errorCode: 'DB_PLAYER_LEADERBOARD_FAILED', }); }); app.get('/api/leaderboard/vehicles', (req, res) => { const { vehicle } = req.query; const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; // `limit` trims the response array; cache key omits limit so different // callers share the heavy aggregation. Default unlimited, cap at 10k. const rawLimit = parseInt(req.query.limit, 10); const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 10000) : null; log.info('Vehicle leaderboard request received', { vehicleFilter: vehicle, limit: limit ?? 'all' }); const applyLimit = (full) => { if (limit === null) return full; return { ...full, vehicles: full.vehicles.slice(0, limit), limit, returned: Math.min(limit, full.vehicles.length) }; }; const cacheKey = `leaderboard_vehicles_${vehicle || 'all'}_${dateFilters.startTimestamp ?? 'all'}_${dateFilters.endTimestamp ?? 'all'}`; const compute = () => new Promise((resolve, reject) => { let statsQuery; let queryParams; if (vehicle) { statsQuery = ` SELECT vehicle, MAX(vehicle_internal) as vehicle_internal, p.UID as player_uid, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(ground_kills + air_kills) as total_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, COUNT(*) as battles, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins FROM player_games_hist p WHERE vehicle = ? COLLATE NOCASE AND p.UID IS NOT NULL AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) GROUP BY p.UID, vehicle HAVING SUM(ground_kills + air_kills) > 0 ORDER BY total_kills DESC, battles DESC `; queryParams = [vehicle, dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp]; } else { statsQuery = ` SELECT vehicle, MAX(vehicle_internal) as vehicle_internal, p.UID as player_uid, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(ground_kills + air_kills) as total_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, COUNT(*) as battles, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins FROM player_games_hist p WHERE p.UID IS NOT NULL AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) GROUP BY p.UID, vehicle HAVING SUM(ground_kills + air_kills) > 0 ORDER BY total_kills DESC, battles DESC `; queryParams = [dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp]; } const queryStart = Date.now(); heavyDb.all(statsQuery, queryParams, (err, vehicleRows) => { if (err) { log.error('Database error in vehicle leaderboard', err); return reject(err); } // Nick/squadron lookup from squadron_members cache (instant) loadNickLookupCached((nickCache) => { loadSquadronLookupCached((squadronLookup) => { const uniqueUids = [...new Set(vehicleRows.map(r => r.player_uid))]; const uncoveredUids = uniqueUids.filter(uid => !nickCache[uid]); const buildResponse = (fallbackMap) => { const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), vehicles: vehicleRows.map(row => { const totalKills = row.total_kills || 0; const deaths = row.total_deaths || 0; const wins = row.wins || 0; const battles = row.battles || 0; const kdr = deaths > 0 ? (totalKills / deaths) : totalKills; const winRate = battles > 0 ? (wins / battles) * 100 : 0; const cached = nickCache[row.player_uid]; const fb = fallbackMap[row.player_uid]; const playerNick = cached ? cached.nick : (fb ? fb.nick : row.player_uid); const sqId = resolveSquadronIdentity(cached, fb, squadronLookup); return { vehicle: row.vehicle, vehicle_internal: row.vehicle_internal || null, player_uid: normalizeUid(row.player_uid), player_nick: playerNick, player_squadron_name: sqId.tag_name, player_squadron_short_name: sqId.short_name, player_squadron_clan_id: sqId.clan_id, total_kills: totalKills, ground_kills: row.total_ground_kills || 0, air_kills: row.total_air_kills || 0, battles, wins, win_rate: parseFloat(winRate.toFixed(1)), kdr: parseFloat(kdr.toFixed(1)), deaths, assists: row.total_assists || 0, captures: row.total_captures || 0 }; }) }; log.info('Vehicle leaderboard complete', { vehiclesReturned: vehicleRows.length, totalMs: Date.now() - queryStart }); resolve(response); }; if (uncoveredUids.length > 0) { const fbPlaceholders = uncoveredUids.map(() => '?').join(','); db.all(` SELECT UID as uid, nick, squadron_name, MAX(endtime_unix) FROM player_games_hist WHERE UID IN (${fbPlaceholders}) AND nick NOT LIKE 'coop/%' GROUP BY UID `, uncoveredUids, (err, fbRows) => { const fbMap = {}; if (!err && fbRows) fbRows.forEach(r => { fbMap[r.uid] = r; }); buildResponse(fbMap); }); } else { buildResponse({}); } }); }); }); }); serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, { allowCompute: Boolean(vehicle) || dateFilters.hasFilter, filterError: 'A date filter (start_date/end_date/season/week) or vehicle filter is required for uncached all-time vehicle leaderboard queries.', errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED', }); }); app.get('/api/leaderboard/squadrons', (req, res) => { log.info('Squadron leaderboard request received'); // Parse date filters const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; const rawLimit = parseInt(req.query.limit, 10); const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 10000) : null; const applyLimit = (full) => { if (limit === null) return full; return { ...full, squadrons: full.squadrons.slice(0, limit), limit, returned: Math.min(limit, full.squadrons.length), }; }; const cacheKey = aggregateKey('leaderboard_squadrons', dateFilters); const compute = () => new Promise((resolve, reject) => { const schemaQuery = `PRAGMA table_info(player_games_hist)`; db.all(schemaQuery, (err, schema) => { if (err) { log.error('Database error in schema check', err); return reject({ status: 500, body: { error: 'Database schema error', errorCode: 'DB_SCHEMA_CHECK_FAILED' } }); } const hasSquadronColumn = schema.some(col => col.name === 'squadron_name'); if (!hasSquadronColumn) { log.info('Squadron column missing - returning empty squadron leaderboard'); const emptyResponse = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), timeframe: dateFilters.hasFilter ? "filtered" : "all-time", total_squadrons: 0, squadrons: [], migration_needed: true, message: 'Squadron data not available - squadron_name column missing from database' }; return resolve(emptyResponse); } // Group by (clan_id, squadron_name) so renamed squadrons stay // attached via clan_id while orphans (clan_id IS NULL) still group // by their text. JS-side consolidation collapses by clan_id below. const squadronStatsQuery = ` SELECT clan_id, squadron_name, COUNT(DISTINCT UID) as player_count, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(ground_kills + air_kills) as total_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, COUNT(*) as total_battles, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins FROM player_games_hist WHERE squadron_name IS NOT NULL AND squadron_name != 'UNKNOWN' AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) GROUP BY clan_id, squadron_name HAVING SUM(ground_kills + air_kills) > 0 ORDER BY SUM(ground_kills + air_kills) DESC `; const totalSquadronsQuery = ` SELECT COUNT(DISTINCT COALESCE(CAST(clan_id AS TEXT), squadron_name)) as total_squadrons FROM player_games_hist WHERE squadron_name IS NOT NULL AND squadron_name != 'UNKNOWN' AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) `; // Build query parameters with date filtering const queryParams = [ dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp ]; heavyDb.get(totalSquadronsQuery, queryParams, (err, totalRow) => { if (err) { log.error('Database error in total squadrons query', err); return reject({ status: 500, body: { error: 'Database error occurred', errorCode: 'DB_TOTAL_SQUADRONS_FAILED' } }); } heavyDb.all(squadronStatsQuery, queryParams, (err, squadronRows) => { if (err) { log.error('Database error in squadron leaderboard query', err); return reject({ status: 500, body: { error: 'Database error occurred', errorCode: 'DB_SQUADRON_LEADERBOARD_FAILED' } }); } log.info('DEBUG: Raw squadron query results', { rowCount: squadronRows.length, firstFewRows: squadronRows.slice(0, 3), queryUsed: squadronStatsQuery.replace(/\s+/g, ' ').trim() }); const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); let squadronLookup = {}; let totalSquadrons = totalRow.total_squadrons; const loadSquadronLookup = (callback) => { if (!fs.existsSync(squadronsDbPath)) { return callback(); } const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (squadronsErr) => { if (squadronsErr) { log.error('Failed to open squadrons database', squadronsErr); return callback(); } const squadronLookupQuery = `SELECT clan_id, long_name, short_name, tag_name, members, clanrating FROM squadrons_data`; squadronsDb.all(squadronLookupQuery, [], (err, squadronLookupRows) => { squadronsDb.close(); if (err) { log.error('Error loading squadron lookup data', err); return callback(); } squadronLookupRows.forEach(lookup => { if (lookup.long_name) squadronLookup[lookup.long_name] = lookup; if (lookup.short_name) squadronLookup[lookup.short_name] = lookup; if (lookup.tag_name) squadronLookup[lookup.tag_name] = lookup; if (lookup.clan_id != null) squadronLookup[`__cid_${lookup.clan_id}`] = lookup; }); callback(); }); }); }; loadSquadronLookup(() => { const consolidatedSquadrons = {}; squadronRows.forEach(row => { // Prefer clan_id-keyed lookup so renamed squadrons consolidate // under their current long_name; fall back to text lookup for // rows whose clan_id wasn't backfilled. const lookup = (row.clan_id != null && squadronLookup[`__cid_${row.clan_id}`]) || squadronLookup[row.squadron_name]; const canonicalName = lookup ? lookup.long_name : row.squadron_name; const tagName = lookup ? lookup.tag_name : row.squadron_name; // Group key is clan_id when known; orphans group by name. const consolidationKey = (lookup && lookup.clan_id != null) ? `__cid_${lookup.clan_id}` : canonicalName; if (!consolidatedSquadrons[consolidationKey]) { consolidatedSquadrons[consolidationKey] = { clan_id: lookup ? lookup.clan_id : null, tag_name: tagName, short_name: lookup ? lookup.short_name : null, long_name: canonicalName, player_count: lookup ? (lookup.members || 0) : 0, total_kills: 0, ground_kills: 0, air_kills: 0, total_battles: 0, wins: 0, deaths: 0, assists: 0, captures: 0 }; } const consolidated = consolidatedSquadrons[consolidationKey]; consolidated.total_kills += row.total_kills || 0; consolidated.ground_kills += row.total_ground_kills || 0; consolidated.air_kills += row.total_air_kills || 0; consolidated.total_battles += row.total_battles || 0; consolidated.wins += row.wins || 0; consolidated.deaths += row.total_deaths || 0; consolidated.assists += row.total_assists || 0; consolidated.captures += row.total_captures || 0; }); // Get points from squadron_members table const getSquadronPoints = () => { return new Promise((resolve) => { if (!fs.existsSync(squadronsDbPath)) { return resolve({}); } const pointsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { log.error('Failed to open squadrons.db for points lookup', err); return resolve({}); } pointsDb.all(` SELECT long_name, clanrating as total_points FROM squadrons_data WHERE clanrating IS NOT NULL `, [], (err, rows) => { pointsDb.close(); if (err) { log.error('Error querying squadron points', err); return resolve({}); } const pointsMap = {}; rows.forEach(row => { pointsMap[row.long_name] = row.total_points || 0; }); resolve(pointsMap); }); }); }); }; getSquadronPoints().then(pointsMap => { const squadronsArray = Object.values(consolidatedSquadrons).map(squadron => { const kdr = squadron.deaths > 0 ? (squadron.total_kills / squadron.deaths) : squadron.total_kills; const winRate = squadron.total_battles > 0 ? (squadron.wins / squadron.total_battles) * 100 : 0; const totalPoints = pointsMap[squadron.long_name] || 0; return { clan_id: squadron.clan_id, tag_name: squadron.tag_name, short_name: squadron.short_name, long_name: squadron.long_name, player_count: squadron.player_count, total_kills: squadron.total_kills, ground_kills: squadron.ground_kills, air_kills: squadron.air_kills, total_battles: squadron.total_battles, wins: squadron.wins, win_rate: parseFloat(winRate.toFixed(1)), kdr: parseFloat(kdr.toFixed(1)), deaths: squadron.deaths, assists: squadron.assists, captures: squadron.captures, points: { total_points: totalPoints, has_points_data: totalPoints > 0 } }; }); // Sort by points, fallback to total kills squadronsArray.sort((a, b) => { if (a.points.has_points_data && b.points.has_points_data) { return b.points.total_points - a.points.total_points; } if (a.points.has_points_data && !b.points.has_points_data) return -1; if (!a.points.has_points_data && b.points.has_points_data) return 1; return b.total_kills - a.total_kills; }); const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), timeframe: dateFilters.hasFilter ? "filtered" : "all-time", total_squadrons: squadronsArray.length, squadrons: squadronsArray }; log.info('Squadron leaderboard query completed with consolidation and points', { originalSquadrons: squadronRows.length, consolidatedSquadrons: squadronsArray.length, totalSquadrons: squadronsArray.length, squadronsWithPoints: squadronsArray.filter(s => s.points.has_points_data).length }); resolve(response); }); }); }); }); }); }); serveAggregateCached(cacheKey, dateFilters, compute, res, applyLimit, { allowCompute: true, errorCode: 'DB_SQUADRON_LEADERBOARD_FAILED', }); }); app.get('/api/squadrons/:squadronname', (req, res) => { const { squadronname } = req.params; if (!squadronname) { return res.status(400).json({ error: 'Squadron name parameter is required' }); } // Parse date filters const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; log.info('Squadron details request received', { squadronName: squadronname }); // Cache the response for 5 minutes — squadron stats change slowly and // a single page render fires this endpoint per tab refresh. Without // caching every load re-runs the 5s+ join. Dedup so concurrent first // hits share one execution. const cacheKey = `squadron_detail_${squadronname}_${start_date || ''}_${end_date || ''}_${season || ''}_${week || ''}`; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); if (!hasSquadronColumn) { log.info('Squadron column missing - returning empty squadron details', { squadronName: squadronname }); return res.json({ date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), tag_name: squadronname, long_name: squadronname, squadron_summary: { player_count: 0, total_kills: 0, ground_kills: 0, air_kills: 0, total_battles: 0, wins: 0, win_rate: 0, kdr: 0, deaths: 0, assists: 0, captures: 0, points: { total_points: 0, has_points_data: false } }, players: [], migration_needed: true, message: 'Squadron data not available - squadron_name column missing from database' }); } let canonicalName = squadronname; let tagName = squadronname; loadSquadronLookupCached((squadronLookup) => { const lookup = squadronLookup[squadronname]; let memberCount = 0; const allVariants = []; if (lookup) { canonicalName = lookup.long_name; tagName = lookup.tag_name; memberCount = lookup.members || 0; } allVariants.push(canonicalName); Object.keys(squadronLookup).forEach(key => { if (squadronLookup[key].long_name === canonicalName && key !== canonicalName) { allVariants.push(key); } }); const clanId = lookup ? lookup.clan_id : null; const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); const totalPoints = lookup ? (lookup.clanrating || 0) : 0; // Step 1: Get the authoritative member roster from squadron_members const membersPromise = new Promise((resolve) => { if (!clanId || !fs.existsSync(squadronsDbPath)) return resolve([]); const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { log.error('Failed to open squadrons.db for members lookup', err); return resolve([]); } squadronsDb.all(`SELECT uid, nick, points FROM squadron_members WHERE clan_id = ?`, [clanId], (err, rows) => { squadronsDb.close(); if (err) { log.error('Error querying squadron_members', err); return resolve([]); } resolve(rows); }); }); }); // Step 1b: When a date filter has an end timestamp, fetch the // squadrons_points snapshot at or before that moment so we can // report each member's points AS OF that historical point. // clan_pts is gzip-compressed JSON of [members_dict, total_score]. const snapshotPromise = new Promise((resolve) => { if (!dateFilters.endTimestamp || !fs.existsSync(squadronsDbPath)) return resolve(null); const sdb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { resolve(null); return; } sdb.get( clanId ? 'SELECT clan_pts, total_score FROM squadrons_points WHERE clan_id = ? AND unix_time <= ? ORDER BY unix_time DESC LIMIT 1' : 'SELECT clan_pts, total_score FROM squadrons_points WHERE long_name = ? AND unix_time <= ? ORDER BY unix_time DESC LIMIT 1', [clanId || canonicalName, dateFilters.endTimestamp], (qerr, row) => { sdb.close(); if (qerr || !row || !row.clan_pts) return resolve(null); try { const buf = Buffer.isBuffer(row.clan_pts) ? row.clan_pts : Buffer.from(row.clan_pts, 'binary'); const decompressed = zlib.gunzipSync(buf); const parsed = JSON.parse(decompressed.toString('utf8')); if (Array.isArray(parsed) && parsed[0] && typeof parsed[0] === 'object') { resolve({ members: parsed[0], total: row.total_score }); } else { resolve(null); } } catch (e) { log.error('Failed to decompress clan_pts snapshot', e, { longName: canonicalName }); resolve(null); } } ); }); }); Promise.all([membersPromise, snapshotPromise]) .then(([memberRows, snapshot]) => { const histMembers = snapshot ? snapshot.members : null; const histTotal = (snapshot && typeof snapshot.total === 'number') ? snapshot.total : null; if (!memberRows.length) { log.info('No members found in squadron_members', { squadronName: squadronname, clanId }); return res.json({ date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), tag_name: tagName, long_name: canonicalName, squadron_summary: { player_count: 0, total_kills: 0, ground_kills: 0, air_kills: 0, total_battles: 0, wins: 0, win_rate: 0, kdr: 0, deaths: 0, assists: 0, captures: 0, points: { total_points: totalPoints, has_points_data: totalPoints > 0 } }, players: [], message: `Squadron '${squadronname}' not found or has no members` }); } // Build lookup maps from the roster const memberNicks = {}; const memberPoints = {}; const memberUids = memberRows.map(r => { const uid = String(r.uid); memberNicks[uid] = r.nick || ''; memberPoints[uid] = r.points || 0; return uid; }); // Step 2: Query stats from player_games_hist for roster members, // but only for battles explicitly attributed to this squadron. const uidPlaceholders = memberUids.map(() => '?').join(','); const variantPlaceholders = allVariants.map(() => '?').join(','); const mainDate = buildDateClause('p', dateFilters); const fallbackDate = buildDateClause('p', dateFilters); const summaryDate = buildDateClause('', dateFilters); // When clan_id is known (the common case post-migration), // filter on it directly so SQLite can use idx_pgh_clanid_endtime. // The OR-with-text-fallback we used to have here was the // single biggest perf hit on filtered profile queries — // it forced a full scan because the planner can't OR // two index plans together cleanly. We drop the fallback; // truly orphaned rows (clan_id NULL) won't appear, but // those rows are unrecoverable without a manual backfill // anyway. const squadronAttrClause = clanId ? `p.clan_id = ?` : `p.squadron_name IN (${variantPlaceholders})`; const summaryAttrClause = clanId ? `clan_id = ?` : `squadron_name IN (${variantPlaceholders})`; const playerStatsQuery = ` SELECT p.UID as uid, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(ground_kills + air_kills) as total_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, COUNT(*) as total_battles, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins FROM player_games_hist p WHERE p.UID IN (${uidPlaceholders}) AND ${squadronAttrClause} ${mainDate.clause} GROUP BY p.UID `; const summaryQuery = ` SELECT COUNT(DISTINCT session_id) as total_battles, COUNT(DISTINCT CASE WHEN UPPER(victor_bool) = 'WIN' THEN session_id END) as wins, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(ground_kills + air_kills) as total_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths FROM player_games_hist WHERE ${summaryAttrClause} AND UID IS NOT NULL ${summaryDate.clause} `; const vehicleStatsQuery = ` SELECT vehicle_internal, MAX(vehicle) as vehicle, SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, SUM(ground_kills + air_kills) as total_kills, SUM(assists) as total_assists, SUM(captures) as total_captures, SUM(deaths) as total_deaths, COUNT(*) as total_battles, SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins FROM player_games_hist WHERE ${summaryAttrClause} AND vehicle_internal IS NOT NULL AND vehicle_internal <> '' ${summaryDate.clause} GROUP BY vehicle_internal ORDER BY total_battles DESC, total_kills DESC `; // Params must match the SQL placeholder order exactly. // The squadron-attribution clause is either `clan_id = ?` // (1 param) OR `squadron_name IN (variants)` (V params); // we only spread the side that's actually in the SQL. const squadronAttrParams = clanId ? [clanId] : allVariants; const statsParams = [ ...memberUids, ...squadronAttrParams, ...mainDate.params ]; const summaryParams = [ ...squadronAttrParams, ...summaryDate.params ]; const vehicleStatsParams = [ ...squadronAttrParams, ...summaryDate.params ]; const fallbackPlayerStatsQuery = ` WITH roster_sessions AS ( SELECT p.UID as uid, p.session_id, SUM(p.ground_kills) as total_ground_kills, SUM(p.air_kills) as total_air_kills, SUM(p.ground_kills + p.air_kills) as total_kills, SUM(p.assists) as total_assists, SUM(p.captures) as total_captures, SUM(p.deaths) as total_deaths, MAX(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) as won FROM player_games_hist p WHERE p.UID IN (${uidPlaceholders}) ${fallbackDate.clause} GROUP BY p.UID, p.session_id ) SELECT rs.uid as uid, SUM(rs.total_ground_kills) as total_ground_kills, SUM(rs.total_air_kills) as total_air_kills, SUM(rs.total_kills) as total_kills, SUM(rs.total_assists) as total_assists, SUM(rs.total_captures) as total_captures, SUM(rs.total_deaths) as total_deaths, COUNT(*) as total_battles, SUM(rs.won) as wins FROM roster_sessions rs GROUP BY rs.uid `; const fallbackSummaryQuery = ` WITH roster_sessions AS ( SELECT p.UID as uid, p.session_id, SUM(p.ground_kills) as total_ground_kills, SUM(p.air_kills) as total_air_kills, SUM(p.ground_kills + p.air_kills) as total_kills, SUM(p.assists) as total_assists, SUM(p.captures) as total_captures, SUM(p.deaths) as total_deaths, MAX(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) as won FROM player_games_hist p WHERE p.UID IN (${uidPlaceholders}) AND (? IS NULL OR p.endtime_unix >= ?) AND (? IS NULL OR p.endtime_unix <= ?) GROUP BY p.UID, p.session_id ) SELECT COUNT(DISTINCT session_id) as total_battles, COUNT(DISTINCT CASE WHEN won = 1 THEN session_id END) as wins, SUM(total_ground_kills) as total_ground_kills, SUM(total_air_kills) as total_air_kills, SUM(total_kills) as total_kills, SUM(total_assists) as total_assists, SUM(total_captures) as total_captures, SUM(total_deaths) as total_deaths FROM roster_sessions `; const fallbackStatsParams = [ ...memberUids, ...fallbackDate.params ]; const fallbackSummaryParams = [ ...memberUids, ...fallbackDate.params ]; const hasMeaningfulStats = (rows) => Array.isArray(rows) && rows.some(row => (row.total_battles || 0) > 0 || (row.total_kills || 0) > 0 || (row.total_assists || 0) > 0 || (row.total_captures || 0) > 0 ); const allowFallback = !dateFilters.hasFilter; const buildAndSendResponse = (statsRows, summaryRow, vehicleRows = [], { usingFallback = false } = {}) => { const safeStatsRows = statsRows || []; const safeSummaryRow = summaryRow || {}; const safeVehicleRows = vehicleRows || []; // Index stats by UID for fast lookup const statsByUid = {}; safeStatsRows.forEach(row => { statsByUid[String(row.uid)] = row; }); // Build player list from roster, attaching member stats const players = memberUids.map(uid => { const stats = statsByUid[uid]; const totalKills = stats ? (stats.total_kills || 0) : 0; const deaths = stats ? (stats.total_deaths || 0) : 0; const wins = stats ? (stats.wins || 0) : 0; const totalBattles = stats ? (stats.total_battles || 0) : 0; const kdr = deaths > 0 ? (totalKills / deaths) : totalKills; const winRate = totalBattles > 0 ? (wins / totalBattles) * 100 : 0; const nick = (stats && stats.hist_nick) ? stats.hist_nick : memberNicks[uid]; let sqbPoints; if (dateFilters.endTimestamp) { const entry = histMembers ? histMembers[String(uid)] : null; if (entry && typeof entry === 'object') { sqbPoints = Number(entry.points) || 0; } else if (typeof entry === 'number') { sqbPoints = entry; } else { sqbPoints = 0; } } else { sqbPoints = memberPoints[uid] || 0; } return { uid, nick, sqb_points: sqbPoints, total_kills: totalKills, ground_kills: stats ? (stats.total_ground_kills || 0) : 0, air_kills: stats ? (stats.total_air_kills || 0) : 0, total_battles: totalBattles, wins, win_rate: parseFloat(winRate.toFixed(1)), kdr: parseFloat(kdr.toFixed(1)), kps: totalBattles > 0 ? parseFloat((totalKills / totalBattles).toFixed(2)) : 0, deaths, assists: stats ? (stats.total_assists || 0) : 0, captures: stats ? (stats.total_captures || 0) : 0 }; }); players.sort((a, b) => b.total_kills - a.total_kills); const sqTotalKills = safeSummaryRow.total_kills || 0; const sqGroundKills = safeSummaryRow.total_ground_kills || 0; const sqAirKills = safeSummaryRow.total_air_kills || 0; const sqDeaths = safeSummaryRow.total_deaths || 0; const sqWins = safeSummaryRow.wins || 0; const sqBattles = safeSummaryRow.total_battles || 0; const sqAssists = safeSummaryRow.total_assists || 0; const sqCaptures = safeSummaryRow.total_captures || 0; const sqKdr = sqDeaths > 0 ? (sqTotalKills / sqDeaths) : sqTotalKills; const sqWinRate = sqBattles > 0 ? (sqWins / sqBattles) * 100 : 0; const activePlayers = players.filter(p => p.total_battles > 0); const sqKps = activePlayers.length > 0 ? parseFloat((activePlayers.reduce((sum, p) => sum + p.kps, 0) / activePlayers.length).toFixed(2)) : 0; loadPerformanceBenchmarksCached(dateFilters, (benchmarks) => { const playerBenchmark = benchmarks.players; const squadronBenchmark = benchmarks.squadrons; Promise.all([ queryPlayerRatingStatsForUids(memberUids, dateFilters), querySquadronRatingStats(allVariants, dateFilters, clanId) ]).then(([playerRatingStats, squadronRatingStats]) => { players.forEach(player => { player.performance = computePerformanceScore( playerRatingStats.get(String(player.uid)) || { games: 0 }, playerBenchmark ); }); players.sort((a, b) => (b.performance || 0) - (a.performance || 0) || (b.total_kills || 0) - (a.total_kills || 0)); const squadronPerformanceSource = squadronRatingStats && Number(squadronRatingStats.games || 0) > 0 ? squadronRatingStats : { games: sqBattles, total_kills: sqTotalKills, total_assists: sqAssists, total_captures: sqCaptures, total_deaths: sqDeaths, wins: sqWins, heavy_score: 0 }; const sqPerformance = computePerformanceScore(squadronPerformanceSource, squadronBenchmark); const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), clan_id: lookup ? lookup.clan_id : null, tag_name: tagName, short_name: lookup ? (lookup.short_name || tagName) : tagName, long_name: canonicalName, squadron_summary: { player_count: memberCount, total_kills: sqTotalKills, ground_kills: sqGroundKills, air_kills: sqAirKills, total_battles: sqBattles, wins: sqWins, win_rate: parseFloat(sqWinRate.toFixed(1)), kdr: parseFloat(sqKdr.toFixed(1)), kps: sqKps, deaths: sqDeaths, assists: sqAssists, captures: sqCaptures, performance: sqPerformance, points: { total_points: dateFilters.endTimestamp ? (histTotal != null ? histTotal : 0) : totalPoints, has_points_data: dateFilters.endTimestamp ? (histTotal != null && histTotal > 0) : (totalPoints > 0) } }, players, vehicles: safeVehicleRows.map(row => { const totalKills = row.total_kills || 0; const deaths = row.total_deaths || 0; const wins = row.wins || 0; const totalBattles = row.total_battles || 0; return { vehicle_internal: row.vehicle_internal, vehicle: normalizeVehicleName(row.vehicle) || row.vehicle_internal, total_kills: totalKills, ground_kills: row.total_ground_kills || 0, air_kills: row.total_air_kills || 0, total_battles: totalBattles, wins, win_rate: totalBattles > 0 ? parseFloat(((wins / totalBattles) * 100).toFixed(1)) : 0, kdr: deaths > 0 ? parseFloat((totalKills / deaths).toFixed(1)) : totalKills, deaths, assists: row.total_assists || 0, captures: row.total_captures || 0, }; }) }; log.info('Squadron details query completed', { squadronName: squadronname, canonicalName, variants: allVariants, rosterSize: memberUids.length, playersWithStats: Object.keys(statsByUid).length, totalPoints, histTotal, usingSnapshot: !!histMembers, usingFallback, squadronBattles: sqBattles }); setCachedResponse(cacheKey, response); res.json(response); }); }); }; const dbAll = (query, params) => new Promise((resolve, reject) => db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows)) ); const dbGet = (query, params) => new Promise((resolve, reject) => db.get(query, params, (err, row) => err ? reject(err) : resolve(row)) ); Promise.all([ dbAll(playerStatsQuery, statsParams), dbGet(summaryQuery, summaryParams), dbAll(vehicleStatsQuery, vehicleStatsParams) ]).then(([statsRows, summaryRow, vehicleRows]) => { if (hasMeaningfulStats(statsRows) || !allowFallback) { return buildAndSendResponse(statsRows, summaryRow, vehicleRows, { usingFallback: false }); } return Promise.all([ dbAll(fallbackPlayerStatsQuery, fallbackStatsParams), dbGet(fallbackSummaryQuery, fallbackSummaryParams) ]).then(([fallbackStatsRows, fallbackSummaryRow]) => { buildAndSendResponse(fallbackStatsRows, fallbackSummaryRow, vehicleRows, { usingFallback: true }); }).catch((fallbackErr) => { log.error('Database error querying fallback squadron stats', fallbackErr, { squadronName: squadronname }); buildAndSendResponse(statsRows, summaryRow, vehicleRows, { usingFallback: false }); }); }).catch((err) => { log.error('Database error in squadron queries', err, { squadronName: squadronname }); res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SQUADRON_QUERY_FAILED' }); }); }) .catch(err => { log.error('Database error in squadron queries', err, { squadronName: squadronname }); res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SQUADRON_QUERY_FAILED' }); }); }); }); app.get('/api/squadrons/:squadronname/history', (req, res) => { const { squadronname } = req.params; if (!squadronname) { return res.status(400).json({ error: 'Squadron name parameter is required' }); } const cacheKey = `squadron_history_${squadronname}`; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); loadSquadronLookupCached((squadronLookup) => { const lookup = squadronLookup[squadronname]; let canonicalName = squadronname; const clanId = lookup ? lookup.clan_id : null; if (lookup) { canonicalName = lookup.long_name; } const allVariants = [canonicalName]; Object.keys(squadronLookup).forEach(key => { if (squadronLookup[key].long_name === canonicalName && key !== canonicalName) { allVariants.push(key); } }); const placeholders = allVariants.map(() => '?').join(','); // When clan_id is known, filter on it directly so SQLite uses // idx_pgh_clanid_endtime. The OR-with-text-fallback we used to have // here forced a full scan because the planner can't OR two index // plans together cleanly. Same change as /api/squadrons/:name — // truly orphaned rows (clan_id NULL) are unrecoverable without a // backfill anyway. const historyQuery = clanId ? ` SELECT date(endtime_unix, 'unixepoch') as period, COUNT(DISTINCT session_id) as battles, ROUND(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1.0 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0), 1) as win_rate, ROUND(CAST(SUM(ground_kills + air_kills) AS REAL) / MAX(SUM(deaths), 1), 2) as kdr FROM player_games_hist WHERE clan_id = ? AND UID IS NOT NULL AND endtime_unix IS NOT NULL GROUP BY period ORDER BY period ASC` : ` SELECT date(endtime_unix, 'unixepoch') as period, COUNT(DISTINCT session_id) as battles, ROUND(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1.0 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0), 1) as win_rate, ROUND(CAST(SUM(ground_kills + air_kills) AS REAL) / MAX(SUM(deaths), 1), 2) as kdr FROM player_games_hist WHERE squadron_name IN (${placeholders}) AND UID IS NOT NULL AND endtime_unix IS NOT NULL GROUP BY period ORDER BY period ASC `; const historyParams = clanId ? [clanId] : allVariants; db.all(historyQuery, historyParams, (err, battleRows) => { if (err) { log.error('Database error in squadron history query', err, { squadronName: squadronname }); return res.status(500).json({ error: 'Database error', errorCode: 'DB_SQUADRON_HISTORY_FAILED' }); } // Fetch rating history from squadrons.db and return separately. // Rating is returned at the raw hourly-snapshot granularity so the // rating chart can show full detail (season resets, intraday // spikes); daily aggregation hid transient troughs. const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); if (!fs.existsSync(squadronsDbPath)) { return res.json({ history: battleRows, rating_hourly: [] }); } const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { return res.json({ history: battleRows, rating_hourly: [] }); } // Prefer clan_id so renames don't truncate the rating chart. const ratingQuery = clanId ? ` SELECT unix_time, total_score FROM squadrons_points WHERE clan_id = ? ORDER BY unix_time ASC ` : ` SELECT unix_time, total_score FROM squadrons_points WHERE long_name = ? ORDER BY unix_time ASC `; squadronsDb.all(ratingQuery, [clanId || canonicalName], (err, ratingRows) => { squadronsDb.close(); const buildAndCache = (body) => { setCachedResponse(cacheKey, body); res.json(body); }; if (err || !ratingRows) { return buildAndCache({ history: battleRows, rating_hourly: [] }); } buildAndCache({ history: battleRows, rating_hourly: ratingRows.map(r => ({ t: r.unix_time, rating: r.total_score, })), }); }); }); }); }); }); app.get('/api/squadrons/:squadronname/games', (req, res) => { const { squadronname } = req.params; if (!squadronname) { return res.status(400).json({ error: 'Squadron name parameter is required' }); } const dateFilters = parseDateFilters(req); const cacheKey = `squadron_games_${squadronname}_${dateFilters.startTimestamp || 'null'}_${dateFilters.endTimestamp || 'null'}`; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); if (!hasSquadronColumn) { return res.json({ tag_name: squadronname, long_name: squadronname, games: [], total_games_returned: 0, message: 'Squadron data not available - squadron_name column missing from database' }); } loadSquadronLookupCached((squadronLookup) => { const lookup = squadronLookup[squadronname]; let canonicalName = squadronname; let tagName = squadronname; const clanId = lookup ? lookup.clan_id : null; if (lookup) { canonicalName = lookup.long_name; tagName = lookup.tag_name; } const allVariants = [canonicalName]; Object.keys(squadronLookup).forEach(key => { if (squadronLookup[key].long_name === canonicalName && key !== canonicalName) { allVariants.push(key); } }); const variantPlaceholders = allVariants.map(() => '?').join(','); // Same OR-fallback removal as /api/squadrons/:name and /history — // forces the planner onto idx_pgh_clanid_endtime instead of a scan. const attrClause = clanId ? `p.clan_id = ?` : `p.squadron_name IN (${variantPlaceholders})`; const gamesQuery = ` SELECT p.session_id, MAX(p.endtime_unix) as endtime_unix, GROUP_CONCAT(DISTINCT p.nick) as players, COUNT(DISTINCT p.UID) as player_count, SUM(p.ground_kills) as ground_kills, SUM(p.air_kills) as air_kills, SUM(p.assists) as assists, SUM(p.captures) as captures, SUM(p.deaths) as deaths, CASE WHEN SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) * 2 > COUNT(*) THEN 'Win' ELSE 'Loss' END as result, ms.map_name FROM player_games_hist p LEFT JOIN match_summary ms ON ms.session_id = p.session_id WHERE ${attrClause} AND p.UID IS NOT NULL AND p.session_id IS NOT NULL AND (? IS NULL OR p.endtime_unix >= ?) AND (? IS NULL OR p.endtime_unix <= ?) GROUP BY p.session_id ORDER BY endtime_unix DESC LIMIT 2000 `; const gamesParams = [ ...(clanId ? [clanId] : allVariants), dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp ]; db.all(gamesQuery, gamesParams, (err, rows) => { if (err) { log.error('Database error in squadron games query', err, { squadronName: squadronname }); return res.status(500).json({ error: 'Database error', errorCode: 'DB_SQUADRON_GAMES_FAILED' }); } const response = { tag_name: tagName, long_name: canonicalName, games: (rows || []).map(row => ({ session_id: row.session_id, timestamp: row.endtime_unix || 0, map_name: row.map_name || null, players: row.players || '', player_count: row.player_count || 0, stats: { ground_kills: row.ground_kills || 0, air_kills: row.air_kills || 0, assists: row.assists || 0, captures: row.captures || 0, deaths: row.deaths || 0 }, result: row.result || 'Unknown' })), total_games_returned: (rows || []).length }; setCachedResponse(cacheKey, response); res.json(response); }); }); }); function sendSqliteSchema(res, dbPath, label) { if (!fs.existsSync(dbPath)) { return res.status(404).json({ error: `${label} database not found`, path: dbPath }); } const schemaDb = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { return res.status(500).json({ error: `Failed to open ${label} database`, details: err.message }); } schemaDb.all(`SELECT name FROM sqlite_master WHERE type='table'`, (err, tables) => { if (err) { schemaDb.close(); return res.status(500).json({ error: 'Failed to get table list', details: err.message }); } const tableSchemas = {}; let completedTables = 0; if (!tables.length) { schemaDb.close(); return res.json({ database_path: dbPath, tables: {}, message: `No tables found in ${label} database` }); } tables.forEach(table => { const tableName = table.name; schemaDb.all(`PRAGMA table_info(${tableName})`, (schemaErr, schema) => { if (schemaErr) { tableSchemas[tableName] = { error: schemaErr.message }; completedTables++; if (completedTables === tables.length) { schemaDb.close(); res.json({ database_path: dbPath, tables: tableSchemas, timestamp: new Date().toISOString() }); } return; } schemaDb.all(`SELECT * FROM ${tableName} LIMIT 3`, (sampleErr, samples) => { tableSchemas[tableName] = { schema, sample_records: sampleErr ? [] : (samples || []), record_count: sampleErr ? 0 : (samples || []).length }; completedTables++; if (completedTables === tables.length) { schemaDb.close(); res.json({ database_path: dbPath, tables: tableSchemas, timestamp: new Date().toISOString() }); } }); }); }); }); }); } app.get('/api/leaderboard/stats', (req, res) => { log.info('Leaderboard stats request received'); const dateFilters = parseDateFilters(req); const { start_date, end_date, season, week } = req.query; const cacheKey = `leaderboard_stats_${start_date || 'all'}_${end_date || 'all'}_${season || 'all'}_${week || 'all'}`; const cached = getCachedResponse(cacheKey, STATS_CACHE_TTL); if (cached) { log.info('Returning cached leaderboard stats'); return res.json(cached); } // Dedup so the 3 web cluster workers + any concurrent traffic share one // DB call. Without this we saw 200s+ wall-clock durations on cold start as // requests serialized on the single read connection. dedup(cacheKey, () => new Promise((resolve, reject) => { const overallStatsQuery = ` SELECT COUNT(DISTINCT UID) as total_players, COUNT(DISTINCT vehicle) as total_vehicles_used, COUNT(*) as total_battles FROM player_games_hist WHERE (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) `; const topVehiclesQuery = ` SELECT vehicle as vehicle, MAX(vehicle_internal) as vehicle_internal, COUNT(*) as usage_count FROM player_games_hist WHERE (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) GROUP BY vehicle ORDER BY usage_count DESC LIMIT 12 `; const queryParams = [ dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp, ]; const queryStart = Date.now(); heavyDb.get(overallStatsQuery, queryParams, (err, statsRow) => { if (err) { log.error('Database error in overall stats query', err); return reject({ status: 500, body: { error: 'Database error occurred', errorCode: 'DB_OVERALL_STATS_FAILED' } }); } heavyDb.all(topVehiclesQuery, queryParams, (err, vehicleRows) => { if (err) { log.error('Database error in top vehicles query', err); return reject({ status: 500, body: { error: 'Database error occurred', errorCode: 'DB_TOP_VEHICLES_FAILED' } }); } const response = { date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), total_players: statsRow.total_players, total_vehicles_used: statsRow.total_vehicles_used, total_battles: statsRow.total_battles, last_updated: new Date().toISOString(), top_vehicles: vehicleRows.map(row => ({ vehicle: row.vehicle, vehicle_internal: row.vehicle_internal || null, usage_count: row.usage_count })) }; log.info('Leaderboard stats query completed', { totalPlayers: statsRow.total_players, totalVehicles: statsRow.total_vehicles_used, totalBattles: statsRow.total_battles, topVehiclesReturned: vehicleRows.length, ms: Date.now() - queryStart, }); setCachedResponse(cacheKey, response); resolve(response); }); }); })).then(response => res.json(response)) .catch(err => { if (res.headersSent) return; if (err && err.status) return res.status(err.status).json(err.body); res.status(500).json({ error: 'Database error', errorCode: 'DB_LEADERBOARD_STATS_FAILED' }); }); }); // ============================================================================ // ANALYTICS ENDPOINTS // ============================================================================ // Normalize a raw map_name: strip "[mode]" prefixes, leading "Gamemode " token, // collapse whitespace, and use lowercase as the merge key while preserving a // human-friendly display form (Title Case). function normalizeMapName(raw) { if (!raw) return null; let s = String(raw).trim(); if (!s) return null; s = s.replace(/^\s*\[[^\]]+\]\s*/, ''); // "[Conquest #1] Foo" -> "Foo" s = s.replace(/^\s*gamemode\s+/i, ''); // "Gamemode Foo" -> "Foo" s = s.replace(/\s+/g, ' ').trim(); if (!s) return null; const key = s.toLowerCase(); // Title-case display form: capitalize first letter of each word but keep // already-cased words like "to", "the", and parens intact. const display = s.replace(/\b([a-z])/g, (m) => m.toUpperCase()); return { key, display }; } app.get('/api/analytics/maps/:squadron', (req, res) => { const sq = req.params.squadron; const startDate = parseInt(req.query.start_date) || 0; const endDate = parseInt(req.query.end_date) || 0; loadSquadronLookupCached(() => { const filter = resolveSquadronFilter(sq); const variantSet = new Set(filter.variants); const where = matchSummarySquadronWhere(filter); const params = [...where.params, startDate]; let endClause = ''; if (endDate) { endClause = ' AND endtime_unix <= ?'; params.push(endDate); } db.all( `SELECT map_name, winning_sq, losing_sq, winning_clan_id, losing_clan_id FROM match_summary WHERE ${where.clause} AND endtime_unix >= ?${endClause}`, params, (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const stats = {}; for (const row of rows) { const norm = normalizeMapName(row.map_name) || { key: 'unknown', display: 'Unknown' }; if (!stats[norm.key]) stats[norm.key] = { map_name: norm.display, wins: 0, losses: 0 }; if (rowIsWinFor(row, filter, variantSet)) stats[norm.key].wins++; else if (rowIsLossFor(row, filter, variantSet)) stats[norm.key].losses++; } const result = Object.values(stats).map(r => ({ ...r, total: r.wins + r.losses, win_rate: r.wins + r.losses > 0 ? Math.round(r.wins / (r.wins + r.losses) * 1000) / 10 : 0, })); result.sort((a, b) => b.total - a.total); res.json(result); } ); }); }); app.get('/api/analytics/time/:squadron', (req, res) => { const sq = req.params.squadron; const startDate = parseInt(req.query.start_date) || 0; const endDate = parseInt(req.query.end_date) || 0; loadSquadronLookupCached(() => { const filter = resolveSquadronFilter(sq); const variantSet = new Set(filter.variants); const where = matchSummarySquadronWhere(filter); const params = [...where.params, Math.max(startDate, 1)]; let endClause = ''; if (endDate) { endClause = ' AND endtime_unix <= ?'; params.push(endDate); } db.all( `SELECT endtime_unix, winning_sq, losing_sq, winning_clan_id, losing_clan_id FROM match_summary WHERE ${where.clause} AND endtime_unix >= ?${endClause}`, params, (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const hourly = {}; const daily = {}; for (const row of rows) { const d = new Date(row.endtime_unix * 1000); const hour = d.getUTCHours(); if (!hourly[hour]) hourly[hour] = { wins: 0, losses: 0 }; if (rowIsWinFor(row, filter, variantSet)) hourly[hour].wins++; else if (rowIsLossFor(row, filter, variantSet)) hourly[hour].losses++; const dayMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); daily[dayMs] = (daily[dayMs] || 0) + 1; } const hourlyOut = {}; for (const hour of Object.keys(hourly).sort((a, b) => a - b)) { const s = hourly[hour]; const total = s.wins + s.losses; hourlyOut[hour] = { ...s, total, win_rate: total > 0 ? Math.round(s.wins / total * 1000) / 10 : 0 }; } const dailyOut = Object.entries(daily) .map(([day, count]) => ({ day: parseInt(day), count })) .sort((a, b) => a.day - b.day); res.json({ hourly: hourlyOut, daily: dailyOut }); } ); }); }); app.get('/api/analytics/consistency/:squadron', (req, res) => { const sq = req.params.squadron; const minGames = parseInt(req.query.min_games) || 10; const startDate = parseInt(req.query.start_date) || 0; const endDate = parseInt(req.query.end_date) || 0; loadSquadronLookupCached(() => { const filter = resolveSquadronFilter(sq); const variantSet = new Set(filter.variants); const pghWhere = playerGamesHistSquadronWhere(filter); const params = [...pghWhere.params]; let dateClause = ''; if (startDate) { dateClause += ' AND p.endtime_unix >= ?'; params.push(startDate); } if (endDate) { dateClause += ' AND p.endtime_unix <= ?'; params.push(endDate); } db.all( `SELECT p.UID, p.nick, p.session_id, SUM(p.ground_kills + p.air_kills) as total_kills, SUM(p.deaths) as total_deaths, m.winning_sq, m.losing_sq, m.winning_clan_id, m.losing_clan_id FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE ${pghWhere.clause}${dateClause} GROUP BY p.UID, p.session_id`, params, (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const players = {}; for (const row of rows) { const UID = row.UID; if (!players[UID]) players[UID] = { uid: UID, nick: row.nick, kills: [], deaths: [], wins: 0, losses: 0 }; players[UID].nick = row.nick; players[UID].kills.push(row.total_kills || 0); players[UID].deaths.push(row.total_deaths || 0); if (rowIsWinFor(row, filter, variantSet)) players[UID].wins++; else if (rowIsLossFor(row, filter, variantSet)) players[UID].losses++; } const result = Object.values(players) .filter(p => p.kills.length >= minGames) .map(p => { const n = p.kills.length; const sumK = p.kills.reduce((a, b) => a + b, 0); const sumD = p.deaths.reduce((a, b) => a + b, 0); const avgK = sumK / n; const avgD = sumD / n; const kd = sumD > 0 ? sumK / sumD : sumK; const decided = p.wins + p.losses; const winRate = decided > 0 ? (p.wins / decided) * 100 : 0; return { uid: p.uid, nick: p.nick, games: n, wins: p.wins, losses: p.losses, avg_kills: Math.round(avgK * 100) / 100, avg_deaths: Math.round(avgD * 100) / 100, kd: Math.round(kd * 100) / 100, win_rate: Math.round(winRate * 10) / 10, }; }) .sort((a, b) => b.games - a.games); res.json(result); } ); }); }); app.get('/api/analytics/matchup/:squadron', (req, res) => { const sq = req.params.squadron; const startDate = parseInt(req.query.start_date) || 0; const endDate = parseInt(req.query.end_date) || 0; loadSquadronLookupCached(() => { const filter = resolveSquadronFilter(sq); const variantSet = new Set(filter.variants); const where = matchSummarySquadronWhere(filter); const params = [...where.params, startDate]; let endClause = ''; if (endDate) { endClause = ' AND endtime_unix <= ?'; params.push(endDate); } db.all( `SELECT winning_sq, losing_sq, winning_clan_id, losing_clan_id FROM match_summary WHERE ${where.clause} AND endtime_unix >= ?${endClause}`, params, (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const stats = {}; for (const row of rows) { const { winning_sq, losing_sq } = row; if (!winning_sq || !losing_sq || winning_sq === losing_sq) continue; const isWin = rowIsWinFor(row, filter, variantSet); const isLoss = rowIsLossFor(row, filter, variantSet); if (!isWin && !isLoss) continue; const opponent = isWin ? losing_sq : winning_sq; if (!opponent) continue; if (!stats[opponent]) stats[opponent] = { opponent, wins: 0, losses: 0 }; if (isWin) stats[opponent].wins++; else stats[opponent].losses++; } const enriched = Object.values(stats).map(r => { const total = r.wins + r.losses; return { ...r, total, win_rate: total > 0 ? Math.round(r.wins / total * 1000) / 10 : 0, }; }); const wonAgainst = [...enriched] .sort((a, b) => (b.wins - a.wins) || (b.total - a.total)) .slice(0, 10); const lostAgainst = [...enriched] .sort((a, b) => (b.losses - a.losses) || (b.total - a.total)) .slice(0, 10); res.json({ won_against: wonAgainst, lost_against: lostAgainst, total_opponents: enriched.length, }); } ); }); }); app.get('/api/analytics/comps/:squadron', (req, res) => { const sq = req.params.squadron; const startDate = parseInt(req.query.start_date) || 0; const endDate = parseInt(req.query.end_date) || 0; const minSize = Math.max(1, parseInt(req.query.min_size) || 8); loadSquadronLookupCached(() => { const filter = resolveSquadronFilter(sq); const variantSet = new Set(filter.variants); const pghWhere = playerGamesHistSquadronWhere(filter); const params = [...pghWhere.params]; let dateClause = ''; if (startDate) { dateClause += ' AND p.endtime_unix >= ?'; params.push(startDate); } if (endDate) { dateClause += ' AND p.endtime_unix <= ?'; params.push(endDate); } db.all( `SELECT p.session_id, p.UID, p.vehicle, p.vehicle_internal, p.ground_kills, p.air_kills, p.deaths, m.winning_sq, m.losing_sq, m.winning_clan_id, m.losing_clan_id FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE ${pghWhere.clause}${dateClause}`, params, (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); // Outcome per session and the multiset of vehicle internals brought. const sessionOutcome = new Map(); // sid -> 'win' | 'loss' | 'unknown' const sessionVehicles = new Map(); // sid -> Map // Per-vehicle aggregates keyed by internal id (so type lookup works // and dedup of localized aliases is correct). wins/losses count // sessions; spawns/kills/deaths count individual rows. const vehStats = new Map(); // internal -> { display, type, spawns, kills, deaths, sessions: Map } for (const r of rows) { if (!r.session_id) continue; // Lowercase the internal so case-only duplicates (e.g. // germ_leopard_I vs germ_leopard_i) collapse into one row. const rawInternal = (r.vehicle_internal && r.vehicle_internal !== 'DISCONNECTED') ? r.vehicle_internal : null; if (!rawInternal) continue; const internal = rawInternal.toLowerCase(); const display = normalizeVehicleName(r.vehicle) || internal; if (!sessionOutcome.has(r.session_id)) { let outcome = 'unknown'; if (rowIsWinFor(r, filter, variantSet)) outcome = 'win'; else if (rowIsLossFor(r, filter, variantSet)) outcome = 'loss'; sessionOutcome.set(r.session_id, outcome); } const outcome = sessionOutcome.get(r.session_id); let vMap = sessionVehicles.get(r.session_id); if (!vMap) { vMap = new Map(); sessionVehicles.set(r.session_id, vMap); } vMap.set(internal, (vMap.get(internal) || 0) + 1); let s = vehStats.get(internal); if (!s) { s = { display, type: getVehicleType(internal), spawns: 0, kills: 0, deaths: 0, sessions: new Map(), }; vehStats.set(internal, s); } s.spawns++; s.kills += (r.ground_kills || 0) + (r.air_kills || 0); s.deaths += r.deaths || 0; if (!s.sessions.has(r.session_id)) s.sessions.set(r.session_id, outcome); } const totalSessions = sessionOutcome.size; const topVehicles = [...vehStats.entries()] .map(([internal, s]) => { let wins = 0, losses = 0; for (const o of s.sessions.values()) { if (o === 'win') wins++; else if (o === 'loss') losses++; } const decided = wins + losses; const sessionsCount = s.sessions.size; return { vehicle_internal: internal, vehicle: s.display, type: s.type, spawns: s.spawns, sessions: sessionsCount, share_pct: totalSessions > 0 ? Math.round((sessionsCount / totalSessions) * 1000) / 10 : 0, kills: s.kills, deaths: s.deaths, kd: s.deaths > 0 ? Math.round((s.kills / s.deaths) * 100) / 100 : s.kills, wins, losses, win_rate: decided > 0 ? Math.round((wins / decided) * 1000) / 10 : 0, }; }) .sort((a, b) => b.spawns - a.spawns) .slice(0, 50); // Match-level compositions: keyed by the multiset of vehicles fielded // per session. We aggregate across matches that fielded the *exact* // same multiset, plus a parallel aggregation by type-notation // (e.g. "3F 4T 1AA") for the search-bar presets. const compStats = new Map(); // sig -> { vehicles, types, notation, size, games, wins, losses } const notationStats = new Map(); // notation -> { types, size, games, wins, losses } for (const [sid, vMap] of sessionVehicles) { const flat = []; const typeCounts = {}; for (const [internal, n] of vMap) { const t = vehStats.get(internal).type; for (let i = 0; i < n; i++) flat.push(internal); typeCounts[t] = (typeCounts[t] || 0) + n; } if (flat.length < minSize) continue; flat.sort(); const sig = flat.join('||'); const notation = buildCompNotation(typeCounts); const outcome = sessionOutcome.get(sid); let c = compStats.get(sig); if (!c) { // De-aggregate to (internal, count) pairs for the response. const counts = new Map(); for (const id of flat) counts.set(id, (counts.get(id) || 0) + 1); const vehiclesArr = [...counts.entries()].map(([id, count]) => ({ vehicle_internal: id, vehicle: vehStats.get(id).display, type: vehStats.get(id).type, count, })).sort((a, b) => a.vehicle.localeCompare(b.vehicle)); c = { vehicles: vehiclesArr, types: typeCounts, notation, size: flat.length, games: 0, wins: 0, losses: 0, }; compStats.set(sig, c); } c.games++; if (outcome === 'win') c.wins++; else if (outcome === 'loss') c.losses++; let nc = notationStats.get(notation); if (!nc) { nc = { notation, types: { ...typeCounts }, size: flat.length, games: 0, wins: 0, losses: 0 }; notationStats.set(notation, nc); } nc.games++; if (outcome === 'win') nc.wins++; else if (outcome === 'loss') nc.losses++; } // Keep all distinct comps (so preset notations always have something // to match against). Cap at 500 as a sanity ceiling — even a year of // active play rarely produces more distinct vehicle multisets than // that with the size>=8 filter. const compositions = [...compStats.values()] .map(c => { const decided = c.wins + c.losses; return { vehicles: c.vehicles, types: c.types, notation: c.notation, size: c.size, games: c.games, wins: c.wins, losses: c.losses, win_rate: decided > 0 ? Math.round((c.wins / decided) * 1000) / 10 : 0, }; }) .sort((a, b) => (b.games - a.games) || (b.win_rate - a.win_rate)) .slice(0, 500); const notations = [...notationStats.values()] .map(n => { const decided = n.wins + n.losses; return { notation: n.notation, types: n.types, size: n.size, games: n.games, wins: n.wins, losses: n.losses, win_rate: decided > 0 ? Math.round((n.wins / decided) * 1000) / 10 : 0, }; }) .sort((a, b) => b.games - a.games); res.json({ min_size: minSize, notations, total_sessions: totalSessions, top_vehicles: topVehicles, compositions, }); } ); }); }); // ─── PLAYER-SCOPED ANALYTICS ─────────────────────────────────────────────── // All endpoints take :uid plus optional ?start_date / ?end_date (unix seconds). // We group by session because a player can have multiple rows per session // (one per vehicle); for the per-session squadron we use MAX(squadron_name) // — players don't switch squadrons mid-match. function playerSessionDateClause(req) { const startDate = parseInt(req.query.start_date) || 0; const endDate = parseInt(req.query.end_date) || 0; let clause = ''; const params = []; if (startDate) { clause += ' AND p.endtime_unix >= ?'; params.push(startDate); } if (endDate) { clause += ' AND p.endtime_unix <= ?'; params.push(endDate); } return { clause, params }; } app.get('/api/analytics/player/maps/:uid', (req, res) => { const uid = req.params.uid; const { clause, params } = playerSessionDateClause(req); db.all( `SELECT p.session_id, MAX(p.squadron_name) AS sq, m.map_name, m.winning_sq, m.losing_sq FROM player_games_hist p JOIN match_summary m ON m.session_id = p.session_id WHERE p.UID = ?${clause} GROUP BY p.session_id`, [uid, ...params], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const stats = {}; for (const { sq, map_name, winning_sq, losing_sq } of rows) { if (!sq) continue; const norm = normalizeMapName(map_name) || { key: 'unknown', display: 'Unknown' }; if (!stats[norm.key]) stats[norm.key] = { map_name: norm.display, wins: 0, losses: 0 }; if (winning_sq === sq) stats[norm.key].wins++; else if (losing_sq === sq) stats[norm.key].losses++; } const result = Object.values(stats).map(r => ({ ...r, total: r.wins + r.losses, win_rate: (r.wins + r.losses) > 0 ? Math.round(r.wins / (r.wins + r.losses) * 1000) / 10 : 0, })).sort((a, b) => b.total - a.total); res.json(result); } ); }); app.get('/api/analytics/player/time/:uid', (req, res) => { const uid = req.params.uid; const { clause, params } = playerSessionDateClause(req); db.all( `SELECT p.session_id, MAX(p.squadron_name) AS sq, MAX(p.endtime_unix) AS endtime_unix, m.winning_sq, m.losing_sq FROM player_games_hist p JOIN match_summary m ON m.session_id = p.session_id WHERE p.UID = ?${clause} GROUP BY p.session_id`, [uid, ...params], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const hourly = {}; const daily = {}; for (const { sq, endtime_unix, winning_sq, losing_sq } of rows) { if (!sq || !endtime_unix) continue; const d = new Date(endtime_unix * 1000); const hour = d.getUTCHours(); if (!hourly[hour]) hourly[hour] = { wins: 0, losses: 0 }; if (winning_sq === sq) hourly[hour].wins++; else if (losing_sq === sq) hourly[hour].losses++; const dayMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); daily[dayMs] = (daily[dayMs] || 0) + 1; } const hourlyOut = {}; for (const hour of Object.keys(hourly).sort((a, b) => a - b)) { const s = hourly[hour]; const total = s.wins + s.losses; hourlyOut[hour] = { ...s, total, win_rate: total > 0 ? Math.round(s.wins / total * 1000) / 10 : 0 }; } const dailyOut = Object.entries(daily) .map(([day, count]) => ({ day: parseInt(day), count })) .sort((a, b) => a.day - b.day); res.json({ hourly: hourlyOut, daily: dailyOut }); } ); }); app.get('/api/analytics/player/timeline/:uid', (req, res) => { // Returns one row per (session, vehicle) the player used. Frontend groups // by session for the K/D & WR lines and by week × vehicle for the stacked bar. const uid = req.params.uid; const { clause, params } = playerSessionDateClause(req); db.all( `SELECT p.session_id, p.endtime_unix, p.vehicle, p.ground_kills, p.air_kills, p.deaths, p.squadron_name AS sq, m.winning_sq, m.losing_sq FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE p.UID = ?${clause} ORDER BY p.endtime_unix ASC`, [uid, ...params], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const result = rows.filter(r => r.endtime_unix).map(r => ({ session_id: r.session_id, endtime_unix: r.endtime_unix, vehicle: r.vehicle || null, kills: (r.ground_kills || 0) + (r.air_kills || 0), deaths: r.deaths || 0, won: r.winning_sq === r.sq ? 1 : (r.losing_sq === r.sq ? 0 : null), })); res.json(result); } ); }); app.get('/api/analytics/player/squadmates/:uid', (req, res) => { const uid = req.params.uid; const { clause, params } = playerSessionDateClause(req); const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 20, 50)); db.all( `WITH shared_sessions AS ( SELECT p.UID AS teammate_uid, p.session_id, me.squadron_name AS me_sq, m.winning_sq, m.losing_sq FROM player_games_hist p JOIN player_games_hist me ON me.session_id = p.session_id AND me.squadron_name = p.squadron_name LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE me.UID = ?${clause} AND p.UID != ? AND p.UID IS NOT NULL AND p.UID != '' AND p.nick NOT LIKE 'coop/%' AND me.nick NOT LIKE 'coop/%' ) SELECT teammate_uid AS uid, COUNT(DISTINCT session_id) AS shared, SUM(CASE WHEN winning_sq = me_sq THEN 1 ELSE 0 END) AS wins, SUM(CASE WHEN losing_sq = me_sq THEN 1 ELSE 0 END) AS losses FROM shared_sessions GROUP BY teammate_uid HAVING shared > 0 ORDER BY shared DESC, wins DESC, uid ASC LIMIT ?`, [uid, ...params, uid, limit], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const teammateUids = rows.map(r => r.uid).filter(Boolean); if (!teammateUids.length) return res.json([]); const placeholders = teammateUids.map(() => '?').join(','); db.all( `SELECT UID AS uid, nick, COUNT(*) AS cnt, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE UID IN (${placeholders}) AND nick NOT LIKE 'coop/%' GROUP BY UID, nick`, teammateUids, (nickErr, nickRows) => { if (nickErr) return res.status(500).json({ error: 'Database error' }); const bestNicks = new Map(); for (const row of nickRows) { const current = bestNicks.get(row.uid); const candidate = { nick: row.nick || row.uid, cnt: row.cnt || 0, last_seen: row.last_seen || 0, }; if (!current || candidate.cnt > current.cnt || (candidate.cnt === current.cnt && candidate.last_seen > current.last_seen)) { bestNicks.set(row.uid, candidate); } } const result = rows.map(row => { const wins = Number(row.wins) || 0; const losses = Number(row.losses) || 0; const shared = Number(row.shared) || 0; const resolved = wins + losses; return { uid: row.uid, nick: (bestNicks.get(row.uid) || {}).nick || row.uid, shared, wins, losses, win_rate: resolved > 0 ? Math.round((wins / resolved) * 1000) / 10 : 0, }; }); res.json(result); } ); } ); }); app.get('/api/analytics/player/matchup/:uid', (req, res) => { const uid = req.params.uid; const { clause, params } = playerSessionDateClause(req); db.all( `SELECT p.session_id, MAX(p.squadron_name) AS sq, m.winning_sq, m.losing_sq FROM player_games_hist p JOIN match_summary m ON m.session_id = p.session_id WHERE p.UID = ?${clause} GROUP BY p.session_id`, [uid, ...params], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const stats = {}; for (const { sq, winning_sq, losing_sq } of rows) { if (!sq || !winning_sq || !losing_sq || winning_sq === losing_sq) continue; const opponent = winning_sq === sq ? losing_sq : (losing_sq === sq ? winning_sq : null); if (!opponent) continue; if (!stats[opponent]) stats[opponent] = { opponent, wins: 0, losses: 0 }; if (winning_sq === sq) stats[opponent].wins++; else stats[opponent].losses++; } const enriched = Object.values(stats).map(r => { const total = r.wins + r.losses; return { ...r, total, win_rate: total > 0 ? Math.round(r.wins / total * 1000) / 10 : 0 }; }); const wonAgainst = [...enriched] .sort((a, b) => (b.wins - a.wins) || (b.total - a.total)) .slice(0, 10); const lostAgainst = [...enriched] .sort((a, b) => (b.losses - a.losses) || (b.total - a.total)) .slice(0, 10); res.json({ won_against: wonAgainst, lost_against: lostAgainst, total_opponents: enriched.length }); } ); }); // ─── VEHICLE-SCOPED ANALYTICS ────────────────────────────────────────────── function vehicleDateClause(req) { const startDate = parseInt(req.query.start_date) || 0; const endDate = parseInt(req.query.end_date) || 0; let clause = ''; const params = []; if (startDate) { clause += ' AND p.endtime_unix >= ?'; params.push(startDate); } if (endDate) { clause += ' AND p.endtime_unix <= ?'; params.push(endDate); } return { clause, params }; } // Set of vehicle_internal ids that have data, with their total spawn counts. // The display name is *not* served here — the client looks it up in the // translation map (/api/i18n/vehicles), which is keyed by internal and stores // per-language names with country/event glyphs (▄ ◢ ◊ ␗) baked in (see // BOT/utils.init_vehicle_translation_cache, strip_decorations=False). That map // is the single source of truth for display. let vehicleListCache = null; function ensureVehicleList(cb) { if (vehicleListCache) return cb(null, vehicleListCache); heavyDb.all( `SELECT LOWER(vehicle_internal) AS vehicle_internal, COUNT(*) AS total FROM player_games_hist WHERE vehicle_internal IS NOT NULL AND vehicle_internal != '' GROUP BY vehicle_internal COLLATE NOCASE`, [], (err, rows) => { if (err) return cb(err); vehicleListCache = rows .filter(r => r.vehicle_internal && r.vehicle_internal !== 'disconnected') .map(r => ({ vehicle_internal: r.vehicle_internal, total: r.total || 0 })) .sort((a, b) => b.total - a.total); cb(null, vehicleListCache); } ); } app.get('/api/analytics/vehicle-list', (req, res) => { const cacheKey = 'analytics_vehicle_list'; const cached = getCachedResponse(cacheKey); if (cached) return res.json(cached); ensureVehicleList((err, list) => { if (err) return res.status(500).json({ error: 'Database error' }); const out = { vehicles: list }; setCachedResponse(cacheKey, out); res.json(out); }); }); // Per-vehicle endpoints key on vehicle_internal (case-insensitive) so casing // duplicates in player_games_hist collapse, and country variants stay distinct. app.get('/api/analytics/vehicle/stats/:internal', (req, res) => { const internal = req.params.internal; const { clause, params } = vehicleDateClause(req); db.all( `SELECT p.UID, p.session_id, p.squadron_name AS sq, p.ground_kills, p.air_kills, p.deaths, m.winning_sq, m.losing_sq FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE p.vehicle_internal = ? COLLATE NOCASE${clause}`, [internal, ...params], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); let kills = 0, deaths = 0, wins = 0, losses = 0; const players = new Set(), sessions = new Set(); for (const r of rows) { kills += (r.ground_kills || 0) + (r.air_kills || 0); deaths += r.deaths || 0; if (r.UID) players.add(r.UID); if (r.session_id) sessions.add(r.session_id); if (r.sq && r.winning_sq === r.sq) wins++; else if (r.sq && r.losing_sq === r.sq) losses++; } const decided = wins + losses; res.json({ spawns: rows.length, unique_players: players.size, sessions: sessions.size, kills, deaths, kd: deaths > 0 ? Math.round((kills / deaths) * 100) / 100 : kills, ks: rows.length > 0 ? Math.round((kills / rows.length) * 100) / 100 : 0, win_rate: decided > 0 ? Math.round((wins / decided) * 1000) / 10 : 0, wins, losses, }); } ); }); app.get('/api/analytics/vehicle/players/:internal', (req, res) => { const internal = req.params.internal; const minGames = parseInt(req.query.min_games) || 3; const { clause, params } = vehicleDateClause(req); db.all( `SELECT p.UID, p.nick, p.squadron_name AS sq, p.session_id, p.ground_kills, p.air_kills, p.deaths, m.winning_sq, m.losing_sq FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE p.vehicle_internal = ? COLLATE NOCASE${clause}`, [internal, ...params], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const stats = {}; for (const r of rows) { if (!r.UID) continue; let s = stats[r.UID]; if (!s) { s = { uid: r.UID, nick: r.nick, sq: r.sq, kills: 0, deaths: 0, wins: 0, losses: 0, sessions: new Set() }; stats[r.UID] = s; } s.nick = r.nick; s.sq = r.sq; s.kills += (r.ground_kills || 0) + (r.air_kills || 0); s.deaths += r.deaths || 0; if (r.session_id) s.sessions.add(r.session_id); if (r.sq && r.winning_sq === r.sq) s.wins++; else if (r.sq && r.losing_sq === r.sq) s.losses++; } const out = Object.values(stats) .map(s => { const decided = s.wins + s.losses; return { uid: s.uid, nick: s.nick, squadron: s.sq || null, games: s.sessions.size, spawns: s.kills + s.deaths > 0 ? undefined : undefined, // placeholder kills: s.kills, deaths: s.deaths, kd: s.deaths > 0 ? Math.round((s.kills / s.deaths) * 100) / 100 : s.kills, win_rate: decided > 0 ? Math.round((s.wins / decided) * 1000) / 10 : 0, wins: s.wins, losses: s.losses, }; }) .filter(s => s.games >= minGames) .sort((a, b) => b.games - a.games) .slice(0, 500); res.json(out); } ); }); app.get('/api/analytics/vehicle/squadrons/:internal', (req, res) => { const internal = req.params.internal; const minGames = parseInt(req.query.min_games) || 3; const { clause, params } = vehicleDateClause(req); db.all( `SELECT p.squadron_name AS sq, p.session_id, p.ground_kills, p.air_kills, p.deaths, m.winning_sq, m.losing_sq FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE p.vehicle_internal = ? COLLATE NOCASE${clause}`, [internal, ...params], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const stats = {}; for (const r of rows) { if (!r.sq) continue; let s = stats[r.sq]; if (!s) { s = { squadron: r.sq, kills: 0, deaths: 0, wins: 0, losses: 0, sessions: new Set() }; stats[r.sq] = s; } s.kills += (r.ground_kills || 0) + (r.air_kills || 0); s.deaths += r.deaths || 0; if (r.session_id) s.sessions.add(r.session_id); if (r.winning_sq === r.sq) s.wins++; else if (r.losing_sq === r.sq) s.losses++; } const out = Object.values(stats) .map(s => { const decided = s.wins + s.losses; return { squadron: s.squadron, games: s.sessions.size, kills: s.kills, deaths: s.deaths, kd: s.deaths > 0 ? Math.round((s.kills / s.deaths) * 100) / 100 : s.kills, win_rate: decided > 0 ? Math.round((s.wins / decided) * 1000) / 10 : 0, wins: s.wins, losses: s.losses, }; }) .filter(s => s.games >= minGames) .sort((a, b) => b.games - a.games) .slice(0, 500); res.json(out); } ); }); app.get('/api/analytics/vehicle/maps/:internal', (req, res) => { const internal = req.params.internal; const { clause, params } = vehicleDateClause(req); db.all( `SELECT p.session_id, p.squadron_name AS sq, m.map_name, m.winning_sq, m.losing_sq FROM player_games_hist p JOIN match_summary m ON m.session_id = p.session_id WHERE p.vehicle_internal = ? COLLATE NOCASE${clause} GROUP BY p.session_id, p.squadron_name`, [internal, ...params], (err, rows) => { if (err) return res.status(500).json({ error: 'Database error' }); const stats = {}; for (const { sq, map_name, winning_sq, losing_sq } of rows) { if (!sq) continue; const norm = normalizeMapName(map_name) || { key: 'unknown', display: 'Unknown' }; if (!stats[norm.key]) stats[norm.key] = { map_name: norm.display, wins: 0, losses: 0 }; if (winning_sq === sq) stats[norm.key].wins++; else if (losing_sq === sq) stats[norm.key].losses++; } const result = Object.values(stats).map(r => ({ ...r, total: r.wins + r.losses, win_rate: (r.wins + r.losses) > 0 ? Math.round(r.wins / (r.wins + r.losses) * 1000) / 10 : 0, })).sort((a, b) => b.total - a.total); res.json(result); } ); }); // ─── i18n: vehicle translation map ──────────────────────────────────── // Serves a flat object: { internal_id: { en: "...", ru: "...", ... } } // produced by BOT/utils.init_vehicle_translation_cache() (Python). Falls back // to the English-only `vehicle_data_cache.json` so the page works even before // the multi-lang cache has been generated. let _vehicleTranslationsResponse = null; let _vehicleTranslationsMtime = 0; function buildTranslationsResponse() { const fullPath = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_translations.json'); const englishPath = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_data_cache_all.json'); const englishFallback = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_data_cache.json'); if (fs.existsSync(fullPath)) { const stat = fs.statSync(fullPath); if (_vehicleTranslationsResponse && stat.mtimeMs === _vehicleTranslationsMtime) { return _vehicleTranslationsResponse; } const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); _vehicleTranslationsResponse = { source: 'multilang', vehicles: data }; _vehicleTranslationsMtime = stat.mtimeMs; return _vehicleTranslationsResponse; } const target = fs.existsSync(englishPath) ? englishPath : (fs.existsSync(englishFallback) ? englishFallback : null); if (!target) return { source: 'none', vehicles: {} }; const stat = fs.statSync(target); if (_vehicleTranslationsResponse && stat.mtimeMs === _vehicleTranslationsMtime) { return _vehicleTranslationsResponse; } const raw = JSON.parse(fs.readFileSync(target, 'utf-8')); const vehicles = {}; for (const entry of raw) { if (!Array.isArray(entry) || entry.length < 2) continue; const cdk = entry[0]; const englishName = entry[1]; vehicles[cdk] = { en: englishName }; } _vehicleTranslationsResponse = { source: 'english_only', vehicles }; _vehicleTranslationsMtime = stat.mtimeMs; return _vehicleTranslationsResponse; } app.get('/api/i18n/vehicles', (req, res) => { res.set('Cache-Control', 'public, max-age=3600'); res.json(buildTranslationsResponse()); }); app.get('/api/i18n/vehicle-types', (req, res) => { const map = loadVehicleMetaCache(); const types = {}; for (const [internal, meta] of map.entries()) { types[internal] = meta && meta.type ? meta.type : '?'; } res.set('Cache-Control', 'public, max-age=3600'); res.json({ source: 'vehicle_meta_cache', types }); }); app.use((err, req, res, next) => { console.error('Unhandled error:', err); res.status(500).json({ error: 'Internal server error' }); }); app.use((req, res) => { res.status(404).json({ error: 'Endpoint not found', availableEndpoints: [ 'GET /api/player/:uid', 'GET /api/player/:uid/games', 'GET /api/search/:nickname', 'GET /api/live', 'GET /api/leaderboard/players', 'GET /api/leaderboard/squadrons', 'GET /api/leaderboard/vehicles', 'GET /api/leaderboard/stats', 'GET /api/squadrons/:squadronname', 'GET /api/analytics/maps/:squadron', 'GET /api/analytics/player/squadmates/:uid', 'GET /api/analytics/time/:squadron', 'GET /api/analytics/comps/:squadron', 'GET /api/analytics/consistency/:squadron', 'GET /api/i18n/vehicles', 'GET /api/analytics/matchup/:squadron', 'GET /api/debug/schema', 'GET /health', 'GET /api/info', 'GET /api/i18n/vehicle-types' ] }); }); // Periodic database liveness check (lightweight query, every 5 minutes) setInterval(() => { db.get("SELECT 1", (err) => { if (err) log.error('Database liveness check failed', err); }); }, 300000); // Periodic WAL checkpoint every 10 min. PASSIVE mode is intentional: TRUNCATE // blocks db's worker thread while waiting for heavyDb readers (vehicle/player // leaderboards run 47–133s), which serializes all db queries behind it. // PASSIVE never blocks — it checkpoints what it can and skips if readers are active. setTimeout(() => { setInterval(() => { runWalCheckpoint('PASSIVE', 'Periodic WAL checkpoint completed', 'Periodic WAL checkpoint failed:', 'debug'); }, 600000); // Every 10 minutes }, 150000); // Start after 2.5 min offset // Warm hot leaderboard windows shortly after boot, then every 4 min. // The first tick waits for the DB-ready grace so heavy scans don't fight startup. setTimeout(warmLeaderboards, 20000); setInterval(warmLeaderboards, 4 * 60 * 1000); app.listen(PORT, () => { console.log(`SREBOT Player API server running on port ${PORT}`); console.log(`Health check: http://localhost:${PORT}/health`); console.log(`API info: http://localhost:${PORT}/api/info`); log.info('Database refresh interval set to 60 seconds'); }); // All-time benchmark warmup used to run here at +5s. Removed: the dual // CTE-heavy benchmark queries (player + squadron, each ~minutes on the full // 4M-row table) were the dominant startup cost and blocked the read connection // against incoming user traffic. The cache fills lazily on first request now // (TTL is 1 hour, see PERFORMANCE_BENCHMARK_CACHE_TTL). // Last-resort guards. SQLite BUSY/IOERR errors that escape callback handling // would otherwise tear down the API and put PM2 into a restart loop (we hit // 127 restarts in one afternoon from a BUSY on squadrons.db). Log and keep // running — every code path that can produce these has its own handler now, // and a real bug should surface in the logs rather than via a crash. process.on('uncaughtException', (err) => { log.error('Uncaught exception (continuing)', err, { message: err && err.message, code: err && err.code }); }); process.on('unhandledRejection', (reason) => { const r = reason instanceof Error ? reason : new Error(String(reason)); log.error('Unhandled rejection (continuing)', r, { message: r.message, code: r.code }); }); process.on('SIGINT', () => { console.log('\nShutting down server...'); db.close((err) => { if (err) { console.error('Error closing database:', err.message); } else { console.log('Database connection closed.'); } process.exit(0); }); });