This commit is contained in:
@@ -24,7 +24,7 @@ export interface ChatMatch {
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
lastReceivedAt: string;
|
||||
status: 'active' | 'completed' | 'interrupted';
|
||||
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||
startObserved: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface MatchHistoryQuery {
|
||||
map: string;
|
||||
q: string;
|
||||
days: number;
|
||||
status: '' | 'active' | 'completed' | 'interrupted';
|
||||
status: '' | 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||
sort: MatchSort;
|
||||
direction: 'asc' | 'desc';
|
||||
page: number;
|
||||
@@ -35,7 +35,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
||||
map.length > 1024 ||
|
||||
q.length > 100 ||
|
||||
![0, 7, 30, 90].includes(days) ||
|
||||
!['', 'active', 'completed', 'interrupted'].includes(status) ||
|
||||
!['', 'active', 'completed', 'interrupted', 'end-report-missing'].includes(status) ||
|
||||
!matchSorts.includes(sort as MatchSort) ||
|
||||
!['asc', 'desc'].includes(direction) ||
|
||||
!Number.isSafeInteger(page) ||
|
||||
|
||||
@@ -13,7 +13,10 @@ export interface ServerMatch {
|
||||
startedAt: string;
|
||||
lastReportedAt: string | null;
|
||||
endedAt: string | null;
|
||||
status: 'active' | 'completed' | 'interrupted';
|
||||
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||
reporterMatchKey: string | null;
|
||||
// Unknown without sequence reports; otherwise gaps through the highest received sequence.
|
||||
missingReports: number | null;
|
||||
startPlayers: number | null;
|
||||
uniquePlayers: number;
|
||||
endPlayers: number | null;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/** Reporter identities and received sequences survive controller restarts with match history. */
|
||||
export function migrateLeaderboardReports(handle: Database.Database): void {
|
||||
const columns = new Set(
|
||||
(handle.prepare('PRAGMA table_info(leaderboard_matches)').all() as { name: string }[]).map(
|
||||
(column) => column.name
|
||||
)
|
||||
);
|
||||
for (const [name, definition] of [
|
||||
['reporter_match_key', 'TEXT'],
|
||||
['last_seq', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['report_count', 'INTEGER NOT NULL DEFAULT 0']
|
||||
]) {
|
||||
if (!columns.has(name))
|
||||
handle.exec(`ALTER TABLE leaderboard_matches ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
handle.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_reporter_match
|
||||
ON leaderboard_matches(server, reporter_match_key)
|
||||
WHERE reporter_match_key IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS leaderboard_match_reports (
|
||||
match_id INTEGER NOT NULL REFERENCES leaderboard_matches(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL CHECK (typeof(seq) = 'integer' AND seq > 0),
|
||||
applied INTEGER NOT NULL CHECK (applied IN (0,1)),
|
||||
PRIMARY KEY (match_id, seq)
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'
|
||||
import { migrateHostSchema } from '../hosts/schema.js';
|
||||
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
|
||||
import { migrateGameFileSchema } from '../game-files/schema.js';
|
||||
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||
import type { Migration } from './migration-runner.js';
|
||||
|
||||
// Append only: never renumber entries or change an applied migration to add an
|
||||
@@ -97,6 +98,11 @@ export const migrations: readonly Migration[] = [
|
||||
name: 'super viewer authentication role',
|
||||
mode: 'foreign-keys-off',
|
||||
run: migrateSuperViewerRole
|
||||
},
|
||||
{
|
||||
version: 18,
|
||||
name: 'durable leaderboard report identity and sequences',
|
||||
run: migrateLeaderboardReports
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -7,10 +7,17 @@ interface Match {
|
||||
id: number;
|
||||
map_key: string;
|
||||
game: string;
|
||||
status: 'active' | 'completed' | 'interrupted';
|
||||
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 {
|
||||
@@ -51,7 +58,7 @@ function mapIdentity(handle: Database.Database, report: GameTelemetry) {
|
||||
|
||||
function fingerprint(report: GameTelemetry): string {
|
||||
// Receipt timestamps and roster ordering do not identify new gameplay.
|
||||
const { receivedAt: _receivedAt, timing: _timing, ...event } = report;
|
||||
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 }))
|
||||
@@ -59,7 +66,16 @@ function fingerprint(report: GameTelemetry): string {
|
||||
}
|
||||
|
||||
/** Called inside the same transaction as the latest scoreboard snapshot. */
|
||||
export function recordLeaderboardTelemetry(handle: Database.Database, report: GameTelemetry): void {
|
||||
export function recordLeaderboardTelemetry(
|
||||
handle: Database.Database,
|
||||
report: GameTelemetry
|
||||
): { matchId: number; current: boolean } {
|
||||
if (report.match !== undefined) return recordKeyedTelemetry(handle, report);
|
||||
return { matchId: recordLegacyTelemetry(handle, report), current: true };
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -74,7 +90,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
||||
handle
|
||||
.prepare('UPDATE leaderboard_matches SET last_received_at = ? WHERE id = ?')
|
||||
.run(report.receivedAt, match.id);
|
||||
if (match.status !== 'interrupted') return;
|
||||
if (match.status !== 'interrupted') return match.id;
|
||||
}
|
||||
|
||||
const isStart = report.event === 'game_start';
|
||||
@@ -95,40 +111,55 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
||||
)
|
||||
.run(report.receivedAt, match.id);
|
||||
}
|
||||
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
|
||||
};
|
||||
match = createMatch(handle, report, identity, signature);
|
||||
}
|
||||
if (!match) return;
|
||||
// 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_players = COALESCE(start_players, ?) WHERE id = ?'
|
||||
'UPDATE leaderboard_matches SET start_observed = 1, start_players = COALESCE(start_players, ?) WHERE id = ?'
|
||||
)
|
||||
.run(report.players.length, match.id);
|
||||
}
|
||||
@@ -159,8 +190,10 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
||||
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 = excluded.last_round,
|
||||
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
|
||||
@@ -172,7 +205,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
||||
const survived =
|
||||
!isStart && prior?.present ? Math.max(0, completedRounds - prior.last_round) : 0;
|
||||
saveIdentity.run(
|
||||
identity.game,
|
||||
match.game,
|
||||
player.guid,
|
||||
player.name,
|
||||
report.receivedAt,
|
||||
@@ -217,6 +250,153 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
||||
);
|
||||
}
|
||||
|
||||
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 && 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;
|
||||
@@ -239,7 +419,7 @@ export function backfillLeaderboard(handle: Database.Database): void {
|
||||
} catch {
|
||||
continue;
|
||||
} // Legacy malformed snapshots cannot establish player records.
|
||||
recordLeaderboardTelemetry(handle, report);
|
||||
recordLegacyTelemetry(handle, report);
|
||||
}
|
||||
handle.prepare("INSERT INTO settings (key, value) VALUES ('leaderboard_v1', '1')").run();
|
||||
})();
|
||||
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
|
||||
|
||||
const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
||||
m.reporter_match_key AS reporterMatchKey,
|
||||
CASE WHEN m.last_seq > 0 THEN m.last_seq - m.report_count END AS missingReports,
|
||||
m.last_received_at AS lastReportedAt,
|
||||
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
||||
m.start_players AS startPlayers,
|
||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
||||
CASE WHEN m.status = 'completed' THEN
|
||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id AND p.present = 1)
|
||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id
|
||||
AND (CASE WHEN m.reporter_match_key IS NULL THEN p.present ELSE p.completed END) = 1)
|
||||
END AS endPlayers,
|
||||
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
|
||||
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
|
||||
@@ -112,7 +115,8 @@ export function getMatchDetail(id: number): MatchDetail | null {
|
||||
ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1),
|
||||
(SELECT identity.name FROM leaderboard_players identity
|
||||
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
||||
p.has_stats AS hasStats, p.present AS presentAtEnd,
|
||||
p.has_stats AS hasStats,
|
||||
CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
|
||||
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
|
||||
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
|
||||
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
|
||||
@@ -126,7 +130,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
|
||||
FROM leaderboard_match_players p WHERE p.match_id = ?
|
||||
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
|
||||
)
|
||||
.all(match.game, id) as Array<
|
||||
.all(match.game, match.reporterMatchKey, id) as Array<
|
||||
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
||||
hasStats: number;
|
||||
presentAtEnd: number;
|
||||
|
||||
@@ -15,6 +15,14 @@ export function listTelemetry(): GameTelemetry[] {
|
||||
}
|
||||
|
||||
export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
||||
return saveTelemetryWithOutcome(report).telemetry;
|
||||
}
|
||||
|
||||
/** Acknowledgements retain their match id even when the live snapshot must stay put. */
|
||||
export function saveTelemetryWithOutcome(report: GameTelemetry): {
|
||||
telemetry: GameTelemetry;
|
||||
current: boolean;
|
||||
} {
|
||||
// Keep callers outside the HTTP route atomic too.
|
||||
const handle = db();
|
||||
return handle.transaction(() => {
|
||||
@@ -24,12 +32,16 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
||||
const previous: GameTelemetry | undefined = row?.payload
|
||||
? JSON.parse(row.payload)
|
||||
: undefined;
|
||||
recordLeaderboardTelemetry(handle, report);
|
||||
const recorded = recordLeaderboardTelemetry(handle, report);
|
||||
if (!recorded.current) {
|
||||
return {
|
||||
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
|
||||
current: false
|
||||
};
|
||||
}
|
||||
const match = handle
|
||||
.prepare(
|
||||
'SELECT id, started_at, start_observed FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.get(report.server) as { id: number; started_at: string; start_observed: number };
|
||||
.prepare('SELECT id, started_at, start_observed FROM leaderboard_matches WHERE id = ?')
|
||||
.get(recorded.matchId) as { id: number; started_at: string; start_observed: number };
|
||||
const sameMatch = previous?.timing?.matchId === match.id;
|
||||
const serverTiming = handle
|
||||
.prepare(
|
||||
@@ -128,6 +140,6 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
||||
)
|
||||
.run(report.receivedAt, report.server, report.receivedAt);
|
||||
}
|
||||
return report;
|
||||
return { telemetry: report, current: true };
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ function policies(): Policy[] {
|
||||
id: 'id',
|
||||
clock: 'last_received_at',
|
||||
days: 'matchKeepDays',
|
||||
eligible: "o.status IN ('completed','interrupted')",
|
||||
eligible: "o.status IN ('completed','interrupted','end-report-missing')",
|
||||
bytes: '0'
|
||||
},
|
||||
{
|
||||
|
||||
+21
-2
@@ -13,7 +13,14 @@ export interface TelemetryPlayerStats extends TelemetryPlayer {
|
||||
score: number;
|
||||
}
|
||||
|
||||
export type GameEvent = { server: string; map: string } & (
|
||||
export type GameEvent = {
|
||||
server: string;
|
||||
map: string;
|
||||
/** Opaque identifier assigned by the reporter for one match on this server. */
|
||||
match?: string;
|
||||
/** Monotonically increasing report number within the reporter's match. */
|
||||
seq?: number;
|
||||
} & (
|
||||
| { event: 'game_start'; players: TelemetryPlayer[] }
|
||||
| {
|
||||
event: 'round_change' | 'heartbeat' | 'game_end';
|
||||
@@ -52,6 +59,13 @@ export function parseGameEvent(value: unknown): GameEvent {
|
||||
if (typeof body[key] !== 'string' || !body[key].trim() || body[key].length > 255)
|
||||
throw new Error(`${key} must be a nonempty string (up to 255 characters)`);
|
||||
}
|
||||
if (
|
||||
body.match !== undefined &&
|
||||
(typeof body.match !== 'string' || !body.match.trim() || body.match.length > 64)
|
||||
)
|
||||
throw new Error('match must be a nonempty string (up to 64 characters)');
|
||||
if (body.seq !== undefined && (!Number.isSafeInteger(body.seq) || (body.seq as number) < 1))
|
||||
throw new Error('seq must be a positive safe integer');
|
||||
if (!Array.isArray(body.players)) throw new Error('players must be an array');
|
||||
const guids = new Set<string>();
|
||||
const players = body.players.map((value, index) => {
|
||||
@@ -89,7 +103,12 @@ export function parseGameEvent(value: unknown): GameEvent {
|
||||
}
|
||||
return { ...identity, ...stats };
|
||||
});
|
||||
const common = { server: body.server as string, map: body.map as string };
|
||||
const common = {
|
||||
server: body.server as string,
|
||||
map: body.map as string,
|
||||
...(body.match !== undefined ? { match: body.match as string } : {}),
|
||||
...(body.seq !== undefined ? { seq: body.seq as number } : {})
|
||||
};
|
||||
if (body.event === 'game_start') return { ...common, event: 'game_start', players };
|
||||
if (!Number.isSafeInteger(body.round) || (body.round as number) < 0)
|
||||
throw new Error('round must be a nonnegative integer');
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
>Completed</option
|
||||
><option value="active">In progress</option><option value="interrupted"
|
||||
>Interrupted</option
|
||||
></Select
|
||||
><option value="end-report-missing">End report missing</option></Select
|
||||
></Field
|
||||
>
|
||||
{#if suffix}
|
||||
@@ -343,7 +343,9 @@
|
||||
? 'In progress'
|
||||
: match.status === 'completed'
|
||||
? 'Completed'
|
||||
: 'Interrupted'}
|
||||
: match.status === 'end-report-missing'
|
||||
? 'End report missing'
|
||||
: 'Interrupted'}
|
||||
{@const duration =
|
||||
match.status === 'active'
|
||||
? reporting
|
||||
@@ -383,6 +385,10 @@
|
||||
missing="Not recorded"
|
||||
/></small
|
||||
>{/if}
|
||||
{#if match.missingReports !== null && match.missingReports > 0}<small
|
||||
class="mobile-match-context"
|
||||
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
|
||||
>{/if}
|
||||
{#if server === undefined}<div class="instance">
|
||||
<span title={match.server}>{matchInstanceLabel(match)}</span
|
||||
>{#if match.server_deleted_at}<span>
|
||||
@@ -395,12 +401,15 @@
|
||||
class="status"
|
||||
class:active={reporting}
|
||||
class:interrupted={match.status === 'interrupted' ||
|
||||
match.status === 'end-report-missing' ||
|
||||
notReporting}>{status}</span
|
||||
>{#if notReporting}<small
|
||||
>Last report <RelativeTime
|
||||
value={match.lastReportedAt}
|
||||
missing="Not recorded"
|
||||
/></small
|
||||
>{/if}{#if match.missingReports !== null && match.missingReports > 0}<small
|
||||
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
|
||||
>{/if}</td
|
||||
>
|
||||
<td class="numeric">{match.uniquePlayers}</td>
|
||||
@@ -462,10 +471,12 @@
|
||||
Players counts unique players across all reports. A dash means the value is unavailable,
|
||||
not zero. In-progress matches show elapsed time since their recorded start and link to
|
||||
the live server while reports are current. Otherwise, they show Not reporting with the
|
||||
last report time. Completed durations require a recorded start and completed ending.
|
||||
Round shows the final round for completed matches and the highest observed round for
|
||||
unfinished matches. Open a completed match for exact timestamps and starting and ending
|
||||
player counts.
|
||||
last report time. Completed durations require a recorded start and completed ending. End
|
||||
report missing means a newer game was reported before this game's end report arrived.
|
||||
Missing report counts show gaps in the reporter's sequence and can decrease as delayed
|
||||
reports arrive. Only completed games contribute to the leaderboard. Round shows the
|
||||
final round for completed matches and the highest observed round for unfinished matches.
|
||||
Open a completed match for exact timestamps and starting and ending player counts.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user