Files
electric-cherry/src/lib/server/fleet/leaderboard-ingest.ts
T
2026-10-12 13:57:34 -04:00

442 lines
18 KiB
TypeScript

import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { parseServerName, selectedZombieMap, zombieMaps } from '../../map-aliases.js';
import { parseGameEvent, type GameTelemetry } from '../../telemetry.js';
interface Match {
id: number;
map_key: string;
game: string;
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
highest_round: number;
last_event: GameTelemetry['event'];
last_fingerprint: string;
start_observed: number;
started_at: string;
server_deleted_at: string | null;
}
interface KeyedMatch extends Match {
last_seq: number;
}
interface Participation {
guid: string;
last_round: number;
present: number;
}
function mapIdentity(handle: Database.Database, report: GameTelemetry) {
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();
const map = report.map.toLowerCase();
// 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') : '';
return {
game,
map,
key: [game, map, ...(variant ? [variant] : [])].join(':'),
name:
selected?.friendly ??
(game === 't6' && candidates.length > 1
? `${map} (${slug ?? 'unspecified variant'})`
: map)
};
}
function fingerprint(report: GameTelemetry): string {
// Receipt timestamps and roster ordering do not identify new gameplay.
const { receivedAt: _receivedAt, timing: _timing, match: _match, seq: _seq, ...event } = report;
const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid));
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
): { matchId: number; current: boolean } {
if (report.match !== undefined) return recordKeyedTelemetry(handle, report);
const matchId = recordLegacyTelemetry(handle, report);
saveScoreTotals(handle, report, matchId);
return { matchId, current: true };
}
// Separate from applyReport so the historical version-0 backfill still works
// before the score_total migration. Missing totals never erase known values.
function saveScoreTotals(handle: Database.Database, report: GameTelemetry, matchId: number): void {
if (report.event === 'game_start') return;
const players = report.players.filter((player) => player.score_total !== undefined);
if (!players.length) return;
const save = handle.prepare(`UPDATE leaderboard_match_players
SET score_total = MAX(COALESCE(score_total, 0), ?)
WHERE match_id = ? AND guid = ?`);
for (const player of players) save.run(player.score_total, matchId, player.guid);
}
// Also used by the version-0 snapshot backfill, before keyed schema exists.
function recordLegacyTelemetry(handle: Database.Database, report: GameTelemetry): number {
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'
)
.get(report.server) as Match | undefined;
if (match?.last_fingerprint === signature && match.map_key === identity.key) {
// Unchanged gameplay still proves an open match is reporting. Keep the
// duplicate from changing participation, counters, or completed results.
if (match.status === 'active')
handle
.prepare('UPDATE leaderboard_matches SET last_received_at = ? WHERE id = ?')
.run(report.receivedAt, match.id);
if (match.status !== 'interrupted') return match.id;
}
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');
if (newMatch) {
if (match?.status === 'active') {
handle
.prepare(
"UPDATE leaderboard_matches SET status = 'interrupted', ended_at = ? WHERE id = ?"
)
.run(report.receivedAt, match.id);
}
match = createMatch(handle, report, identity, signature);
}
// No prior match always creates one above.
if (!match) throw new Error('Could not create telemetry match');
applyReport(handle, report, match, signature);
return match.id;
}
function createMatch(
handle: Database.Database,
report: GameTelemetry,
identity: ReturnType<typeof mapIdentity>,
signature: string
): Match {
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(report.event === 'game_start'),
report.event,
signature
);
return handle
.prepare('SELECT * FROM leaderboard_matches WHERE id = ?')
.get(inserted.lastInsertRowid) as Match;
}
function applyReport(
handle: Database.Database,
report: GameTelemetry,
match: Match,
signature: string
): void {
const isStart = report.event === 'game_start';
const round = isStart ? 0 : report.round;
if (isStart) {
handle
.prepare(
'UPDATE leaderboard_matches SET start_observed = 1, 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)
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)`);
const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, rounds_survived, kills, headshots, downs, revives, best_score,
last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
ON CONFLICT(match_id, guid) DO UPDATE SET
highest_round = MAX(highest_round, excluded.highest_round),
rounds_survived = rounds_survived + excluded.rounds_survived,
kills = MAX(kills, excluded.kills), headshots = MAX(headshots, excluded.headshots),
downs = MAX(downs, excluded.downs), revives = MAX(revives, excluded.revives),
best_score = MAX(best_score, excluded.best_score), last_round = MAX(last_round, excluded.last_round),
present = 1, has_stats = MAX(has_stats, excluded.has_stats),
first_observed_round = CASE WHEN excluded.present_at_start = 1 THEN 0 ELSE first_observed_round END,
present_at_start = CASE WHEN excluded.present_at_start = 1 THEN 1 ELSE present_at_start END,
completed = MAX(completed, excluded.completed), last_seen_at = excluded.last_seen_at`);
// Highest round includes late joins; total rounds only credits progression
// while present at both ends of an observed interval. Entering round 8
// completes round 7, but a player's first sighting at round 8 starts at zero.
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(
match.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)
);
}
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
);
}
function recordKeyedTelemetry(
handle: Database.Database,
report: GameTelemetry
): { matchId: number; current: boolean } {
const signature = fingerprint(report);
let match = handle
.prepare('SELECT * FROM leaderboard_matches WHERE server = ? AND reporter_match_key = ?')
.get(report.server, report.match) as KeyedMatch | undefined;
if (!match) {
// A key is a durable identity. Only a previously unseen key supersedes
// the current game; a retry for a known old key cannot close a new one.
handle
.prepare(
`UPDATE leaderboard_matches SET
status = CASE WHEN reporter_match_key IS NULL THEN 'interrupted' ELSE 'end-report-missing' END,
ended_at = ? WHERE server = ? AND server_deleted_at IS NULL AND status = 'active'`
)
.run(report.receivedAt, report.server);
const created = createMatch(handle, report, mapIdentity(handle, report), signature);
handle
.prepare('UPDATE leaderboard_matches SET reporter_match_key = ? WHERE id = ?')
.run(report.match, created.id);
match = { ...created, last_seq: 0 };
}
const result = { matchId: match.id, current: false };
if (report.seq !== undefined) {
const inserted = handle
.prepare(
`INSERT INTO leaderboard_match_reports (match_id, seq, applied)
VALUES (?, ?, 0) ON CONFLICT DO NOTHING`
)
.run(match.id, report.seq);
if (!inserted.changes) return result;
// Count received sequences even when their state is too old to apply.
// This represents gaps without allocating a row for each missing value.
handle
.prepare(
`UPDATE leaderboard_matches SET last_seq = MAX(last_seq, ?),
report_count = report_count + 1 WHERE id = ?`
)
.run(report.seq, match.id);
}
const stale =
report.seq !== undefined
? report.seq < match.last_seq
: report.event !== 'game_start' && report.round < match.highest_round;
const delayedStart =
report.event === 'game_start' &&
(stale || match.highest_round > 0 || match.status !== 'active');
let applied = false;
if (delayedStart) {
if (!match.start_observed) {
observeDelayedStart(handle, report, match);
applied = true;
}
} else if (stale) {
// Completion is forward progress, even if its sequence trails a report
// already seen. Do not rewind the newer roster or counters with it.
if (report.event === 'game_end' && match.status !== 'completed') {
completeDelayedMatch(handle, report, match);
applied = true;
}
} else if (match.status !== 'completed') {
applyReport(handle, report, match, signature);
applied = true;
const latest = handle
.prepare(
`SELECT id FROM leaderboard_matches
WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1`
)
.get(report.server) as { id: number } | undefined;
result.current = latest?.id === match.id && match.server_deleted_at === null;
}
if (applied) saveScoreTotals(handle, report, match.id);
if (applied && report.seq !== undefined) {
handle
.prepare(
'UPDATE leaderboard_match_reports SET applied = 1 WHERE match_id = ? AND seq = ?'
)
.run(match.id, report.seq);
}
return result;
}
function completeDelayedMatch(
handle: Database.Database,
report: Extract<GameTelemetry, { round: number }>,
match: Match
): void {
handle
.prepare(
`UPDATE leaderboard_matches SET status = 'completed', ended_at = ?,
highest_round = MAX(highest_round, ?) WHERE id = ?`
)
.run(report.receivedAt, report.round, match.id);
const identity =
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO NOTHING`);
const endPlayer = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, kills, headshots, downs, revives, best_score,
last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1, 1, ?, ?, ?, 0)
ON CONFLICT(match_id, guid) DO UPDATE SET
highest_round = MAX(highest_round, excluded.highest_round),
kills = MAX(kills, excluded.kills), headshots = MAX(headshots, excluded.headshots),
downs = MAX(downs, excluded.downs), revives = MAX(revives, excluded.revives),
best_score = MAX(best_score, excluded.best_score), has_stats = 1, completed = 1`);
for (const player of report.players) {
identity.run(match.game, player.guid, player.name, report.receivedAt, report.receivedAt);
endPlayer.run(
match.id,
player.guid,
report.round,
player.kills,
player.headshots,
player.downs,
player.revives,
player.score,
Math.max(0, report.round - 1),
report.receivedAt,
report.receivedAt,
report.round
);
}
}
/** Recover start evidence without replacing the latest roster or its progress. */
function observeDelayedStart(handle: Database.Database, report: GameTelemetry, match: Match): void {
handle
.prepare(
`UPDATE leaderboard_matches SET start_observed = 1, start_players = ? WHERE id = ?`
)
.run(report.players.length, match.id);
const identity =
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO NOTHING`);
const playerStart = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, present, first_seen_at, last_seen_at, first_observed_round, present_at_start)
VALUES (?, ?, 0, ?, ?, 0, 1) ON CONFLICT(match_id, guid) DO UPDATE SET
present_at_start = 1, first_observed_round = 0`);
for (const player of report.players) {
identity.run(match.game, player.guid, player.name, match.started_at, match.started_at);
playerStart.run(match.id, player.guid, match.started_at, match.started_at);
}
}
/** 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`
)
.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;
report = { ...parseGameEvent(stored), receivedAt: stored.receivedAt };
} catch {
continue;
} // Legacy malformed snapshots cannot establish player records.
recordLegacyTelemetry(handle, report);
}
handle.prepare("INSERT INTO settings (key, value) VALUES ('leaderboard_v1', '1')").run();
})();
}