sync server.js from production (roster-vs-game-history freshness comparison for squadron identity, replacing the older per-request fallback approach)

This commit is contained in:
deploy-migration
2026-07-02 14:59:33 +00:00
parent 1d9562105c
commit 9aa6db5b58
+210 -193
View File
@@ -138,6 +138,8 @@ const SQUADRON_CACHE_TTL = 30 * 60 * 1000;
let hasSquadronColumn = false; let hasSquadronColumn = false;
let nickLookupCache = null; let nickLookupCache = null;
let nickLookupCacheTime = 0; let nickLookupCacheTime = 0;
let gameHistoryLookupCache = null;
let gameHistoryLookupCacheTime = 0;
const performanceBenchmarkCache = new Map(); const performanceBenchmarkCache = new Map();
const performanceBenchmarkInFlight = new Map(); const performanceBenchmarkInFlight = new Map();
const PERFORMANCE_BENCHMARK_CACHE_TTL = 60 * 60 * 1000; const PERFORMANCE_BENCHMARK_CACHE_TTL = 60 * 60 * 1000;
@@ -535,7 +537,7 @@ function loadNickLookupCached(callback) {
} }
squadronsDb.all(` 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 SELECT sm.uid, sm.nick, sm.clan_id AS sm_clan_id, sm.updated_at, sd.clan_id AS sd_clan_id, sd.tag_name, sd.short_name
FROM squadron_members sm FROM squadron_members sm
LEFT JOIN squadrons_data sd ON sm.clan_id = sd.clan_id LEFT JOIN squadrons_data sd ON sm.clan_id = sd.clan_id
`, [], (err, rows) => { `, [], (err, rows) => {
@@ -545,13 +547,22 @@ function loadNickLookupCached(callback) {
log.error('Error loading nick lookup data', err); log.error('Error loading nick lookup data', err);
nickLookupCache = {}; nickLookupCache = {};
} else { } else {
// A uid can have rows under multiple clan_ids (membership history
// isn't always cleaned up promptly), so keep only the freshest
// row per uid rather than letting scan order decide the winner.
const lookup = {}; const lookup = {};
rows.forEach(row => { rows.forEach(row => {
const existing = lookup[row.uid];
if (existing && existing.updated_at != null && row.updated_at != null
&& existing.updated_at >= row.updated_at) {
return;
}
lookup[row.uid] = { lookup[row.uid] = {
nick: row.nick, nick: row.nick,
clan_id: row.sd_clan_id || row.sm_clan_id || null, clan_id: row.sd_clan_id || row.sm_clan_id || null,
tag_name: row.tag_name, tag_name: row.tag_name,
short_name: row.short_name short_name: row.short_name,
updated_at: row.updated_at != null ? Number(row.updated_at) : null
}; };
}); });
nickLookupCache = lookup; nickLookupCache = lookup;
@@ -564,6 +575,44 @@ function loadNickLookupCached(callback) {
}); });
} }
// Bulk "latest tracked game per uid" lookup, shared across leaderboard endpoints
// so each request doesn't need its own per-uid fallback query. SQLite guarantees
// nick/squadron_name/clan_id come from the same row as MAX(endtime_unix) here
// since there's exactly one aggregate in the query (documented bare-column
// behavior, not a portable SQL assumption).
function loadGameHistoryLookupCached(callback) {
if (gameHistoryLookupCache && Date.now() - gameHistoryLookupCacheTime < SQUADRON_CACHE_TTL) {
return callback(gameHistoryLookupCache);
}
heavyDb.all(`
SELECT UID as uid, nick, squadron_name, clan_id, MAX(endtime_unix) as endtime_unix
FROM player_games_hist
WHERE nick NOT LIKE 'coop/%'
GROUP BY UID
`, [], (err, rows) => {
if (err) {
log.error('Error loading game history lookup data', err);
gameHistoryLookupCache = gameHistoryLookupCache || {};
} else {
const lookup = {};
rows.forEach(row => {
lookup[row.uid] = {
nick: row.nick,
squadron_name: row.squadron_name,
clan_id: row.clan_id != null ? Number(row.clan_id) : null,
endtime_unix: row.endtime_unix != null ? Number(row.endtime_unix) : null
};
});
gameHistoryLookupCache = lookup;
log.info('Game history lookup cache updated', { entries: rows.length });
}
gameHistoryLookupCacheTime = Date.now();
callback(gameHistoryLookupCache);
});
}
function dbAll(query, params = []) { function dbAll(query, params = []) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || [])); db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || []));
@@ -1172,22 +1221,30 @@ function buildCompNotation(typeCounts) {
} }
// Canonicalize a player's squadron identity using the squadrons_data lookup. // Canonicalize a player's squadron identity using the squadrons_data lookup.
// Prefers clan_id from the squadron_members cache (joined to squadrons_data); otherwise // Trusts whichever of the two signals is more recent: the roster-sync snapshot
// falls back to a string lookup against long_name / short_name / tag_name. // (cached, from squadron_members) or the last tracked game (fallback, from
// player_games_hist) — see resolveCurrentSquadronIdentity for why neither one
// is reliable on its own (a roster row can outlive a squadron's sync going
// stale; a last game can predate a squadron change by weeks).
// Always returns the canonical tag_name + short_name for the matched clan_id when possible, // 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. // so consumers can group/dedupe by clan_id (or short_name) regardless of which raw value was stored.
function resolveSquadronIdentity(cached, fallback, squadronLookup) { function resolveSquadronIdentity(cached, fallback, squadronLookup) {
const rosterTime = cached?.updated_at != null ? Number(cached.updated_at) : null;
const gameTime = fallback?.endtime_unix != null ? Number(fallback.endtime_unix) : null;
const useRoster = cached?.clan_id != null
&& (gameTime == null || (rosterTime != null && rosterTime >= gameTime));
let clanId = null; let clanId = null;
let tagName = null; let tagName = null;
let shortName = null; let shortName = null;
if (cached && cached.clan_id) { if (useRoster) {
clanId = cached.clan_id; clanId = cached.clan_id;
tagName = cached.tag_name || null; tagName = cached.tag_name || null;
shortName = cached.short_name || null; shortName = cached.short_name || null;
} }
const rawTag = tagName || (cached ? cached.tag_name : null) || (fallback ? fallback.squadron_name : null); const rawTag = tagName || (useRoster ? cached.tag_name : null) || (fallback ? fallback.squadron_name : null);
if ((!clanId || !tagName || !shortName) && rawTag && squadronLookup) { if ((!clanId || !tagName || !shortName) && rawTag && squadronLookup) {
const sq = squadronLookup[rawTag]; const sq = squadronLookup[rawTag];
if (sq) { if (sq) {
@@ -1269,9 +1326,20 @@ function cleanText(value) {
return text || null; return text || null;
} }
function resolveCurrentSquadronIdentity(latestRow, squadronLookup, preferredClanId = null) { function resolveCurrentSquadronIdentity(latestRow, squadronLookup, rosterHint = null) {
const effectiveClanId = preferredClanId != null // Two independent, occasionally-disagreeing signals for "current squadron":
? Number(preferredClanId) // the roster-sync snapshot (squadron_members, via rosterHint) and the tag on
// the player's last tracked game (player_games_hist, via latestRow). Neither
// is reliably up to date on its own — a roster row can be an orphan left over
// from a squadron whose sync has lapsed, and the last game can predate a
// squadron change by weeks. Trust whichever one is actually more recent.
const rosterTime = rosterHint?.updated_at != null ? Number(rosterHint.updated_at) : null;
const gameTime = latestRow?.endtime_unix != null ? Number(latestRow.endtime_unix) : null;
const useRoster = rosterHint?.clan_id != null
&& (gameTime == null || (rosterTime != null && rosterTime >= gameTime));
const effectiveClanId = useRoster
? Number(rosterHint.clan_id)
: normalizeClanId(latestRow?.clan_id); : normalizeClanId(latestRow?.clan_id);
const lookupByClanId = effectiveClanId != null const lookupByClanId = effectiveClanId != null
? squadronLookup[`__cid_${effectiveClanId}`] || squadronLookup[String(effectiveClanId)] ? squadronLookup[`__cid_${effectiveClanId}`] || squadronLookup[String(effectiveClanId)]
@@ -1300,7 +1368,7 @@ function resolveCurrentSquadronIdentity(latestRow, squadronLookup, preferredClan
async function loadPlayerIdentitySnapshot(uid, squadronLookup, options = {}) { async function loadPlayerIdentitySnapshot(uid, squadronLookup, options = {}) {
const latestRow = options.latestRow || await dbGetAsync( const latestRow = options.latestRow || await dbGetAsync(
db, db,
`SELECT nick, squadron_name, clan_id `SELECT nick, squadron_name, clan_id, endtime_unix
FROM player_games_hist FROM player_games_hist
WHERE UID = ? AND nick NOT LIKE 'coop/%' WHERE UID = ? AND nick NOT LIKE 'coop/%'
ORDER BY session_id DESC ORDER BY session_id DESC
@@ -1309,7 +1377,7 @@ async function loadPlayerIdentitySnapshot(uid, squadronLookup, options = {}) {
); );
if (!latestRow) return null; if (!latestRow) return null;
const currentIdentity = resolveCurrentSquadronIdentity(latestRow, squadronLookup, options.preferredClanId ?? null); const currentIdentity = resolveCurrentSquadronIdentity(latestRow, squadronLookup, options.rosterHint ?? null);
const [nickRows, squadronRows] = await Promise.all([ const [nickRows, squadronRows] = await Promise.all([
dbAllAsync( dbAllAsync(
db, db,
@@ -2016,7 +2084,7 @@ app.get('/api/player/:uid', (req, res) => {
const { start_date, end_date, season, week } = req.query; const { start_date, end_date, season, week } = req.query;
const latestNickQuery = ` const latestNickQuery = `
SELECT nick, squadron_name, clan_id SELECT nick, squadron_name, clan_id, endtime_unix
FROM player_games_hist FROM player_games_hist
WHERE UID = ? AND nick NOT LIKE 'coop/%' WHERE UID = ? AND nick NOT LIKE 'coop/%'
ORDER BY session_id DESC ORDER BY session_id DESC
@@ -2060,37 +2128,23 @@ app.get('/api/player/:uid', (req, res) => {
const lookupPromise = new Promise((resolve) => { const lookupPromise = new Promise((resolve) => {
loadSquadronLookupCached(resolve); loadSquadronLookupCached(resolve);
}); });
// Authoritative current-roster membership. squadron_members is updated // Roster-sync snapshot (squadron_members). resolveCurrentSquadronIdentity
// by the periodic squadron-sync, so this reflects "what squadron this // weighs this against the last tracked game's timestamp rather than trusting
// player belongs to right now" — beats the most-recent player_games_hist // it blindly — an orphaned row from a squadron whose sync has lapsed should
// row which can be stale post-rename if the player hasn't played a SQB // lose to a more recent game record.
// since the rename.
const rosterPromise = new Promise((resolve) => { const rosterPromise = new Promise((resolve) => {
const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); loadNickLookupCached((nickCache) => resolve(nickCache[uid] || null));
if (!fs.existsSync(squadronsDbPath)) return resolve(null);
const sdb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => {
if (err) return resolve(null);
sdb.get(
'SELECT clan_id FROM squadron_members WHERE uid = ? ORDER BY updated_at DESC LIMIT 1',
[uid],
(qerr, row) => {
sdb.close();
if (qerr || !row || row.clan_id == null) return resolve(null);
resolve(Number(row.clan_id));
}
);
});
}); });
Promise.all([nickPromise, statsPromise, lookupPromise, rosterPromise]) Promise.all([nickPromise, statsPromise, lookupPromise, rosterPromise])
.then(async ([nickRow, vehicleRows, squadronLookup, rosterClanId]) => { .then(async ([nickRow, vehicleRows, squadronLookup, rosterHint]) => {
if (!nickRow) { if (!nickRow) {
return jsonError(res, 404, 'Player not found', { uid: normalizeUid(uid) }); return jsonError(res, 404, 'Player not found', { uid: normalizeUid(uid) });
} }
const identity = await loadPlayerIdentitySnapshot(uid, squadronLookup, { const identity = await loadPlayerIdentitySnapshot(uid, squadronLookup, {
latestRow: nickRow, latestRow: nickRow,
preferredClanId: rosterClanId, rosterHint,
}); });
loadPerformanceBenchmarksCached(dateFilters, (benchmarks) => { loadPerformanceBenchmarksCached(dateFilters, (benchmarks) => {
const vehicles = vehicleRows.map(row => { const vehicles = vehicleRows.map(row => {
@@ -2330,7 +2384,7 @@ app.get('/api/search/:nickname', (req, res) => {
`; `;
const latestNickQuery = ` const latestNickQuery = `
SELECT nick, squadron_name, clan_id SELECT nick, squadron_name, clan_id, endtime_unix
FROM player_games_hist FROM player_games_hist
WHERE UID = ? AND nick NOT LIKE 'coop/%' WHERE UID = ? AND nick NOT LIKE 'coop/%'
ORDER BY session_id DESC ORDER BY session_id DESC
@@ -2352,47 +2406,49 @@ app.get('/api/search/:nickname', (req, res) => {
}); });
} }
loadSquadronLookupCached((squadronLookup) => { loadNickLookupCached((nickCache) => {
const lookups = rows.map(row => (async () => { loadSquadronLookupCached((squadronLookup) => {
const latestRow = await dbGetAsync(db, latestNickQuery, [row.UID]); const lookups = rows.map(row => (async () => {
if (!latestRow) { const latestRow = await dbGetAsync(db, latestNickQuery, [row.UID]);
return { if (!latestRow) {
uid: normalizeUid(row.UID), return {
nick: '', uid: normalizeUid(row.UID),
previous_nicks: [], nick: '',
squadron_name: '', previous_nicks: [],
squadron_long_name: '', squadron_name: '',
squadron_clan_id: null, squadron_long_name: '',
previous_squadron_names: [], squadron_clan_id: null,
previous_squadron_names: [],
};
}
return loadPlayerIdentitySnapshot(row.UID, squadronLookup, { latestRow, rosterHint: nickCache[row.UID] || null });
})());
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
}; };
}
return loadPlayerIdentitySnapshot(row.UID, squadronLookup, { latestRow });
})());
Promise.all(lookups).then(results => { log.info('Search query executed', {
const finalResults = results.filter(Boolean); searchTerm: searchTerm,
const response = { nickname: nickname,
search_term: nickname.trim(), resultsFound: finalResults.length,
results: finalResults, firstFewResults: finalResults.slice(0, 3)
total_found: finalResults.length, });
limited_to: 50
};
log.info('Search query executed', { setCachedResponse(cacheKey, response);
searchTerm: searchTerm, res.json(response);
nickname: nickname, }).catch(identityErr => {
resultsFound: finalResults.length, log.error('Failed to build player identity search response', identityErr, {
firstFewResults: finalResults.slice(0, 3) nickname,
endpoint: '/api/search/:nickname'
});
jsonError(res, 500, 'Database error occurred', { errorCode: 'DB_SEARCH_QUERY_FAILED' });
}); });
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' });
}); });
}); });
}); });
@@ -3609,78 +3665,57 @@ app.get('/api/leaderboard/players', (req, res) => {
log.info('Player leaderboard aggregation done', { rows: statsRows.length, ms: Date.now() - queryStart }); 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) // Step 2: nick/squadron lookup — both sources are shared, TTL-cached
// maps (no heavy SQL per request); resolveSquadronIdentity picks
// whichever of the two is more recent per player.
loadNickLookupCached((nickCache) => { loadNickLookupCached((nickCache) => {
loadSquadronLookupCached((squadronLookup) => { loadSquadronLookupCached((squadronLookup) => {
const uncoveredUids = statsRows.filter(r => !nickCache[r.uid]).map(r => r.uid); loadGameHistoryLookupCached((gameHistoryCache) => {
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 buildResponse = (fallbackMap) => { const cached = nickCache[row.uid];
const response = { const fb = gameHistoryCache[row.uid];
date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), const nick = cached ? cached.nick : (fb ? fb.nick : row.uid);
timeframe: dateFilters.hasFilter ? "filtered" : "all-time", const sqId = resolveSquadronIdentity(cached, fb, squadronLookup);
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]; return {
const fb = fallbackMap[row.uid]; uid: row.uid,
const nick = cached ? cached.nick : (fb ? fb.nick : row.uid); nick,
const sqId = resolveSquadronIdentity(cached, fb, squadronLookup); squadron_name: sqId.tag_name,
squadron_short_name: sqId.short_name,
return { squadron_clan_id: sqId.clan_id,
uid: row.uid, total_kills: totalKills,
nick, ground_kills: row.total_ground_kills || 0,
squadron_name: sqId.tag_name, air_kills: row.total_air_kills || 0,
squadron_short_name: sqId.short_name, total_battles: totalBattles,
squadron_clan_id: sqId.clan_id, wins,
total_kills: totalKills, win_rate: parseFloat(winRate.toFixed(1)),
ground_kills: row.total_ground_kills || 0, kdr: parseFloat(kdr.toFixed(1)),
air_kills: row.total_air_kills || 0, deaths,
total_battles: totalBattles, assists: row.total_assists || 0,
wins, captures: row.total_captures || 0,
win_rate: parseFloat(winRate.toFixed(1)), total_score: Math.round(row.total_score || 0)
kdr: parseFloat(kdr.toFixed(1)), };
deaths, })
assists: row.total_assists || 0,
captures: row.total_captures || 0,
total_score: Math.round(row.total_score || 0)
};
})
};
log.info('Player leaderboard complete', {
playersReturned: statsRows.length,
cachedNicks: statsRows.length - uncoveredUids.length,
fallbackNicks: Object.keys(fallbackMap).length,
uncoveredNicks: uncoveredUids.length - Object.keys(fallbackMap).length,
totalMs: Date.now() - queryStart
});
resolve(response);
}; };
// Fallback for players not in any squadron — uses (UID, endtime_unix) index log.info('Player leaderboard complete', {
if (uncoveredUids.length > 0) { playersReturned: statsRows.length,
const fbPlaceholders = uncoveredUids.map(() => '?').join(','); totalMs: Date.now() - queryStart
db.all(` });
SELECT UID as uid, nick, squadron_name, MAX(endtime_unix)
FROM player_games_hist resolve(response);
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({});
}
}); });
}); });
}); });
@@ -3760,68 +3795,50 @@ app.get('/api/leaderboard/vehicles', (req, res) => {
return reject(err); return reject(err);
} }
// Nick/squadron lookup from squadron_members cache (instant) // Nick/squadron lookup — shared, TTL-cached maps; resolveSquadronIdentity
// picks whichever of roster/game-history is more recent per player.
loadNickLookupCached((nickCache) => { loadNickLookupCached((nickCache) => {
loadSquadronLookupCached((squadronLookup) => { loadSquadronLookupCached((squadronLookup) => {
const uniqueUids = [...new Set(vehicleRows.map(r => r.player_uid))]; loadGameHistoryLookupCached((gameHistoryCache) => {
const uncoveredUids = uniqueUids.filter(uid => !nickCache[uid]); 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 buildResponse = (fallbackMap) => { const cached = nickCache[row.player_uid];
const response = { const fb = gameHistoryCache[row.player_uid];
date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), const playerNick = cached ? cached.nick : (fb ? fb.nick : row.player_uid);
vehicles: vehicleRows.map(row => { const sqId = resolveSquadronIdentity(cached, fb, squadronLookup);
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]; return {
const fb = fallbackMap[row.player_uid]; vehicle: row.vehicle,
const playerNick = cached ? cached.nick : (fb ? fb.nick : row.player_uid); vehicle_internal: row.vehicle_internal || null,
const sqId = resolveSquadronIdentity(cached, fb, squadronLookup); player_uid: normalizeUid(row.player_uid),
player_nick: playerNick,
return { player_squadron_name: sqId.tag_name,
vehicle: row.vehicle, player_squadron_short_name: sqId.short_name,
vehicle_internal: row.vehicle_internal || null, player_squadron_clan_id: sqId.clan_id,
player_uid: normalizeUid(row.player_uid), total_kills: totalKills,
player_nick: playerNick, ground_kills: row.total_ground_kills || 0,
player_squadron_name: sqId.tag_name, air_kills: row.total_air_kills || 0,
player_squadron_short_name: sqId.short_name, battles, wins,
player_squadron_clan_id: sqId.clan_id, win_rate: parseFloat(winRate.toFixed(1)),
total_kills: totalKills, kdr: parseFloat(kdr.toFixed(1)),
ground_kills: row.total_ground_kills || 0, deaths,
air_kills: row.total_air_kills || 0, assists: row.total_assists || 0,
battles, wins, captures: row.total_captures || 0
win_rate: parseFloat(winRate.toFixed(1)), };
kdr: parseFloat(kdr.toFixed(1)), })
deaths,
assists: row.total_assists || 0,
captures: row.total_captures || 0
};
})
};
log.info('Vehicle leaderboard complete', { vehiclesReturned: vehicleRows.length, totalMs: Date.now() - queryStart });
resolve(response);
}; };
if (uncoveredUids.length > 0) { log.info('Vehicle leaderboard complete', { vehiclesReturned: vehicleRows.length, totalMs: Date.now() - queryStart });
const fbPlaceholders = uncoveredUids.map(() => '?').join(','); resolve(response);
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({});
}
}); });
}); });
}); });