Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -20,7 +20,9 @@ interface Participation {
|
||||
}
|
||||
|
||||
function mapIdentity(handle: Database.Database, report: GameTelemetry) {
|
||||
const server = handle.prepare('SELECT game, map, map_slug FROM servers WHERE name = ?').get(report.server) as
|
||||
const server = handle
|
||||
.prepare('SELECT game, map, map_slug FROM servers WHERE name = ?')
|
||||
.get(report.server) as
|
||||
{ game: string | null; map: string | null; map_slug: string | null } | undefined;
|
||||
const parsed = parseServerName(report.server);
|
||||
const game = (server?.game ?? parsed?.game ?? 'unknown').toLowerCase();
|
||||
@@ -28,15 +30,22 @@ function mapIdentity(handle: Database.Database, report: GameTelemetry) {
|
||||
// A shared engine map (notably zm_transit) is not enough to identify T6's
|
||||
// location/ruleset. Use the registered selection only when it matches.
|
||||
const parsedSlug = parsed?.mapCanonical === map ? parsed.mapSlug : undefined;
|
||||
const slug = server?.map === map ? server.map_slug ?? parsedSlug : parsedSlug;
|
||||
const candidates = zombieMaps(game).filter(entry => entry.canonical === map);
|
||||
const selected = game === 't6' && candidates.length > 1 && !slug
|
||||
? undefined : selectedZombieMap(game, map, slug);
|
||||
const variant = game === 't6' ? selected?.slug ?? slug ?? 'unspecified' : '';
|
||||
const slug = server?.map === map ? (server.map_slug ?? parsedSlug) : parsedSlug;
|
||||
const candidates = zombieMaps(game).filter((entry) => entry.canonical === map);
|
||||
const selected =
|
||||
game === 't6' && candidates.length > 1 && !slug
|
||||
? undefined
|
||||
: selectedZombieMap(game, map, slug);
|
||||
const variant = game === 't6' ? (selected?.slug ?? slug ?? 'unspecified') : '';
|
||||
return {
|
||||
game, map,
|
||||
game,
|
||||
map,
|
||||
key: [game, map, ...(variant ? [variant] : [])].join(':'),
|
||||
name: selected?.friendly ?? (game === 't6' && candidates.length > 1 ? `${map} (${slug ?? 'unspecified variant'})` : map)
|
||||
name:
|
||||
selected?.friendly ??
|
||||
(game === 't6' && candidates.length > 1
|
||||
? `${map} (${slug ?? 'unspecified variant'})`
|
||||
: map)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,48 +53,92 @@ function fingerprint(report: GameTelemetry): string {
|
||||
// Receipt timestamps and roster ordering do not identify new gameplay.
|
||||
const { receivedAt: _receivedAt, timing: _timing, ...event } = report;
|
||||
const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid));
|
||||
return createHash('sha256').update(JSON.stringify({ ...event, players })).digest('hex');
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify({ ...event, players }))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/** Called inside the same transaction as the latest scoreboard snapshot. */
|
||||
export function recordLeaderboardTelemetry(handle: Database.Database, report: GameTelemetry): void {
|
||||
const identity = mapIdentity(handle, report);
|
||||
const signature = fingerprint(report);
|
||||
let match = handle.prepare('SELECT * FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1')
|
||||
let match = handle
|
||||
.prepare(
|
||||
'SELECT * FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.get(report.server) as Match | undefined;
|
||||
if (match?.last_fingerprint === signature && match.map_key === identity.key) return;
|
||||
|
||||
const isStart = report.event === 'game_start';
|
||||
const round = isStart ? 0 : report.round;
|
||||
const newMatch = !match || match.map_key !== identity.key
|
||||
|| (isStart && (match.status !== 'active' || match.last_event !== 'game_start'))
|
||||
|| (!isStart && round < match.highest_round)
|
||||
|| ((report.event === 'round_change' || report.event === 'heartbeat') && match.status !== 'active');
|
||||
const newMatch =
|
||||
!match ||
|
||||
match.map_key !== identity.key ||
|
||||
(isStart && (match.status !== 'active' || match.last_event !== 'game_start')) ||
|
||||
(!isStart && round < match.highest_round) ||
|
||||
((report.event === 'round_change' || report.event === 'heartbeat') &&
|
||||
match.status !== 'active');
|
||||
|
||||
if (newMatch) {
|
||||
if (match?.status === 'active') {
|
||||
handle.prepare("UPDATE leaderboard_matches SET status = 'interrupted', ended_at = ? WHERE id = ?")
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE leaderboard_matches SET status = 'interrupted', ended_at = ? WHERE id = ?"
|
||||
)
|
||||
.run(report.receivedAt, match.id);
|
||||
}
|
||||
const inserted = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
const inserted = handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches
|
||||
(server, game, map, map_key, map_name, started_at, last_received_at, start_observed, last_event, last_fingerprint)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(report.server, identity.game, identity.map, identity.key, identity.name,
|
||||
report.receivedAt, report.receivedAt, Number(isStart), report.event, signature);
|
||||
match = { id: Number(inserted.lastInsertRowid), game: identity.game, map_key: identity.key,
|
||||
status: 'active', highest_round: 0, last_event: report.event, last_fingerprint: signature };
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
report.server,
|
||||
identity.game,
|
||||
identity.map,
|
||||
identity.key,
|
||||
identity.name,
|
||||
report.receivedAt,
|
||||
report.receivedAt,
|
||||
Number(isStart),
|
||||
report.event,
|
||||
signature
|
||||
);
|
||||
match = {
|
||||
id: Number(inserted.lastInsertRowid),
|
||||
game: identity.game,
|
||||
map_key: identity.key,
|
||||
status: 'active',
|
||||
highest_round: 0,
|
||||
last_event: report.event,
|
||||
last_fingerprint: signature
|
||||
};
|
||||
}
|
||||
if (!match) return;
|
||||
|
||||
if (isStart) {
|
||||
handle.prepare('UPDATE leaderboard_matches SET start_players = COALESCE(start_players, ?) WHERE id = ?')
|
||||
handle
|
||||
.prepare(
|
||||
'UPDATE leaderboard_matches SET start_players = COALESCE(start_players, ?) WHERE id = ?'
|
||||
)
|
||||
.run(report.players.length, match.id);
|
||||
}
|
||||
|
||||
const previous = new Map((handle.prepare('SELECT guid, last_round, present FROM leaderboard_match_players WHERE match_id = ?')
|
||||
.all(match.id) as Participation[]).map(player => [player.guid, player]));
|
||||
handle.prepare('UPDATE leaderboard_match_players SET present = 0 WHERE match_id = ?').run(match.id);
|
||||
const saveIdentity = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
const previous = new Map(
|
||||
(
|
||||
handle
|
||||
.prepare(
|
||||
'SELECT guid, last_round, present FROM leaderboard_match_players WHERE match_id = ?'
|
||||
)
|
||||
.all(match.id) as Participation[]
|
||||
).map((player) => [player.guid, player])
|
||||
);
|
||||
handle
|
||||
.prepare('UPDATE leaderboard_match_players SET present = 0 WHERE match_id = ?')
|
||||
.run(match.id);
|
||||
const saveIdentity =
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO UPDATE SET
|
||||
name = CASE WHEN excluded.last_seen_at >= last_seen_at THEN excluded.name ELSE name END,
|
||||
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
||||
@@ -108,33 +161,76 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
||||
const completedRounds = Math.max(0, round - 1);
|
||||
for (const player of report.players) {
|
||||
const prior = previous.get(player.guid);
|
||||
const survived = !isStart && prior?.present ? Math.max(0, completedRounds - prior.last_round) : 0;
|
||||
saveIdentity.run(identity.game, player.guid, player.name, report.receivedAt, report.receivedAt);
|
||||
const survived =
|
||||
!isStart && prior?.present ? Math.max(0, completedRounds - prior.last_round) : 0;
|
||||
saveIdentity.run(
|
||||
identity.game,
|
||||
player.guid,
|
||||
player.name,
|
||||
report.receivedAt,
|
||||
report.receivedAt
|
||||
);
|
||||
const stats = 'kills' in player ? player : null;
|
||||
savePlayer.run(match.id, player.guid, round, survived,
|
||||
stats?.kills ?? 0, stats?.headshots ?? 0, stats?.downs ?? 0, stats?.revives ?? 0, stats?.score ?? 0,
|
||||
completedRounds, Number(!isStart), Number(report.event === 'game_end'), report.receivedAt, report.receivedAt, round, Number(isStart));
|
||||
savePlayer.run(
|
||||
match.id,
|
||||
player.guid,
|
||||
round,
|
||||
survived,
|
||||
stats?.kills ?? 0,
|
||||
stats?.headshots ?? 0,
|
||||
stats?.downs ?? 0,
|
||||
stats?.revives ?? 0,
|
||||
stats?.score ?? 0,
|
||||
completedRounds,
|
||||
Number(!isStart),
|
||||
Number(report.event === 'game_end'),
|
||||
report.receivedAt,
|
||||
report.receivedAt,
|
||||
round,
|
||||
Number(isStart)
|
||||
);
|
||||
}
|
||||
handle.prepare(`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?),
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?),
|
||||
last_received_at = ?, last_event = ?, last_fingerprint = ?,
|
||||
status = CASE WHEN ? = 'game_end' THEN 'completed' ELSE status END,
|
||||
ended_at = CASE WHEN ? = 'game_end' THEN ? ELSE ended_at END WHERE id = ?`)
|
||||
.run(round, report.receivedAt, report.event, signature, report.event, report.event, report.receivedAt, match.id);
|
||||
ended_at = CASE WHEN ? = 'game_end' THEN ? ELSE ended_at END WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
round,
|
||||
report.receivedAt,
|
||||
report.event,
|
||||
signature,
|
||||
report.event,
|
||||
report.event,
|
||||
report.receivedAt,
|
||||
match.id
|
||||
);
|
||||
}
|
||||
|
||||
/** Seed the single surviving scoreboard per server once; older games are gone. */
|
||||
export function backfillLeaderboard(handle: Database.Database): void {
|
||||
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'leaderboard_v1'").get()) return;
|
||||
handle.transaction(() => {
|
||||
const reports = handle.prepare(`SELECT payload FROM server_telemetry WHERE payload IS NOT NULL ORDER BY received_at, server`)
|
||||
const reports = handle
|
||||
.prepare(
|
||||
`SELECT payload FROM server_telemetry WHERE payload IS NOT NULL ORDER BY received_at, server`
|
||||
)
|
||||
.all() as Array<{ payload: string }>;
|
||||
for (const row of reports) {
|
||||
let report: GameTelemetry;
|
||||
try {
|
||||
const stored = JSON.parse(row.payload);
|
||||
if (typeof stored.receivedAt !== 'string' || !Number.isFinite(Date.parse(stored.receivedAt))) continue;
|
||||
if (
|
||||
typeof stored.receivedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(stored.receivedAt))
|
||||
)
|
||||
continue;
|
||||
report = { ...parseGameEvent(stored), receivedAt: stored.receivedAt };
|
||||
} catch { continue; } // Legacy malformed snapshots cannot establish player records.
|
||||
} catch {
|
||||
continue;
|
||||
} // Legacy malformed snapshots cannot establish player records.
|
||||
recordLeaderboardTelemetry(handle, report);
|
||||
}
|
||||
handle.prepare("INSERT INTO settings (key, value) VALUES ('leaderboard_v1', '1')").run();
|
||||
|
||||
Reference in New Issue
Block a user