Fix active and match timer drift between status polls
Persist occupancy transitions with telemetry and carry timer anchors through live updates. Cover delayed polling, lobby rejoining, and lifecycle boundaries with regression tests. Fixes #47
This commit is contained in:
@@ -89,9 +89,9 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
created_by TEXT, -- 'ui' | operator identifier
|
||||
destroyed_at TEXT, -- ISO ts when tombstoned (row usually deleted; kept as safety net)
|
||||
-- Occupancy + match timers. active_since / inactive_since are mutually
|
||||
-- exclusive: exactly one is non-null while a server is reachable. RCON
|
||||
-- outages do NOT clear either — timers survive transient unreachability
|
||||
-- and resume from the same anchor when the server comes back.
|
||||
-- exclusive. Accepted telemetry records transitions immediately; status
|
||||
-- polls provide the RCON fallback. Outages do NOT clear either — timers
|
||||
-- survive transient unreachability and resume from the same anchor.
|
||||
active_since TEXT, -- ISO ts of last 0 -> >0 player transition
|
||||
inactive_since TEXT, -- ISO ts of last >0 -> 0 player transition
|
||||
game_started_at TEXT, -- receipt time of the script's game_start
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { db } from '../db/index.js';
|
||||
import type { GameTelemetry } from '../../telemetry.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
|
||||
@@ -16,11 +17,30 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
||||
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;
|
||||
recordLeaderboardTelemetry(handle, report);
|
||||
// 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)) {
|
||||
if (report.players.length > 0) {
|
||||
handle.prepare('UPDATE servers SET active_since = COALESCE(active_since, ?), inactive_since = NULL WHERE name = ?')
|
||||
.run(report.receivedAt, report.server);
|
||||
if (report.event === 'game_start') {
|
||||
handle.prepare('UPDATE servers SET game_started_at = ?, game_ended_at = NULL WHERE name = ?').run(report.receivedAt, report.server);
|
||||
} else if (report.event === 'game_end') {
|
||||
handle.prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?').run(report.receivedAt, report.server);
|
||||
}
|
||||
} else {
|
||||
handle.prepare(`UPDATE servers SET active_since = NULL, inactive_since = COALESCE(inactive_since, ?),
|
||||
game_started_at = NULL, game_ended_at = NULL WHERE name = ?`).run(report.receivedAt, report.server);
|
||||
}
|
||||
}
|
||||
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;
|
||||
const match = handle.prepare('SELECT id FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1').get(report.server) as { id: number };
|
||||
const sameMatch = previous?.timing?.matchId === match.id;
|
||||
// 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, players: Object.fromEntries(report.players.map(player => {
|
||||
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,
|
||||
@@ -32,10 +52,7 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
||||
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_start') {
|
||||
handle.prepare('UPDATE servers SET game_started_at = ?, game_ended_at = NULL WHERE name = ?').run(report.receivedAt, report.server);
|
||||
} else if (report.event === 'game_end') {
|
||||
handle.prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?').run(report.receivedAt, report.server);
|
||||
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')`)
|
||||
|
||||
Reference in New Issue
Block a user