add TSS API endpoints + web proxy (#1234)

Adds /api/tss/teams/* and /api/tss/leaderboard/teams to SREBOT/server.js,
reading tss_battles.db and tss_teams.db with queries adapted to the
team_id / team_name / teams_data schema. Mirrors the existing
/api/squadrons/* endpoints. SREBOT/web/server.js gains matching proxy
routes so the frontend can reach them via the website host.

TSSBOT/BOT/storage.py picks up the columns and table the endpoints need
to return meaningful data: teams_data.clanrating, team_members.points,
and a new teams_points history table. All idempotent under the existing
init_tss_dbs() call.

Co-authored-by: Heidi <clippii@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
NotSoToothless
2026-05-14 14:21:58 -07:00
committed by GitHub
parent ec08655a59
commit 6e66d23313
2 changed files with 770 additions and 0 deletions
+678
View File
@@ -22,6 +22,8 @@ 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');
const TSS_TEAMS_DB_PATH = path.join(STORAGE_ROOT, 'tss_teams.db');
const TSS_BATTLES_DB_PATH = path.join(STORAGE_ROOT, 'tss_battles.db');
fs.mkdirSync(REPLAYS_PATH, { recursive: true });
function replayDataPath(sessionId) {
@@ -123,8 +125,11 @@ const STATS_CACHE_TTL = 30 * 60 * 1000; // Global stats change slowly — cache
const inflightRequests = new Map(); // dedup: key -> Promise
let squadronLookupCache = null;
let squadronLookupCacheTime = 0;
let tssTeamLookupCache = null;
let tssTeamLookupCacheTime = 0;
const SQUADRON_CACHE_TTL = 30 * 60 * 1000;
let hasSquadronColumn = false;
let hasTssTeamColumn = false;
let nickLookupCache = null;
let nickLookupCacheTime = 0;
const performanceBenchmarkCache = new Map();
@@ -418,6 +423,87 @@ function loadSquadronLookupCached(callback) {
});
}
function loadTssTeamLookupCached(callback) {
if (tssTeamLookupCache && Date.now() - tssTeamLookupCacheTime < SQUADRON_CACHE_TTL) {
return callback(tssTeamLookupCache);
}
if (!fs.existsSync(TSS_TEAMS_DB_PATH)) {
tssTeamLookupCache = {};
tssTeamLookupCacheTime = Date.now();
return callback({});
}
const teamsDb = new sqlite3.Database(TSS_TEAMS_DB_PATH, sqlite3.OPEN_READONLY, (err) => {
if (err) {
log.error('Failed to open TSS teams database', err);
tssTeamLookupCache = {};
tssTeamLookupCacheTime = Date.now();
return callback({});
}
teamsDb.all(`SELECT team_id AS clan_id, long_name, short_name, tag_name, members, clanrating FROM teams_data`, [], (err, rows) => {
teamsDb.close();
if (err) {
log.error('Error loading TSS team lookup data', err);
tssTeamLookupCache = {};
tssTeamLookupCacheTime = Date.now();
return callback(tssTeamLookupCache);
}
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);
});
tssTeamLookupCache = lookup;
tssTeamLookupCacheTime = Date.now();
log.info('TSS team lookup cache updated', { entries: (rows || []).length });
callback(tssTeamLookupCache);
});
});
}
function resolveTssTeam(input, lookup) {
const raw = input == null ? '' : String(input).trim();
const hit = raw && lookup ? (lookup[raw] || lookup[raw.toLowerCase()]) : null;
const canonicalName = hit ? (hit.long_name || raw) : raw;
const variants = new Set();
if (raw) variants.add(raw);
if (canonicalName) variants.add(canonicalName);
if (hit) {
if (hit.long_name) variants.add(hit.long_name);
if (hit.short_name) variants.add(hit.short_name);
if (hit.tag_name) variants.add(hit.tag_name);
Object.keys(lookup || {}).forEach(key => {
if (lookup[key] && lookup[key].long_name === hit.long_name) variants.add(key);
});
}
return {
row: hit || null,
clanId: hit && hit.clan_id != null ? Number(hit.clan_id) : null,
canonicalName,
tagName: hit ? (hit.tag_name || hit.short_name || canonicalName) : raw,
variants: Array.from(variants).filter(Boolean),
};
}
function tssDbUnavailable(res) {
return res.status(404).json({
error: 'TSS database not found',
required_databases: ['tss_battles.db', 'tss_teams.db']
});
}
function loadNickLookupCached(callback) {
if (nickLookupCache && Date.now() - nickLookupCacheTime < SQUADRON_CACHE_TTL) {
return callback(nickLookupCache);
@@ -1785,6 +1871,29 @@ const heavyDb = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => {
else log.info('Heavy-reader connection open');
});
let tssDb = null;
let tssHeavyDb = null;
if (fs.existsSync(TSS_BATTLES_DB_PATH)) {
tssHeavyDb = new sqlite3.Database(TSS_BATTLES_DB_PATH, sqlite3.OPEN_READONLY, (err) => {
if (err) log.warn('Failed to open TSS heavy reader connection:', err.message);
else log.info('TSS heavy-reader connection open');
});
tssDb = new sqlite3.Database(TSS_BATTLES_DB_PATH, sqlite3.OPEN_READONLY, (err) => {
if (err) {
log.error('Failed to open TSS battles database', err, { dbPath: TSS_BATTLES_DB_PATH });
tssDb = null;
return;
}
log.info('Connected to TSS battles database in read-only mode', { dbPath: TSS_BATTLES_DB_PATH });
tssDb.all("PRAGMA table_info(player_games_hist)", (schemaErr, cols) => {
if (!schemaErr) hasTssTeamColumn = cols.some(c => c.name === 'team_name');
log.info('TSS schema check complete', { hasTssTeamColumn });
});
});
} else {
log.warn('TSS battles database not found; /api/tss endpoints will return 404', { dbPath: TSS_BATTLES_DB_PATH });
}
const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => {
if (err) {
log.error('Failed to open database', err, { dbPath: DB_PATH });
@@ -2372,6 +2481,7 @@ app.get('/health', (req, res) => {
status: 'OK',
timestamp: new Date().toISOString(),
database: 'Connected',
tss_database: tssDb ? 'Connected' : 'Unavailable',
ready: serverReady,
uptime_ms: Date.now() - startupReadyAt,
});
@@ -3063,6 +3173,15 @@ const API_INFO = {
"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 /api/tss/teams/resolve": "Resolve TSS team short/tag/long name to canonical metadata",
"GET /api/tss/leaderboard/teams": "Get TSS team leaderboards",
"GET /api/tss/leaderboard/squadrons": "Alias for TSS team leaderboards",
"GET /api/tss/teams/:teamname": "Get TSS team roster stats and summary",
"GET /api/tss/teams/:teamname/history": "Get TSS team battle and rating history",
"GET /api/tss/teams/:teamname/games": "Get TSS team match list",
"GET /api/tss/squadrons/:squadronname": "Alias for TSS team roster stats and summary",
"GET /api/debug/tss-teams-db-schema": "Inspect TSS teams database schema",
"GET /api/debug/tss-battles-db-schema": "Inspect TSS battles database schema",
"GET /health": "Health check endpoint",
"GET /api/info": "API information"
},
@@ -3205,6 +3324,65 @@ const API_INFO = {
]
}
]
},
{
file: "tss_battles.db",
purpose: "TSS battle history store. Same schema as sq_battles.db and served by /api/tss endpoints.",
tables: [
{
name: "player_games_hist",
description: "One row per TSS player per battle / vehicle.",
used_by: [
"GET /api/tss/leaderboard/teams",
"GET /api/tss/leaderboard/squadrons",
"GET /api/tss/teams/:teamname",
"GET /api/tss/teams/:teamname/history",
"GET /api/tss/teams/:teamname/games",
"GET /api/tss/squadrons/:squadronname",
"GET /api/debug/tss-battles-db-schema"
]
},
{
name: "match_summary",
description: "One row per TSS match.",
used_by: [
"GET /api/tss/teams/:teamname/games",
"GET /api/debug/tss-battles-db-schema"
]
}
]
},
{
file: "tss_teams.db",
purpose: "TSS team roster cache, team metadata, and historical point snapshots. Served by /api/tss endpoints.",
tables: [
{
name: "teams_data",
description: "Canonical TSS team directory and latest leaderboard snapshot.",
used_by: [
"GET /api/tss/teams/resolve",
"GET /api/tss/leaderboard/teams",
"GET /api/tss/teams/:teamname",
"GET /api/tss/teams/:teamname/history",
"GET /api/debug/tss-teams-db-schema"
]
},
{
name: "team_members",
description: "Current per-team member roster with cached nick and points values.",
used_by: [
"GET /api/tss/teams/:teamname"
]
},
{
name: "teams_points",
description: "Historical TSS team point snapshots keyed by unix time.",
used_by: [
"GET /api/tss/teams/:teamname/history",
"GET /api/debug/tss-teams-db-schema"
]
}
]
}
]
};
@@ -4834,6 +5012,500 @@ app.get('/api/squadrons/:squadronname/games', (req, res) => {
});
});
function sendSqliteSchema(res, dbPath, label) {
if (!fs.existsSync(dbPath)) {
return res.status(404).json({
error: `${label} database not found`,
path: dbPath
});
}
const schemaDb = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => {
if (err) {
return res.status(500).json({
error: `Failed to open ${label} database`,
details: err.message
});
}
schemaDb.all(`SELECT name FROM sqlite_master WHERE type='table'`, (err, tables) => {
if (err) {
schemaDb.close();
return res.status(500).json({ error: 'Failed to get table list', details: err.message });
}
const tableSchemas = {};
let completedTables = 0;
if (!tables.length) {
schemaDb.close();
return res.json({ database_path: dbPath, tables: {}, message: `No tables found in ${label} database` });
}
tables.forEach(table => {
const tableName = table.name;
schemaDb.all(`PRAGMA table_info(${tableName})`, (schemaErr, schema) => {
if (schemaErr) {
tableSchemas[tableName] = { error: schemaErr.message };
completedTables++;
if (completedTables === tables.length) {
schemaDb.close();
res.json({ database_path: dbPath, tables: tableSchemas, timestamp: new Date().toISOString() });
}
return;
}
schemaDb.all(`SELECT * FROM ${tableName} LIMIT 3`, (sampleErr, samples) => {
tableSchemas[tableName] = {
schema,
sample_records: sampleErr ? [] : (samples || []),
record_count: sampleErr ? 0 : (samples || []).length
};
completedTables++;
if (completedTables === tables.length) {
schemaDb.close();
res.json({ database_path: dbPath, tables: tableSchemas, timestamp: new Date().toISOString() });
}
});
});
});
});
});
}
app.get('/api/debug/tss-teams-db-schema', requireAdminBearer, (req, res) => {
sendSqliteSchema(res, TSS_TEAMS_DB_PATH, 'TSS teams');
});
app.get('/api/debug/tss-battles-db-schema', requireAdminBearer, (req, res) => {
sendSqliteSchema(res, TSS_BATTLES_DB_PATH, 'TSS battles');
});
function tssAll(database, query, params = []) {
return new Promise((resolve, reject) => {
if (!database) return reject(new Error('TSS database unavailable'));
database.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || []));
});
}
function tssGet(database, query, params = []) {
return new Promise((resolve, reject) => {
if (!database) return reject(new Error('TSS database unavailable'));
database.get(query, params, (err, row) => err ? reject(err) : resolve(row || null));
});
}
function tssDateParams(dateFilters) {
return [
dateFilters.startTimestamp,
dateFilters.startTimestamp,
dateFilters.endTimestamp,
dateFilters.endTimestamp,
];
}
function tssAttrClause(alias, team) {
const prefix = alias ? `${alias}.` : '';
if (team.clanId != null) return { clause: `${prefix}team_id = ?`, params: [team.clanId] };
const variants = team.variants.length ? team.variants : [team.canonicalName];
return {
clause: `${prefix}team_name IN (${variants.map(() => '?').join(',')})`,
params: variants,
};
}
function registerTssTeamRoutes(teamBasePath) {
app.get(`${teamBasePath}/resolve`, (req, res) => {
const name = req.query.name || req.query.q || req.query.team || req.query.squadron || '';
if (!name) return res.status(400).json({ error: 'name query parameter is required' });
loadTssTeamLookupCached((lookup) => {
const team = resolveTssTeam(name, lookup);
res.json({
query: String(name),
resolved: !!team.row,
clan_id: team.clanId,
long_name: team.canonicalName,
short_name: team.row ? team.row.short_name : null,
tag_name: team.tagName,
members: team.row ? team.row.members : null,
clanrating: team.row ? team.row.clanrating : null,
source: 'tss_teams.db'
});
});
});
app.get(`${teamBasePath}/:teamname`, (req, res) => {
if (!tssDb) return tssDbUnavailable(res);
if (!hasTssTeamColumn) {
return res.json({
tag_name: req.params.teamname,
long_name: req.params.teamname,
team_summary: { player_count: 0, total_kills: 0, total_battles: 0, wins: 0 },
players: [],
migration_needed: true
});
}
const { teamname } = req.params;
const dateFilters = parseDateFilters(req);
const { start_date, end_date, season, week } = req.query;
const cacheKey = `tss_team_detail_${teamBasePath}_${teamname}_${start_date || ''}_${end_date || ''}_${season || ''}_${week || ''}`;
const cached = getCachedResponse(cacheKey);
if (cached) return res.json(cached);
loadTssTeamLookupCached((lookup) => {
const team = resolveTssTeam(teamname, lookup);
const attr = tssAttrClause('p', team);
const summaryAttr = tssAttrClause('', team);
const membersPromise = new Promise((resolve) => {
if (!team.clanId || !fs.existsSync(TSS_TEAMS_DB_PATH)) return resolve([]);
const teamsDb = new sqlite3.Database(TSS_TEAMS_DB_PATH, sqlite3.OPEN_READONLY, (err) => {
if (err) return resolve([]);
teamsDb.all(`SELECT uid, nick, points FROM team_members WHERE team_id = ?`, [team.clanId], (err, rows) => {
teamsDb.close();
resolve(err ? [] : (rows || []));
});
});
});
const playerStatsQuery = `
SELECT
p.UID as uid,
MAX(p.nick) as nick,
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,
COUNT(*) as total_battles,
COUNT(DISTINCT p.session_id) as sessions,
SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins
FROM player_games_hist p
WHERE ${attr.clause}
AND p.UID IS NOT NULL
AND (? IS NULL OR p.endtime_unix >= ?)
AND (? IS NULL OR p.endtime_unix <= ?)
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 ${summaryAttr.clause}
AND UID IS NOT NULL
AND (? IS NULL OR endtime_unix >= ?)
AND (? IS NULL OR endtime_unix <= ?)
`;
Promise.all([
membersPromise,
tssAll(tssDb, playerStatsQuery, [...attr.params, ...tssDateParams(dateFilters)]),
tssGet(tssDb, summaryQuery, [...summaryAttr.params, ...tssDateParams(dateFilters)])
]).then(([memberRows, statsRows, summaryRow]) => {
const memberByUid = new Map((memberRows || []).map(r => [String(r.uid), r]));
const players = (statsRows || []).map(row => {
const uid = String(row.uid);
const member = memberByUid.get(uid) || {};
const kills = row.total_kills || 0;
const deaths = row.total_deaths || 0;
const battles = row.total_battles || 0;
const wins = row.wins || 0;
return {
uid,
nick: member.nick || row.nick || '',
points: member.points || 0,
total_kills: kills,
ground_kills: row.total_ground_kills || 0,
air_kills: row.total_air_kills || 0,
total_battles: battles,
wins,
win_rate: battles > 0 ? parseFloat(((wins / battles) * 100).toFixed(1)) : 0,
kdr: deaths > 0 ? parseFloat((kills / deaths).toFixed(1)) : kills,
deaths,
assists: row.total_assists || 0,
captures: row.total_captures || 0
};
}).sort((a, b) => b.total_kills - a.total_kills);
const summary = summaryRow || {};
const totalKills = summary.total_kills || 0;
const deaths = summary.total_deaths || 0;
const totalBattles = summary.total_battles || 0;
const wins = summary.wins || 0;
const response = {
data_set: 'tss',
date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week),
clan_id: team.clanId,
tag_name: team.tagName,
short_name: team.row ? (team.row.short_name || team.tagName) : team.tagName,
long_name: team.canonicalName,
team_summary: {
player_count: team.row ? (team.row.members || players.length) : players.length,
total_kills: totalKills,
ground_kills: summary.total_ground_kills || 0,
air_kills: summary.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: summary.total_assists || 0,
captures: summary.total_captures || 0,
points: {
total_points: team.row ? (team.row.clanrating || 0) : 0,
has_points_data: !!(team.row && team.row.clanrating)
}
},
squadron_summary: null,
players
};
response.squadron_summary = response.team_summary;
setCachedResponse(cacheKey, response);
res.json(response);
}).catch(err => {
log.error('Database error in TSS team details query', err, { teamName: teamname });
res.status(500).json({ error: 'Database error', errorCode: 'DB_TSS_TEAM_QUERY_FAILED' });
});
});
});
app.get(`${teamBasePath}/:teamname/history`, (req, res) => {
if (!tssDb) return tssDbUnavailable(res);
const { teamname } = req.params;
const cacheKey = `tss_team_history_${teamBasePath}_${teamname}`;
const cached = getCachedResponse(cacheKey);
if (cached) return res.json(cached);
loadTssTeamLookupCached((lookup) => {
const team = resolveTssTeam(teamname, lookup);
const attr = tssAttrClause('', team);
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 ${attr.clause}
AND UID IS NOT NULL
AND endtime_unix IS NOT NULL
GROUP BY period
ORDER BY period ASC
`;
const ratingPromise = new Promise((resolve) => {
if (!fs.existsSync(TSS_TEAMS_DB_PATH)) return resolve([]);
const teamsDb = new sqlite3.Database(TSS_TEAMS_DB_PATH, sqlite3.OPEN_READONLY, (err) => {
if (err) return resolve([]);
const ratingQuery = team.clanId
? `SELECT unix_time, total_score FROM teams_points WHERE team_id = ? ORDER BY unix_time ASC`
: `SELECT unix_time, total_score FROM teams_points WHERE long_name = ? ORDER BY unix_time ASC`;
teamsDb.all(ratingQuery, [team.clanId || team.canonicalName], (err, rows) => {
teamsDb.close();
resolve(err ? [] : (rows || []));
});
});
});
Promise.all([
tssAll(tssDb, historyQuery, attr.params),
ratingPromise
]).then(([historyRows, ratingRows]) => {
const response = {
data_set: 'tss',
history: historyRows,
rating_hourly: ratingRows.map(r => ({ t: r.unix_time, rating: r.total_score }))
};
setCachedResponse(cacheKey, response);
res.json(response);
}).catch(err => {
log.error('Database error in TSS team history query', err, { teamName: teamname });
res.status(500).json({ error: 'Database error', errorCode: 'DB_TSS_TEAM_HISTORY_FAILED' });
});
});
});
app.get(`${teamBasePath}/:teamname/games`, (req, res) => {
if (!tssDb) return tssDbUnavailable(res);
const { teamname } = req.params;
const dateFilters = parseDateFilters(req);
const cacheKey = `tss_team_games_${teamBasePath}_${teamname}_${dateFilters.startTimestamp || 'null'}_${dateFilters.endTimestamp || 'null'}`;
const cached = getCachedResponse(cacheKey);
if (cached) return res.json(cached);
loadTssTeamLookupCached((lookup) => {
const team = resolveTssTeam(teamname, lookup);
const attr = tssAttrClause('p', team);
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(p.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 ${attr.clause}
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
`;
tssAll(tssDb, gamesQuery, [...attr.params, ...tssDateParams(dateFilters)]).then(rows => {
const response = {
data_set: 'tss',
tag_name: team.tagName,
long_name: team.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);
}).catch(err => {
log.error('Database error in TSS team games query', err, { teamName: teamname });
res.status(500).json({ error: 'Database error', errorCode: 'DB_TSS_TEAM_GAMES_FAILED' });
});
});
});
}
function handleTssTeamLeaderboard(req, res) {
if (!tssDb || !tssHeavyDb) return tssDbUnavailable(res);
const dateFilters = parseDateFilters(req);
const { start_date, end_date, season, week, limit } = req.query;
const maxRows = Math.max(1, Math.min(1000, parseInt(limit) || 1000));
const cacheKey = `tss_leaderboard_teams_${start_date || 'all'}_${end_date || 'all'}_${season || 'all'}_${week || 'all'}_${maxRows}`;
const cached = getCachedResponse(cacheKey);
if (cached) return res.json(cached);
const query = `
SELECT
team_id AS clan_id,
team_name AS squadron_name,
COUNT(DISTINCT UID) as player_count,
COUNT(*) as total_battles,
COUNT(DISTINCT session_id) as sessions,
COUNT(DISTINCT CASE WHEN UPPER(victor_bool) = 'WIN' THEN session_id END) as wins,
SUM(ground_kills + air_kills) as total_kills,
SUM(ground_kills) as ground_kills,
SUM(air_kills) as air_kills,
SUM(assists) as assists,
SUM(captures) as captures,
SUM(deaths) as deaths
FROM player_games_hist
WHERE team_name IS NOT NULL
AND team_name != ''
AND (? IS NULL OR endtime_unix >= ?)
AND (? IS NULL OR endtime_unix <= ?)
GROUP BY team_id, team_name
`;
Promise.all([
tssAll(tssHeavyDb, query, tssDateParams(dateFilters)),
new Promise(resolve => loadTssTeamLookupCached(resolve))
]).then(([rows, lookup]) => {
const consolidated = {};
for (const row of rows) {
const lookupRow = row.clan_id != null ? (lookup[String(row.clan_id)] || null) : null;
const key = row.clan_id != null ? `id:${row.clan_id}` : `name:${row.squadron_name}`;
if (!consolidated[key]) {
consolidated[key] = {
clan_id: row.clan_id || (lookupRow ? lookupRow.clan_id : null),
tag_name: lookupRow ? lookupRow.tag_name : row.squadron_name,
short_name: lookupRow ? lookupRow.short_name : row.squadron_name,
long_name: lookupRow ? lookupRow.long_name : row.squadron_name,
player_count: lookupRow ? (lookupRow.members || 0) : 0,
total_battles: 0,
wins: 0,
total_kills: 0,
ground_kills: 0,
air_kills: 0,
deaths: 0,
assists: 0,
captures: 0,
points: {
total_points: lookupRow ? (lookupRow.clanrating || 0) : 0,
has_points_data: !!(lookupRow && lookupRow.clanrating)
}
};
}
const item = consolidated[key];
item.player_count = Math.max(item.player_count || 0, row.player_count || 0);
item.total_battles += row.sessions || row.total_battles || 0;
item.wins += row.wins || 0;
item.total_kills += row.total_kills || 0;
item.ground_kills += row.ground_kills || 0;
item.air_kills += row.air_kills || 0;
item.deaths += row.deaths || 0;
item.assists += row.assists || 0;
item.captures += row.captures || 0;
}
const teams = Object.values(consolidated).map(team => ({
...team,
win_rate: team.total_battles > 0 ? parseFloat(((team.wins / team.total_battles) * 100).toFixed(1)) : 0,
kdr: team.deaths > 0 ? parseFloat((team.total_kills / team.deaths).toFixed(1)) : team.total_kills
})).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) return -1;
if (b.points.has_points_data) return 1;
return b.total_kills - a.total_kills;
});
const response = {
data_set: 'tss',
date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week),
total_teams: teams.length,
total_squadrons: teams.length,
teams: teams.slice(0, maxRows),
squadrons: teams.slice(0, maxRows)
};
setCachedResponse(cacheKey, response);
res.json(response);
}).catch(err => {
log.error('Database error in TSS team leaderboard', err);
res.status(500).json({ error: 'Database error', errorCode: 'DB_TSS_TEAM_LEADERBOARD_FAILED' });
});
}
app.get('/api/tss/leaderboard/teams', handleTssTeamLeaderboard);
app.get('/api/tss/leaderboard/squadrons', handleTssTeamLeaderboard);
registerTssTeamRoutes('/api/tss/teams');
registerTssTeamRoutes('/api/tss/squadrons');
app.get('/api/leaderboard/stats', (req, res) => {
log.info('Leaderboard stats request received');
const dateFilters = parseDateFilters(req);
@@ -5927,6 +6599,10 @@ app.use((req, res) => {
'GET /api/leaderboard/vehicles',
'GET /api/leaderboard/stats',
'GET /api/squadrons/:squadronname',
'GET /api/tss/leaderboard/teams',
'GET /api/tss/teams/:teamname',
'GET /api/tss/teams/:teamname/history',
'GET /api/tss/teams/:teamname/games',
'GET /api/analytics/maps/:squadron',
'GET /api/analytics/player/squadmates/:uid',
'GET /api/analytics/time/:squadron',
@@ -5988,6 +6664,8 @@ process.on('unhandledRejection', (reason) => {
process.on('SIGINT', () => {
console.log('\nShutting down server...');
if (tssHeavyDb) tssHeavyDb.close(() => {});
if (tssDb) tssDb.close(() => {});
db.close((err) => {
if (err) {
console.error('Error closing database:', err.message);