Show match demand in metrics (Refs #138)
CI / checks (push) Successful in 4m44s

This commit is contained in:
2026-10-10 16:26:32 -04:00
parent ea932ec563
commit 616b1937ed
15 changed files with 775 additions and 42 deletions
+193
View File
@@ -12,7 +12,200 @@ afterEach(async () => {
await fixture.cleanup();
});
async function seedMatches(
records: Array<{
id: number;
at: number;
players: number;
game?: string;
map?: string;
status?: 'active' | 'completed' | 'interrupted';
}>
) {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const match = handle.prepare(`INSERT INTO leaderboard_matches
(id, server, server_deleted_at, game, map, map_key, map_name, started_at,
last_received_at, status, start_observed, start_players, last_event, last_fingerprint)
VALUES (?, 'removed-server', ?, ?, 'zm_transit', 'shared-key', ?, ?, ?, ?, 0, 4, 'round_change', 'test')`);
const player = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, present, has_stats, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 0, ?, ?)`);
for (const record of records) {
const at = new Date(record.at).toISOString();
match.run(
record.id,
at,
record.game ?? 't5',
record.map ?? 'Moon',
at,
at,
record.status ?? 'completed'
);
for (let index = 0; index < record.players; index++) {
player.run(record.id, `player-${index}`, at, at);
}
}
return handle;
}
describe('historical metrics', () => {
it.each([1, 7, 30] as const)(
'counts retained matches started within the inclusive %i-day window regardless of status or observed start',
async (days) => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const since = now - days * 86400_000;
const handle = await seedMatches([
{ id: 1, at: since - 1, players: 4 },
{ id: 2, at: since, players: 1, status: 'completed' },
{ id: 3, at: now, players: 2, status: 'active' },
{ id: 4, at: since + 1, players: 0, status: 'interrupted' },
{ id: 5, at: now + 1, players: 4, map: 'Future Map' }
]);
// A recent update does not bring an earlier match into the selected window.
handle
.prepare('UPDATE leaderboard_matches SET last_received_at=?, ended_at=? WHERE id=1')
.run(new Date(now).toISOString(), new Date(now).toISOString());
const result = readMetrics(days, now);
expect(result.matches).toEqual({
started: 3,
withRecordedPlayers: 2,
averageGroupSize: 1.5,
solo: 1,
soloShare: 50
});
expect(result.maps).toHaveLength(1);
expect(result.maps[0].matches).toEqual(result.matches);
expect(result.registeredServerCount).toBe(0);
expect(result.servers).toEqual([]);
expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
}
);
it('weights group sizes by matches and counts unique participants per match across repeated updates', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const handle = await seedMatches([
{ id: 1, at: now - 5000, players: 1 },
{ id: 2, at: now - 4000, players: 1 },
{ id: 3, at: now - 3000, players: 4 },
{ id: 4, at: now - 2000, players: 0 },
{ id: 5, at: now - 1000, players: 4, map: 'Town' }
]);
for (let update = 0; update < 3; update++) {
handle
.prepare(
`INSERT INTO leaderboard_match_players
(match_id, guid, first_seen_at, last_seen_at) VALUES (1, 'player-0', ?, ?)
ON CONFLICT(match_id, guid) DO UPDATE SET kills=kills+1, last_seen_at=excluded.last_seen_at`
)
.run(new Date(now).toISOString(), new Date(now).toISOString());
}
const result = readMetrics(1, now);
expect(result.matches).toEqual({
started: 5,
withRecordedPlayers: 4,
averageGroupSize: 2.5,
solo: 2,
soloShare: 50
});
const moon = result.maps.find((map) => map.map === 'Moon')!.matches;
expect(moon).toMatchObject({
started: 4,
withRecordedPlayers: 3,
averageGroupSize: 2,
solo: 2
});
expect(moon.soloShare).toBeCloseTo((2 / 3) * 100);
expect(result.maps.find((map) => map.map === 'Town')!.matches).toEqual({
started: 1,
withRecordedPlayers: 1,
averageGroupSize: 4,
solo: 0,
soloShare: 0
});
expect(result.firstRetainedSampleAt).toBeNull();
expect(result.maps.every((map) => map.observed === 0 && map.playerHours === 0)).toBe(true);
});
it('distinguishes no matches, unknown rosters, and occupancy-only maps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
const empty = {
started: 0,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
};
expect(readMetrics(1, now).matches).toEqual(empty);
recordMetrics(
[{ server: 'sample-only', game: 't5', map: 'Moon', players: 1, capacity: 4 }],
now - 30_000
);
await seedMatches([{ id: 1, at: now - 60_000, players: 0, map: 'Town' }]);
const result = readMetrics(1, now);
expect(result.matches).toEqual({ ...empty, started: 1 });
expect(result.maps.find((map) => map.map === 'Moon')!.matches).toEqual(empty);
expect(result.maps.find((map) => map.map === 'Town')).toMatchObject({
matches: { ...empty, started: 1 },
observed: 0,
active: 0,
idle: 0,
unknown: 0,
playerHours: 0,
noEmpty: 0,
availabilityObserved: 0,
availabilityUnknown: 0
});
});
it('filters matches by game and exact recorded map label while retaining historical match scope options', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 1, 1, 12);
await seedMatches([
{ id: 1, at: now - 1000, players: 1, game: 't6', map: 'Town Survival' },
{ id: 2, at: now - 2000, players: 2, game: 't6', map: 'Town Grief' },
{ id: 3, at: now - 3000, players: 4, game: 't5', map: 'Town Survival' },
{ id: 4, at: now - 2 * 86400_000, players: 3, game: 't6', map: 'Farm Survival' },
{ id: 5, at: now + 1, players: 4, game: 't6', map: 'Future Map' }
]);
const query = parseMetricsQuery(
new URLSearchParams({ days: '1', game: 't6', map: 'Town Survival' })
);
const selected = readMetrics(query, now);
expect(selected.matches).toEqual({
started: 1,
withRecordedPlayers: 1,
averageGroupSize: 1,
solo: 1,
soloShare: 100
});
expect(selected.maps.map((map) => [map.game, map.map])).toEqual([['t6', 'Town Survival']]);
expect(selected.scopeOptions).toEqual({
games: ['t5', 't6'],
maps: [
{ game: 't5', map: 'Town Survival' },
{ game: 't6', map: 'Farm Survival' },
{ game: 't6', map: 'Town Grief' },
{ game: 't6', map: 'Town Survival' }
]
});
const game = readMetrics({ ...query, map: null }, now);
expect(game.matches).toMatchObject({ started: 2, averageGroupSize: 1.5, soloShare: 50 });
expect(game.maps.map((map) => map.map).sort()).toEqual(['Town Grief', 'Town Survival']);
expect(readMetrics({ ...query, days: 7, map: null }, now).matches.started).toBe(3);
expect(readMetrics({ ...query, game: 't5' }, now).matches.averageGroupSize).toBe(4);
expect(readMetrics({ ...query, map: 'zm_transit' }, now).matches.started).toBe(0);
expect(readMetrics({ ...query, map: 'shared-key' }, now).matches.started).toBe(0);
expect(readMetrics({ ...query, map: 'Farm Survival' }, now)).toMatchObject({
matches: { started: 0, averageGroupSize: null, soloShare: null },
maps: []
});
});
it('weights occupancy by time and excludes failed checks and collection gaps', async () => {
const { recordMetrics, readMetrics } =
await import('../../src/lib/server/fleet/metrics.js');