2b399fdb81
PR #1223 only staged the deletions of the old paths because the new top-level directories were still untracked when the commit was authored. This commit adds the actual restructured tree: SREBOT/ (existing bot), SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES, update_game_files), and TSSBOT/ (skeleton). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6000 lines
252 KiB
JavaScript
6000 lines
252 KiB
JavaScript
require('dotenv').config();
|
||
const express = require('express');
|
||
const sqlite3 = require('sqlite3').verbose();
|
||
const cors = require('cors');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const zlib = require('zlib');
|
||
const seasonsUtil = require('./web/utils/seasons');
|
||
|
||
/** 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.SREBOT_STORAGE_VOL_PATH || '').trim();
|
||
if (!STORAGE_ROOT) {
|
||
throw new Error('SREBOT_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) {
|
||
return path.join(REPLAYS_PATH, String(sessionId).toLowerCase(), 'replay_data.json');
|
||
}
|
||
|
||
const app = express();
|
||
const PORT = process.env.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;
|
||
}
|
||
|
||
// 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 ELO 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 ELO 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 ELO 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 ELO benchmarks', err);
|
||
return [];
|
||
});
|
||
})(),
|
||
(() => {
|
||
const benchmarkQuery = squadronBenchmarkQuery(dateFilters);
|
||
return dbAllHeavy(benchmarkQuery.query, benchmarkQuery.params).catch(err => {
|
||
log.error('Failed to load squadron ELO 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 ELO 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(fs.readFileSync(replayPath, 'utf-8'));
|
||
} 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 = ? 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(fs.readFileSync(replayPath, 'utf-8'));
|
||
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 = `leaderboard_players_${start_date || 'all'}_${end_date || 'all'}`;
|
||
const cached = getCachedResponse(cacheKey);
|
||
if (cached) {
|
||
log.info('Returning cached leaderboard response');
|
||
return res.json(applyLimit(cached));
|
||
}
|
||
|
||
if (!dateFilters.hasFilter) {
|
||
return res.status(400).json({
|
||
error: 'A date filter (start_date/end_date/season/week) is required for uncached all-time player leaderboard queries.',
|
||
errorCode: 'FILTER_REQUIRED'
|
||
});
|
||
}
|
||
|
||
// Dedup: if this exact query is already running, wait for it
|
||
dedup(cacheKey, () => 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);
|
||
reject(err);
|
||
return res.status(500).json({ error: 'Database error', errorCode: 'DB_PLAYER_LEADERBOARD_FAILED' });
|
||
}
|
||
|
||
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
|
||
});
|
||
|
||
setCachedResponse(cacheKey, response);
|
||
resolve(response);
|
||
res.json(applyLimit(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({});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
})).catch(err => {
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: 'Database error', 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'}_${start_date || 'all'}_${end_date || 'all'}`;
|
||
const cached = getCachedResponse(cacheKey);
|
||
if (cached) {
|
||
log.info('Returning cached vehicle leaderboard');
|
||
return res.json(applyLimit(cached));
|
||
}
|
||
|
||
if (!vehicle && !dateFilters.hasFilter) {
|
||
return res.status(400).json({
|
||
error: 'A date filter (start_date/end_date/season/week) or vehicle filter is required for uncached all-time vehicle leaderboard queries.',
|
||
errorCode: 'FILTER_REQUIRED'
|
||
});
|
||
}
|
||
|
||
dedup(cacheKey, () => 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);
|
||
reject(err);
|
||
return res.status(500).json({ error: 'Database error', errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED' });
|
||
}
|
||
|
||
// 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 });
|
||
setCachedResponse(cacheKey, response);
|
||
resolve(response);
|
||
res.json(applyLimit(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({});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
})).catch(err => {
|
||
if (!res.headersSent) {
|
||
res.status(500).json({ error: 'Database error', 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 = `leaderboard_squadrons_${start_date || 'all'}_${end_date || 'all'}`;
|
||
const cached = getCachedResponse(cacheKey);
|
||
if (cached) {
|
||
log.info('Returning cached squadron leaderboard');
|
||
return res.json(applyLimit(cached));
|
||
}
|
||
|
||
dedup(cacheKey, () => 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'
|
||
};
|
||
setCachedResponse(cacheKey, emptyResponse);
|
||
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
|
||
});
|
||
|
||
setCachedResponse(cacheKey, response);
|
||
resolve(response);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|
||
})).then(response => {
|
||
if (!res.headersSent) res.json(applyLimit(response));
|
||
}).catch(err => {
|
||
if (res.headersSent) return;
|
||
if (err && err.status && err.body) {
|
||
return res.status(err.status).json(err.body);
|
||
}
|
||
log.error('Unhandled error in squadron leaderboard', err);
|
||
res.status(500).json({ error: 'Database error occurred', 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 sqKps = sqBattles > 0 ? parseFloat((sqTotalKills / sqBattles).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);
|
||
});
|
||
});
|
||
});
|
||
|
||
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 47–133s), which serializes all db queries behind it.
|
||
// PASSIVE never blocks — it checkpoints what it can and skips if readers are active.
|
||
setTimeout(() => {
|
||
setInterval(() => {
|
||
runWalCheckpoint('PASSIVE', 'Periodic WAL checkpoint completed', 'Periodic WAL checkpoint failed:', 'debug');
|
||
}, 600000); // Every 10 minutes
|
||
}, 150000); // Start after 2.5 min offset
|
||
|
||
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);
|
||
});
|
||
});
|