Files
SREBOT/server.js
T

6126 lines
258 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('./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: '<unresolved>',
};
}
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 || '<unresolved>',
};
}
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<internal, count>
// 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<sid, outcome> }
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 47133s), 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);
});
});