Preserve fleet match timing across empty telemetry
CI / checks (push) Successful in 4m46s

This commit is contained in:
2026-10-09 19:41:55 -04:00
parent d1f581d6a8
commit ce6dba17e6
12 changed files with 171 additions and 53 deletions
+6
View File
@@ -190,6 +190,12 @@ an end closes the match. Receipt order determines the latest report. This small
protocol does not attempt to identify delayed events from earlier games or protocol does not attempt to identify delayed events from earlier games or
provide exactly-once delivery across games. provide exactly-once delivery across games.
Match timing is independent of player count: an empty `game_start` still records
the start, and empty rosters or RCON polls do not erase it. Fleet can display a
fresh reported round even when the start was missed; match duration stays unknown
in that case. When match history identifies a new match from subsequent gameplay,
Fleet clears the previous match's timing rather than carrying it into the new one.
Reports also feed the persistent [Leaderboard](leaderboard.md), independently Reports also feed the persistent [Leaderboard](leaderboard.md), independently
of beta activity. That page documents cumulative stat accounting, round credit, of beta activity. That page documents cumulative stat accounting, round credit,
and the limits of identifying matches from this protocol. and the limits of identifying matches from this protocol.
+1 -2
View File
@@ -105,14 +105,13 @@ export function currentRound(
(report.event !== 'round_change' && report.event !== 'heartbeat') || (report.event !== 'round_change' && report.event !== 'heartbeat') ||
!freshServer(server, now) || !freshServer(server, now) ||
!server.active_since || !server.active_since ||
!server.game_started_at ||
server.game_ended_at || server.game_ended_at ||
(server.last_players ?? 0) === 0 (server.last_players ?? 0) === 0
) )
return null; return null;
const received = Date.parse(report.receivedAt); const received = Date.parse(report.receivedAt);
return freshTelemetry(report, now, server.telemetry_after) && return freshTelemetry(report, now, server.telemetry_after) &&
received >= Date.parse(server.game_started_at) && (!server.game_started_at || received >= Date.parse(server.game_started_at)) &&
received >= Date.parse(server.active_since) received >= Date.parse(server.active_since)
? report.round ? report.round
: null; : null;
+2 -2
View File
@@ -176,8 +176,8 @@ export function selectServerObservation<T extends ServerObservation>(
})), })),
active_since: players > 0 ? timing.activeSince : null, active_since: players > 0 ? timing.activeSince : null,
inactive_since: players === 0 ? timing.inactiveSince : null, inactive_since: players === 0 ? timing.inactiveSince : null,
game_started_at: players === 0 ? null : timing.gameStartedAt, game_started_at: timing.gameStartedAt,
game_ended_at: players === 0 ? null : timing.gameEndedAt game_ended_at: timing.gameEndedAt
}; };
} }
// A current authenticated RCON reply can establish game liveness when // A current authenticated RCON reply can establish game liveness when
+2 -4
View File
@@ -20,15 +20,13 @@ export function successfulStatusFields(
}; };
} }
/** Occupancy timers and match clearing follow the same source as the UI. */ /** Occupancy follows the UI's source; only gameplay evidence changes match timing. */
export function activityFields(current: ServerRow, status: ServerObservation): Partial<ServerRow> { export function activityFields(current: ServerRow, status: ServerObservation): Partial<ServerRow> {
if (status.health !== 'online' || status.last_players === null) return {}; if (status.health !== 'online' || status.last_players === null) return {};
return status.last_players > 0 return status.last_players > 0
? { active_since: current.active_since ?? status.last_seen_at, inactive_since: null } ? { active_since: current.active_since ?? status.last_seen_at, inactive_since: null }
: { : {
active_since: null, active_since: null,
inactive_since: current.inactive_since ?? status.last_seen_at, inactive_since: current.inactive_since ?? status.last_seen_at
game_started_at: null,
game_ended_at: null
}; };
} }
+41 -32
View File
@@ -25,46 +25,55 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
? JSON.parse(row.payload) ? JSON.parse(row.payload)
: undefined; : undefined;
recordLeaderboardTelemetry(handle, report); recordLeaderboardTelemetry(handle, report);
// Record each occupancy transition at receipt, before a heartbeat can const match = handle
// 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( .prepare(
'UPDATE servers SET active_since = COALESCE(active_since, ?), inactive_since = NULL WHERE name = ?' 'SELECT id, started_at, start_observed FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1'
) )
.run(report.receivedAt, report.server); .get(report.server) as { id: number; started_at: string; start_observed: number };
if (report.event === 'game_start') { const sameMatch = previous?.timing?.matchId === match.id;
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 const serverTiming = handle
.prepare( .prepare(
`SELECT active_since AS activeSince, inactive_since AS inactiveSince, `SELECT active_since AS activeSince, inactive_since AS inactiveSince,
game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?` game_started_at AS gameStartedAt, game_ended_at AS gameEndedAt FROM servers WHERE name = ?`
) )
.get(report.server) as ServerTiming; .get(report.server) as ServerTiming;
const match = handle // Record each occupancy transition at receipt, before a heartbeat can
.prepare('SELECT id FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1') // replace game_start or an empty report between status polls.
.get(report.server) as { id: number }; const after = latestGameSubmission(report.server);
const sameMatch = previous?.timing?.matchId === match.id; 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. // A gap beyond the normal heartbeat window cannot establish continuity.
const continuous = const continuous =
previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000; previous && Date.parse(report.receivedAt) - Date.parse(previous.receivedAt) <= 120_000;
+2 -2
View File
@@ -100,10 +100,10 @@
: null : null
); );
const currentRound = $derived( const currentRound = $derived(
!gameEndedAt ? (round !== undefined ? round : reportedRound) : null players !== 0 && !gameEndedAt ? (round !== undefined ? round : reportedRound) : null
); );
const gameElapsed = $derived( const gameElapsed = $derived(
gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null players !== 0 && gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null
); );
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow)); const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
// These lobbies have four slots when the live status omits the limit. // These lobbies have four slots when the live status omits the limit.
+8
View File
@@ -119,6 +119,14 @@ describe('Fleet discovery and match freshness', () => {
(event) => { (event) => {
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) }; const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
expect(currentRound(server, report, now)).toBe(38); expect(currentRound(server, report, now)).toBe(38);
const unknownStart = { ...server, game_started_at: null };
expect(currentRound(unknownStart, report, now)).toBe(38);
expect(
currentRound({ ...unknownStart, telemetry_after: iso() }, report, now)
).toBeNull();
expect(
currentRound(unknownStart, { ...report, receivedAt: iso(-121_000) }, now)
).toBeNull();
for (const change of [ for (const change of [
{ receivedAt: iso(-121_000) }, { receivedAt: iso(-121_000) },
{ receivedAt: iso(-61_000) }, { receivedAt: iso(-61_000) },
+3 -3
View File
@@ -434,7 +434,7 @@ describe('shared server observation', () => {
expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false); expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false);
}); });
it('uses game-start rosters without inventing scores and clears match times on an empty report', () => { it('uses game-start rosters without inventing scores and preserves match times on an empty report', () => {
const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() }; const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() };
const active = selectServerObservation(server(), start, now); const active = selectServerObservation(server(), start, now);
expect(active.roster?.[0]).toMatchObject({ score: null, ping: null }); expect(active.roster?.[0]).toMatchObject({ score: null, ping: null });
@@ -444,8 +444,8 @@ describe('shared server observation', () => {
).toMatchObject({ ).toMatchObject({
last_players: 0, last_players: 0,
roster: [], roster: [],
game_started_at: null, game_started_at: at(),
game_ended_at: null game_ended_at: at()
}); });
}); });
}); });
+52 -2
View File
@@ -608,7 +608,7 @@ describe('database fleet and container readiness', () => {
} }
); );
it('keeps game_end through occupied polls until game_start or an empty lobby', async () => { it('keeps game_end through RCON polls and recovers when telemetry observes a new match', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup(); const { inspect, rcon, fanoutStatus, getServer } = await setup();
inspect.mockResolvedValue({ ok: true, state: 'running' }); inspect.mockResolvedValue({ ok: true, state: 'running' });
const reply = (players: number) => ({ const reply = (players: number) => ({
@@ -621,9 +621,11 @@ describe('database fleet and container readiness', () => {
await postTelemetry(gameEvent('game_end', 10, 1, 'managed')); await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
const ended = getServer('managed')?.game_ended_at; const ended = getServer('managed')?.game_ended_at;
expect(ended).toEqual(expect.any(String)); expect(ended).toEqual(expect.any(String));
await postTelemetry(gameEvent('heartbeat', 10, 1, 'managed'));
await fanoutStatus(); await fanoutStatus();
expect(getServer('managed')?.game_ended_at).toBe(ended); expect(getServer('managed')?.game_ended_at).toBe(ended);
await postTelemetry(gameEvent('heartbeat', 1, 1, 'managed'));
await fanoutStatus();
expect(getServer('managed')).toMatchObject({ game_started_at: null, game_ended_at: null });
await postTelemetry(gameEvent('game_start', 0, 1, 'managed')); await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
expect(getServer('managed')?.game_ended_at).toBeNull(); expect(getServer('managed')?.game_ended_at).toBeNull();
await postTelemetry(gameEvent('game_end', 10, 1, 'managed')); await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
@@ -638,6 +640,54 @@ describe('database fleet and container readiness', () => {
expect(getServer('managed')?.game_ended_at).toBeNull(); expect(getServer('managed')?.game_ended_at).toBeNull();
}); });
it.each([0, 1])(
'preserves a match starting with %i players through empty reports, polls, a gap, and reload',
async (startPlayers) => {
const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js');
const { currentRound } = await import('../../src/lib/fleet-overview.js');
const { closeDb } = await import('../../src/lib/server/db/index.js');
const beginning = Date.parse('2026-10-09T12:00:00Z');
const at = (seconds: number) => new Date(beginning + seconds * 1000).toISOString();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(beginning);
inspect.mockResolvedValue({ ok: true, state: 'running' });
rcon.mockResolvedValue({
ok: true,
endpoint: { host: 'localhost', port: 28960 },
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] }
});
const start = await (
await postTelemetry(gameEvent('game_start', 0, startPlayers, 'managed'))
).json();
expect(start.telemetry.timing.server.gameStartedAt).toBe(at(0));
vi.setSystemTime(beginning + 1000);
await postTelemetry(gameEvent('round_change', 1, 0, 'managed'));
for (const seconds of [30, 121]) {
vi.setSystemTime(beginning + seconds * 1000);
await fanoutStatus();
expect(getServer('managed')).toMatchObject({
active_since: null,
game_started_at: at(0),
game_ended_at: null
});
}
vi.setSystemTime(beginning + 180_000);
await postTelemetry(gameEvent('heartbeat', 4, 1, 'managed'));
await fanoutStatus();
closeDb();
const server = (await (await GET()).json()).servers[0];
expect(server).toMatchObject({
health: 'online',
last_players: 1,
active_since: at(180),
game_started_at: at(0),
game_ended_at: null
});
expect(currentRound(server, server.telemetry, Date.now())).toBe(4);
}
);
it('keeps the first occupied report as the active timer when more telemetry arrives before polling', async () => { it('keeps the first occupied report as the active timer when more telemetry arrives before polling', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup(); const { inspect, rcon, fanoutStatus, getServer } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js'); const { GET } = await import('../../src/routes/api/servers/+server.js');
+39
View File
@@ -18,6 +18,45 @@ afterEach(async () => fixture.cleanup());
const payload = gameEvent('round_change', 17); const payload = gameEvent('round_change', 17);
it.each(['game_end', 'round reset', 'map change'])(
'drops the previous match timing when game_start is missed after %s',
async (boundary) => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const at = (seconds: number) =>
new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString();
saveTelemetry({ ...gameEvent('game_start', 0, 1), receivedAt: at(0) });
const previous = saveTelemetry({ ...gameEvent('heartbeat', 7, 1), receivedAt: at(30) });
if (boundary === 'game_end') {
const ended = saveTelemetry({ ...gameEvent('game_end', 7, 0), receivedAt: at(31) });
expect(ended.timing?.server?.gameEndedAt).toBe(at(31));
}
const next = saveTelemetry({
...gameEvent('heartbeat', boundary === 'map change' ? 8 : 1, 1),
...(boundary === 'map change' ? { map: 'zombie_moon' } : {}),
receivedAt: at(60)
});
expect(next.timing?.matchId).not.toBe(previous.timing?.matchId);
expect(next.timing?.server).toMatchObject({ gameStartedAt: null, gameEndedAt: null });
expect(getServer(payload.server)).toMatchObject({
game_started_at: null,
game_ended_at: null
});
}
);
it('repairs a previously discarded start from an observed match without inventing one', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = (seconds: number) =>
new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString();
saveTelemetry({ ...gameEvent('game_start', 0, 0), receivedAt: at(0) });
// Simulate the old ingestion behavior for an empty start.
db().prepare('UPDATE servers SET game_started_at = NULL WHERE name = ?').run(payload.server);
const report = saveTelemetry({ ...gameEvent('heartbeat', 4, 1), receivedAt: at(120) });
expect(report.timing?.server?.gameStartedAt).toBe(at(0));
});
it('does not let a request received before a lifecycle submission restore cleared timers', async () => { it('does not let a request received before a lifecycle submission restore cleared timers', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
+9
View File
@@ -88,6 +88,15 @@ describe('compact server cards', () => {
expect(body).not.toContain('36ms'); expect(body).not.toContain('36ms');
}); });
it('shows a known round without a match clock and hides match metrics for an empty lobby', () => {
const occupied = html({ gameStartedAt: null, round: 31 });
expect(occupied).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>31<\/dd>/);
expect(occupied).toMatch(/<dt[^>]*>Match time<\/dt>\s*<dd[^>]*>—<\/dd>/);
const empty = html({ players: 0, round: 31, compactEmpty: false });
expect(empty).toMatch(/<dt[^>]*>Round<\/dt>\s*<dd[^>]*>—<\/dd>/);
expect(empty).toMatch(/<dt[^>]*>Match time<\/dt>\s*<dd[^>]*>—<\/dd>/);
});
it('places Pin outside the only link and retains its pressed state and server name', () => { it('places Pin outside the only link and retains its pressed state and server name', () => {
const body = html({ pinned: true, onPin: () => {} }); const body = html({ pinned: true, onPin: () => {} });
expect(body).toMatch(/<article\b/); expect(body).toMatch(/<article\b/);
+4 -4
View File
@@ -460,26 +460,26 @@ describe('Fleet report clock', () => {
report('heartbeat', 34, 0, { report('heartbeat', 34, 0, {
activeSince: null, activeSince: null,
inactiveSince: at(34), inactiveSince: at(34),
gameStartedAt: null, gameStartedAt: at(32),
gameEndedAt: null gameEndedAt: null
}) })
).toMatchObject({ ).toMatchObject({
active_since: null, active_since: null,
inactive_since: at(34), inactive_since: at(34),
game_started_at: null, game_started_at: at(32),
game_ended_at: null game_ended_at: null
}); });
expect( expect(
report('heartbeat', 35, 1, { report('heartbeat', 35, 1, {
activeSince: at(35), activeSince: at(35),
inactiveSince: null, inactiveSince: null,
gameStartedAt: null, gameStartedAt: at(32),
gameEndedAt: null gameEndedAt: null
}) })
).toMatchObject({ ).toMatchObject({
active_since: at(35), active_since: at(35),
inactive_since: null, inactive_since: null,
game_started_at: null, game_started_at: at(32),
game_ended_at: null game_ended_at: null
}); });
}); });