This commit is contained in:
@@ -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
|
||||
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
|
||||
of beta activity. That page documents cumulative stat accounting, round credit,
|
||||
and the limits of identifying matches from this protocol.
|
||||
|
||||
@@ -105,14 +105,13 @@ export function currentRound(
|
||||
(report.event !== 'round_change' && report.event !== 'heartbeat') ||
|
||||
!freshServer(server, now) ||
|
||||
!server.active_since ||
|
||||
!server.game_started_at ||
|
||||
server.game_ended_at ||
|
||||
(server.last_players ?? 0) === 0
|
||||
)
|
||||
return null;
|
||||
const received = Date.parse(report.receivedAt);
|
||||
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)
|
||||
? report.round
|
||||
: null;
|
||||
|
||||
@@ -176,8 +176,8 @@ export function selectServerObservation<T extends ServerObservation>(
|
||||
})),
|
||||
active_since: players > 0 ? timing.activeSince : null,
|
||||
inactive_since: players === 0 ? timing.inactiveSince : null,
|
||||
game_started_at: players === 0 ? null : timing.gameStartedAt,
|
||||
game_ended_at: players === 0 ? null : timing.gameEndedAt
|
||||
game_started_at: timing.gameStartedAt,
|
||||
game_ended_at: timing.gameEndedAt
|
||||
};
|
||||
}
|
||||
// A current authenticated RCON reply can establish game liveness when
|
||||
|
||||
@@ -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> {
|
||||
if (status.health !== 'online' || status.last_players === null) return {};
|
||||
return status.last_players > 0
|
||||
? { active_since: current.active_since ?? status.last_seen_at, inactive_since: null }
|
||||
: {
|
||||
active_since: null,
|
||||
inactive_since: current.inactive_since ?? status.last_seen_at,
|
||||
game_started_at: null,
|
||||
game_ended_at: null
|
||||
inactive_since: current.inactive_since ?? status.last_seen_at
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,46 +25,55 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
||||
? 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
|
||||
const match = handle
|
||||
.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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
.get(report.server) 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;
|
||||
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;
|
||||
// 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;
|
||||
|
||||
@@ -100,10 +100,10 @@
|
||||
: null
|
||||
);
|
||||
const currentRound = $derived(
|
||||
!gameEndedAt ? (round !== undefined ? round : reportedRound) : null
|
||||
players !== 0 && !gameEndedAt ? (round !== undefined ? round : reportedRound) : null
|
||||
);
|
||||
const gameElapsed = $derived(
|
||||
gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null
|
||||
players !== 0 && gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null
|
||||
);
|
||||
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
|
||||
// These lobbies have four slots when the live status omits the limit.
|
||||
|
||||
@@ -119,6 +119,14 @@ describe('Fleet discovery and match freshness', () => {
|
||||
(event) => {
|
||||
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
|
||||
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 [
|
||||
{ receivedAt: iso(-121_000) },
|
||||
{ receivedAt: iso(-61_000) },
|
||||
|
||||
@@ -434,7 +434,7 @@ describe('shared server observation', () => {
|
||||
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 active = selectServerObservation(server(), start, now);
|
||||
expect(active.roster?.[0]).toMatchObject({ score: null, ping: null });
|
||||
@@ -444,8 +444,8 @@ describe('shared server observation', () => {
|
||||
).toMatchObject({
|
||||
last_players: 0,
|
||||
roster: [],
|
||||
game_started_at: null,
|
||||
game_ended_at: null
|
||||
game_started_at: at(),
|
||||
game_ended_at: at()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
const reply = (players: number) => ({
|
||||
@@ -621,9 +621,11 @@ describe('database fleet and container readiness', () => {
|
||||
await postTelemetry(gameEvent('game_end', 10, 1, 'managed'));
|
||||
const ended = getServer('managed')?.game_ended_at;
|
||||
expect(ended).toEqual(expect.any(String));
|
||||
await postTelemetry(gameEvent('heartbeat', 10, 1, 'managed'));
|
||||
await fanoutStatus();
|
||||
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'));
|
||||
expect(getServer('managed')?.game_ended_at).toBeNull();
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||
|
||||
@@ -18,6 +18,45 @@ afterEach(async () => fixture.cleanup());
|
||||
|
||||
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 () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
|
||||
@@ -88,6 +88,15 @@ describe('compact server cards', () => {
|
||||
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', () => {
|
||||
const body = html({ pinned: true, onPin: () => {} });
|
||||
expect(body).toMatch(/<article\b/);
|
||||
|
||||
@@ -460,26 +460,26 @@ describe('Fleet report clock', () => {
|
||||
report('heartbeat', 34, 0, {
|
||||
activeSince: null,
|
||||
inactiveSince: at(34),
|
||||
gameStartedAt: null,
|
||||
gameStartedAt: at(32),
|
||||
gameEndedAt: null
|
||||
})
|
||||
).toMatchObject({
|
||||
active_since: null,
|
||||
inactive_since: at(34),
|
||||
game_started_at: null,
|
||||
game_started_at: at(32),
|
||||
game_ended_at: null
|
||||
});
|
||||
expect(
|
||||
report('heartbeat', 35, 1, {
|
||||
activeSince: at(35),
|
||||
inactiveSince: null,
|
||||
gameStartedAt: null,
|
||||
gameStartedAt: at(32),
|
||||
gameEndedAt: null
|
||||
})
|
||||
).toMatchObject({
|
||||
active_since: at(35),
|
||||
inactive_since: null,
|
||||
game_started_at: null,
|
||||
game_started_at: at(32),
|
||||
game_ended_at: null
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user