146 lines
6.7 KiB
TypeScript
146 lines
6.7 KiB
TypeScript
import { db } from '../db/index.js';
|
|
import type { GameTelemetry, ServerTiming } from '../../telemetry.js';
|
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
|
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
|
|
import { latestGameSubmission } from './status-evidence.js';
|
|
|
|
export function listTelemetry(): GameTelemetry[] {
|
|
const rows = db()
|
|
.prepare(
|
|
`SELECT t.payload FROM server_telemetry t JOIN servers s ON s.name = t.server
|
|
WHERE s.destroyed_at IS NULL AND t.payload IS NOT NULL`
|
|
)
|
|
.all() as Array<{ payload: string }>;
|
|
return rows.map((row) => JSON.parse(row.payload) as 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(() => {
|
|
const row = handle
|
|
.prepare('SELECT payload FROM server_telemetry WHERE server = ?')
|
|
.get(report.server) as { payload: string | null } | undefined;
|
|
const previous: GameTelemetry | undefined = row?.payload
|
|
? JSON.parse(row.payload)
|
|
: undefined;
|
|
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 id = ?')
|
|
.get(recorded.matchId) as { id: number; started_at: string; start_observed: number };
|
|
const sameMatch = previous?.timing?.matchId === match.id;
|
|
const serverTiming = handle
|
|
.prepare(
|
|
`SELECT active_since AS activeSince, inactive_since AS inactiveSince,
|
|
game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?`
|
|
)
|
|
.get(report.server) as ServerTiming;
|
|
// Record each occupancy transition at receipt, before a heartbeat can
|
|
// replace game_start or an empty report between status polls.
|
|
const after = latestGameSubmission(report.server);
|
|
if (!after || Date.parse(report.receivedAt) > Date.parse(after)) {
|
|
const occupied = report.players.length > 0;
|
|
serverTiming.activeSince = occupied
|
|
? (serverTiming.activeSince ?? report.receivedAt)
|
|
: null;
|
|
serverTiming.inactiveSince = occupied
|
|
? null
|
|
: (serverTiming.inactiveSince ?? report.receivedAt);
|
|
// Match boundaries follow history, independently of roster occupancy.
|
|
// Recover starts discarded by older receivers only when a start was
|
|
// actually observed; an inferred match has no known start time.
|
|
const startedAt =
|
|
report.event === 'game_start'
|
|
? report.receivedAt
|
|
: ((sameMatch ? serverTiming.gameStartedAt : null) ??
|
|
(match.start_observed ? match.started_at : null));
|
|
serverTiming.gameStartedAt =
|
|
startedAt && (!after || Date.parse(startedAt) > Date.parse(after))
|
|
? startedAt
|
|
: null;
|
|
serverTiming.gameEndedAt = report.event === 'game_end' ? report.receivedAt : null;
|
|
handle
|
|
.prepare(
|
|
`UPDATE servers SET active_since = ?, inactive_since = ?,
|
|
game_started_at = ?, game_ended_at = ? WHERE name = ?`
|
|
)
|
|
.run(
|
|
serverTiming.activeSince,
|
|
serverTiming.inactiveSince,
|
|
serverTiming.gameStartedAt,
|
|
serverTiming.gameEndedAt,
|
|
report.server
|
|
);
|
|
}
|
|
// A gap beyond the normal heartbeat window cannot establish continuity.
|
|
const continuous =
|
|
previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000;
|
|
report = {
|
|
...report,
|
|
timing: {
|
|
matchId: match.id,
|
|
server: serverTiming,
|
|
players: Object.fromEntries(
|
|
report.players.map((player) => {
|
|
const prior = continuous
|
|
? previous?.timing?.players[player.guid]
|
|
: undefined;
|
|
return [
|
|
player.guid,
|
|
{
|
|
serverJoinedAt: prior?.serverJoinedAt ?? report.receivedAt,
|
|
matchJoinedAt:
|
|
sameMatch && prior ? prior.matchJoinedAt : report.receivedAt,
|
|
roundStarted:
|
|
sameMatch && prior
|
|
? prior.roundStarted
|
|
: report.event === 'game_start'
|
|
? 1
|
|
: report.round
|
|
}
|
|
];
|
|
})
|
|
)
|
|
}
|
|
};
|
|
handle
|
|
.prepare(
|
|
`INSERT INTO server_telemetry (server, round, players, received_at, payload)
|
|
VALUES (?, ?, ?, ?, ?) ON CONFLICT(server) DO UPDATE SET
|
|
round = excluded.round, players = excluded.players, received_at = excluded.received_at, payload = excluded.payload`
|
|
)
|
|
.run(
|
|
report.server,
|
|
report.event === 'game_start' ? 0 : report.round,
|
|
report.players.length,
|
|
report.receivedAt,
|
|
JSON.stringify(report)
|
|
);
|
|
if (report.event === 'game_end') {
|
|
// Legacy betas also take match completion directly from the script.
|
|
if (report.round >= MIN_RECORDED_ROUND)
|
|
handle
|
|
.prepare(
|
|
`UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND server_deleted_at IS NULL AND stage = 'awaiting-match'
|
|
AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`
|
|
)
|
|
.run(report.receivedAt, report.server, report.receivedAt);
|
|
}
|
|
return { telemetry: report, current: true };
|
|
})();
|
|
}
|