Add heartbeat telemetry support

This commit is contained in:
2026-09-19 21:52:05 -04:00
parent 8ac420fe61
commit ce0321a6e9
14 changed files with 63 additions and 21 deletions
+12
View File
@@ -6,6 +6,18 @@ import { gameEvent } from '../helpers/telemetry.js';
const planWith = (...kinds: Parameters<typeof requirement>[0][]): BetaPlan => ({ ...defaultPlan(), requirements: kinds.map((k, i) => requirement(k, `r${i}`)) });
describe('beta requirement evaluation', () => {
it('credits heartbeat participation during a long round without completing a match', () => {
const plan = planWith('populated-minutes', 'round', 'matches');
const m = emptyMetrics();
applyGameEvent(plan, m, gameEvent('game_start'), 0);
applyGameEvent(plan, m, gameEvent('round_change', 30), 60_000);
applyGameEvent(plan, m, gameEvent('heartbeat', 30), 120_000);
applyGameEvent(plan, m, gameEvent('heartbeat', 30), 180_000);
expect(m.values).toMatchObject({ r0: 3, r1: 30 });
expect(m.values.r2 ?? 0).toBe(0);
expect(m.match).toMatchObject({ started: 0, lastEvent: 180_000, ended: false });
});
it('copies reactive presets without mutating saved requirements', () => {
const source = defaultPlan();
const selected = parseBetaPlan(new Proxy(source, {}));
+2 -2
View File
@@ -42,8 +42,8 @@ describe('Fleet discovery and match freshness', () => {
expect(matchesFleet(server, '', 'attention', '', [], [{ ...operation, state: 'attention' }], now)).toBe(true);
expect(matchesFleet({ ...server, health: 'starting' }, '', 'attention', '', [], [], now)).toBe(false);
});
it('only spotlights fresh telemetry from the current occupied match', () => {
const report = { ...gameEvent('round_change', 38, 4, server.name), receivedAt: iso(-1000) };
it.each(['round_change', 'heartbeat'] as const)('only spotlights fresh %s telemetry from the current occupied match', (event) => {
const report = { ...gameEvent(event, 38, 4, server.name), receivedAt: iso(-1000) };
expect(currentRound(server, report, now)).toBe(38);
for (const change of [{ receivedAt: iso(-121_000) }, { receivedAt: iso(-61_000) }, { receivedAt: iso(60_000) }]) {
expect(currentRound(server, { ...report, ...change }, now)).toBeNull();
+12
View File
@@ -43,6 +43,18 @@ async function records(guid = '2741626') {
}>;
}
it('refreshes stats during a round without adding rounds or completing the match', async () => {
await report('game_start');
await report('round_change', 30, [player(undefined, { kills: 100 })]);
await report('heartbeat', 30, [player(undefined, { kills: 150 })]);
await report('heartbeat', 30, [player(undefined, { kills: 150 })]);
expect(await matches()).toEqual([expect.objectContaining({ status: 'active', highest_round: 30 })]);
expect(await records()).toEqual([expect.objectContaining({ kills: 150, rounds_survived: 29, completed: 0 })]);
await report('game_end', 30);
await report('heartbeat', 30);
expect((await matches()).map(match => match.status)).toEqual(['completed', 'active']);
});
it('persists independent stat high-water marks without adding cumulative snapshots or retries', async () => {
await report('game_start');
await report('round_change', 1);
+18 -2
View File
@@ -38,7 +38,8 @@ it('timestamps initial telemetry and subsequent status and telemetry events with
}
});
it('persists full player stats, broadcasts updates, and leaves RCON safety data untouched', async () => {
it.each(['round_change', 'heartbeat'] as const)('persists %s stats, broadcasts updates, and leaves RCON safety data untouched', async (event) => {
const payload = gameEvent(event, 17);
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const listener = vi.fn();
const unsubscribe = subscribe(listener);
@@ -62,7 +63,7 @@ it('persists full player stats, broadcasts updates, and leaves RCON safety data
await reader.cancel();
});
it('accepts all three events without an active beta and updates match lifecycle', async () => {
it('accepts all four events without an active beta and updates match lifecycle', async () => {
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const start = gameEvent('game_start');
@@ -71,6 +72,12 @@ it('accepts all three events without an active beta and updates match lifecycle'
expect(getServer(start.server)?.game_started_at).toBeTruthy();
expect(getServer(start.server)?.game_ended_at).toBeNull();
expect((await post(payload)).status).toBe(200);
const startedAt = getServer(start.server)?.game_started_at;
const heartbeat = gameEvent('heartbeat', 17);
expect((await post(heartbeat)).status).toBe(200);
expect(listTelemetry()[0]).toMatchObject(heartbeat);
expect(getServer(start.server)?.game_started_at).toBe(startedAt);
expect(getServer(start.server)?.game_ended_at).toBeNull();
const end = gameEvent('game_end', 18);
expect((await post(end)).status).toBe(200);
expect(getServer(start.server)?.game_ended_at).toBeTruthy();
@@ -93,6 +100,15 @@ it.each([null, [], {}, ...['boot', 'session_start', 'match_end', 'feature', 'oth
expect((await post(body)).status).toBe(400);
});
it.each([
{ ...gameEvent('heartbeat'), round: undefined },
{ ...gameEvent('heartbeat'), round: -1 },
{ ...gameEvent('heartbeat'), players: [{ name: 'Player', guid: '123' }] },
{ ...gameEvent('heartbeat'), players: [{ ...roster(1)[0], kills: -1 }] }
])('requires round and full stats for heartbeats: %j', async (body) => {
expect((await post(body)).status).toBe(400);
});
it('rejects unknown and destroyed servers and non-JSON requests', async () => {
expect((await post({ ...payload, server: 'unknown' })).status).toBe(404);
expect((await post(payload, 'text/plain')).status).toBe(415);