Add unique and new player metrics (Refs #142)
CI / checks (push) Successful in 4m53s

This commit is contained in:
2026-10-10 18:47:56 -04:00
parent 7fcf1cded2
commit e3e740cea3
16 changed files with 1212 additions and 3 deletions
+48
View File
@@ -206,6 +206,54 @@ it('adds match report invalidation on upgrade without invalidating for score hea
expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]);
});
it('upgrades player report invalidation from version 15 for participation clocks and durable identities', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { installMetricsMatchRevisions } =
await import('../../src/lib/server/reports/revisions.js');
const handle = db();
handle.exec(
'DROP TRIGGER report_metrics_leaderboard_match_players_UPDATE; DROP INDEX idx_leaderboard_players_guid_first_seen'
);
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
handle.exec(`DROP TRIGGER report_metrics_leaderboard_players_${action}`);
installMetricsMatchRevisions(handle);
handle.pragma('user_version = 15');
closeDb();
const upgraded = db();
const revision = () =>
(
upgraded
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
.get() as { revision: number }
).revision;
upgraded.exec(`INSERT INTO leaderboard_matches
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','test');
INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at)
VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`);
let before = revision();
upgraded.exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T13:00:00.000Z'");
expect(revision()).toBeGreaterThan(before);
before = revision();
upgraded.exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
VALUES('t5','player','Player','2026-10-10T12:00:00.000Z','2026-10-10T13:00:00.000Z')`);
expect(revision()).toBeGreaterThan(before);
before = revision();
upgraded.exec(
"UPDATE leaderboard_players SET name='Renamed',last_seen_at='2026-10-10T14:00:00.000Z'"
);
expect(revision()).toBe(before);
upgraded.exec("UPDATE leaderboard_players SET first_seen_at='2026-10-09T12:00:00.000Z'");
expect(revision()).toBeGreaterThan(before);
expect(
upgraded
.prepare(
"SELECT name FROM sqlite_master WHERE name='idx_leaderboard_players_guid_first_seen'"
)
.get()
).toBeDefined();
});
it('adds provisioning report invalidation at version 14 without invalidating for progress updates', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const handle = db();
+212
View File
@@ -0,0 +1,212 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { MetricsDays } from '../../src/lib/metrics.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => {
vi.restoreAllMocks();
await fixture.cleanup();
});
const hour = 3600_000;
const day = 24 * hour;
const now = Date.UTC(2026, 9, 10, 12, 30);
const iso = (at: number) => new Date(at).toISOString();
async function match(start: number, end: number, guids: string[], game = 't5', map = 'Moon') {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const { lastInsertRowid: id } = handle
.prepare(
`INSERT INTO leaderboard_matches
(server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint)
VALUES ('removed-server',?,?,?, ?,?,?,'completed','game_end','test')`
)
.run(game, map, `${game}:${map}`, map, iso(start), iso(end));
for (const guid of guids) {
handle
.prepare(
`INSERT INTO leaderboard_match_players
(match_id,guid,first_seen_at,last_seen_at) VALUES(?,?,?,?)`
)
.run(id, guid, iso(start), iso(end));
handle
.prepare(
`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
VALUES(?,?,?,?,?) ON CONFLICT(game,guid) DO UPDATE SET last_seen_at=excluded.last_seen_at`
)
.run(game, guid, guid, iso(start), iso(end));
}
return Number(id);
}
describe('metrics player history', () => {
it.each<MetricsDays>([1, 7, 30])(
'counts spans and repeated matches once per period and range for %i days',
async (days) => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const bucket = days === 1 ? hour : day;
const boundary = Math.floor((now - bucket) / bucket) * bucket;
await match(now - 40 * day, now - 40 * day, ['regular']);
await match(boundary - 10_000, boundary + 10_000, ['regular', 'newcomer']);
await match(boundary + 20_000, boundary + 30_000, ['regular', 'newcomer']);
const result = readMetrics(days, now);
const before = result.points.find((point) => point.at === boundary - bucket)!;
const after = result.points.find((point) => point.at === boundary)!;
expect(before).toMatchObject({ uniquePlayers: 2, newPlayers: 1 });
expect(after).toMatchObject({ uniquePlayers: 2, newPlayers: 0 });
expect(result.players).toMatchObject({ uniquePlayers: 2, newPlayers: 1 });
expect(result.points.reduce((sum, point) => sum + (point.uniquePlayers ?? 0), 0)).toBe(
4
);
expect(result.summary.observed).toBe(0);
}
);
it('uses participation rather than match start and includes both range endpoints', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const since = now - day;
await match(since - 2 * hour, since - 1, ['ended-before']);
await match(since - hour, since + hour, ['spanning-start']);
await match(since, since, ['at-start']);
await match(now - hour, now + hour, ['spanning-end']);
await match(now, now, ['at-end']);
await match(now + 1, now + 1, ['future']);
const result = readMetrics(1, now);
expect(result.players).toMatchObject({ uniquePlayers: 4, newPlayers: 3 });
expect(result.points[0]).toMatchObject({ uniquePlayers: 2, newPlayers: 1, partial: true });
expect(result.points.at(-1)).toMatchObject({
uniquePlayers: 2,
newPlayers: 1,
partial: true
});
});
it('assigns a single sighting on an internal boundary only to the following period', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
await match(now - 2 * day, now - 2 * day, []);
const boundary = Date.UTC(2026, 9, 10, 11);
await match(boundary, boundary, ['boundary']);
const result = readMetrics(1, now);
expect(result.points.find((point) => point.at === boundary - hour)).toMatchObject({
uniquePlayers: 0,
newPlayers: 0
});
expect(result.points.find((point) => point.at === boundary)).toMatchObject({
uniquePlayers: 1,
newPlayers: 1
});
expect(result.points.find((point) => point.at === boundary + hour)).toMatchObject({
uniquePlayers: 0,
newPlayers: 0
});
});
it('deduplicates identity across games and scopes new players to their original match', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
await match(now - 2 * day, now - 2 * day, ['regular']);
await match(
now - 4 * hour,
now - 4 * hour,
['both-games', 'regular'],
't6',
'Town Survival'
);
await match(now - 3 * hour, now - 3 * hour, ['both-games', 'regular', 'moon-new']);
await match(
now - 2 * hour,
now - 2 * hour,
['moon-new', 'kino-new'],
't5',
'Kino Der Toten'
);
await match(now - hour, now - hour, ['town-new'], 't6', 'Town Survival');
await match(now - hour, now - hour, ['grief-new'], 't6', 'Town Grief');
const read = (game: string | null, map: string | null = null) =>
readMetrics({ days: 1, game, map, timeZone: 'UTC' }, now);
expect(read(null).players).toMatchObject({ uniquePlayers: 6, newPlayers: 5 });
expect(read('t5').players).toMatchObject({ uniquePlayers: 4, newPlayers: 2 });
expect(read('t5', 'Moon').players).toMatchObject({ uniquePlayers: 3, newPlayers: 1 });
expect(read('t5', 'Kino Der Toten').players).toMatchObject({
uniquePlayers: 2,
newPlayers: 1
});
expect(read('t6', 'Town Survival').players).toMatchObject({
uniquePlayers: 3,
newPlayers: 2
});
expect(read('t6', 'Town Grief').players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 });
expect(read('t5', 'Missing').players).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
expect(read('t5', 'Missing').points.every((point) => point.uniquePlayers === 0)).toBe(true);
});
it('leaves periods before recording as gaps and marks the first retained period partial', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const start = now - 2 * hour;
await match(start, start + 10_000, ['first']);
const result = readMetrics(1, now);
expect(result.players).toEqual({ historyStartAt: start, uniquePlayers: 1, newPlayers: 1 });
expect(
result.points
.filter((point) => point.endAt <= start)
.every((point) => point.uniquePlayers === null && point.newPlayers === null)
).toBe(true);
expect(
result.points.find((point) => point.at === Math.floor(start / hour) * hour)
).toMatchObject({
uniquePlayers: 1,
newPlayers: 1,
partial: false,
playerHistoryPartial: true
});
expect(result.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
});
it('moves the history bound after retention without making returning players new', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const firstMatch = await match(now - 40 * day, now - 40 * day, ['regular']);
await match(now - day, now - day + hour, ['regular'], 't6', 'Town Survival');
db().prepare('DELETE FROM leaderboard_matches WHERE id=?').run(firstMatch);
const result = readMetrics(
{ days: 30, game: 't6', map: 'Town Survival', timeZone: 'UTC' },
now
);
expect(result.players).toEqual({
historyStartAt: now - day,
uniquePlayers: 1,
newPlayers: 0
});
expect(result.points[0].uniquePlayers).toBeNull();
expect(db().prepare('SELECT COUNT(*) AS n FROM leaderboard_players').get()).toEqual({
n: 2
});
});
it('keeps no history unavailable, including when only durable player identities remain', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const assertMissing = () => {
const result = readMetrics(1, now);
expect(result.players).toEqual({
historyStartAt: null,
uniquePlayers: null,
newPlayers: null
});
expect(
result.points.every(
(point) => point.uniquePlayers === null && point.newPlayers === null
)
).toBe(true);
};
assertMissing();
await match(now - day, now - day, ['old']);
db().prepare('DELETE FROM leaderboard_matches').run();
await match(now + hour, now + hour, ['future']);
assertMissing();
});
});
+12
View File
@@ -18,6 +18,7 @@ const report = (days: number) => ({
summary: totals,
running: { average: null, peak: 0, capturedHours: 0 },
matches: emptyMatches,
players: { uniquePlayers: null, newPlayers: null, historyStartAt: null },
autoscaling: {
added: 0,
removed: 0,
@@ -286,11 +287,22 @@ describe('metrics background refresh', () => {
startAt: 0,
endAt: 1000,
partial: true,
uniquePlayers: 0,
newPlayers: 0,
playerHistoryPartial: false,
capturedHours: 1 / 3600
}
]
};
const invalid = [
{ ...value, players: undefined },
{ ...value, players: { uniquePlayers: 1, newPlayers: 0 } },
...[undefined, -1, 1.5, '2'].map((newPlayers) => ({
...value,
players: { ...value.players, newPlayers }
})),
{ ...value, points: [{ ...value.points[0], uniquePlayers: undefined }] },
{ ...value, points: [{ ...value.points[0], playerHistoryPartial: undefined }] },
{ ...value, maps: [{ ...value.maps[0], running: undefined }] },
{
...value,
+26
View File
@@ -99,6 +99,32 @@ it('refreshes cached match metrics after active matches and recorded participant
expect((await report()).scopeOptions).toEqual({ games: [], maps: [] });
});
it('refreshes player trends when participation crosses a period and first-seen identity changes', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
const now = Date.UTC(2026, 9, 10, 12, 30);
vi.spyOn(Date, 'now').mockReturnValue(now);
const query = parseMetricsQuery(new URLSearchParams('days=1'));
db().exec(`INSERT INTO leaderboard_matches
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
VALUES(1,'removed','t5','moon','moon','Moon','2026-10-10T11:30:00.000Z','2026-10-10T12:30:00.000Z','heartbeat','test');
INSERT INTO leaderboard_match_players(match_id,guid,first_seen_at,last_seen_at)
VALUES(1,'player','2026-10-10T11:30:00.000Z','2026-10-10T11:59:00.000Z');
INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
VALUES('t5','player','Player','2026-10-10T11:30:00.000Z','2026-10-10T11:59:00.000Z');`);
const first = await metricsReport(query);
expect(first.players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 });
expect(first.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 });
db().exec("UPDATE leaderboard_match_players SET last_seen_at='2026-10-10T12:15:00.000Z'");
expect((await metricsReport(query)).points.at(-1)).toMatchObject({
uniquePlayers: 1,
newPlayers: 0
});
db().exec(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at)
VALUES('t6','player','Player','2026-10-01T11:30:00.000Z','2026-10-01T11:30:00.000Z')`);
expect((await metricsReport(query)).players).toMatchObject({ uniquePlayers: 1, newPlayers: 0 });
});
it('refreshes policy signals after policy edits and global pauses without new samples', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
+71 -1
View File
@@ -7,6 +7,7 @@ import MetricsAutoscaling from '../../src/lib/ui/components/metrics/MetricsAutos
import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte';
import MetricsExplore from '../../src/lib/ui/components/metrics/MetricsExplore.svelte';
import MetricsChart from '../../src/lib/ui/components/metrics/MetricsChart.svelte';
import MetricsPlayers from '../../src/lib/ui/components/metrics/MetricsPlayers.svelte';
import { metricPage } from '../../src/lib/ui/metrics.js';
import { page } from '$app/state';
@@ -66,6 +67,7 @@ function report(): MetricsData {
scopeFirstSampleAt: now - 14 * 86400000,
scopeLatestSampleAt: now,
matches: matches(),
players: { uniquePlayers: 104, newPlayers: 70, historyStartAt: now - 14 * 86400000 },
autoscaling: {
added: 73,
removed: 48,
@@ -86,6 +88,9 @@ function report(): MetricsData {
startAt: now - 86400000,
endAt: now,
partial: true,
uniquePlayers: 28,
newPlayers: 5,
playerHistoryPartial: false,
capturedHours: 1
}
],
@@ -500,6 +505,68 @@ it('shows a global pause once while keeping Kino’s shortage ahead of quiet map
expect(body).toContain('Consider raising the policy maximum');
});
it('renders player totals separately from periods with accessible selection, gaps and partial history', () => {
const data = report();
const original = data.points[0];
data.points = [
{
...original,
at: original.at - 86400000,
startAt: original.startAt - 86400000,
endAt: original.startAt,
partial: false,
uniquePlayers: null,
newPlayers: null
},
{ ...original, partial: false, playerHistoryPartial: true }
];
data.players.historyStartAt = original.startAt + 3600000;
const body = render(MetricsPlayers, {
props: {
points: data.points,
players: data.players,
days: data.days,
since: data.since,
timeZone: data.scope.timeZone
}
}).body;
expect(body).toContain('Players over time');
expect(body).toContain('>104</dd>');
expect(body).toContain('>70</dd>');
expect(body).toContain('28 unique players · 5 new');
expect(body).toContain('Retained player history starts');
expect(body.replace(/\s+/g, ' ')).toContain('Earlier periods are gaps');
expect(body).toContain('Range totals count each player once');
expect(body).toContain('aria-label="Selected player period summary"');
expect(body).toContain('aria-label="Player time data" tabindex="0"');
expect(body).toContain('role="status"');
expect(body).toContain('View exact time data');
expect(body).toContain('No retained history');
const periods = body.match(/<button[^>]*class="period\b[\s\S]*?<\/button>/g) ?? [];
expect(periods).toHaveLength(2);
expect(periods[0]).not.toContain('class="dot');
expect(periods[1]).toContain('partial');
expect(periods[1]).toContain('tabindex="0"');
expect(body).not.toMatch(/<details[^>]*open/);
});
it('shows player trends without occupancy and shows unavailable counts when no match history remains', () => {
const data = report();
data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
let body = render(MetricsPage, {
props: { data: { ...data, user: null, setupRequired: false } }
}).body;
expect(body).toContain('Players over time');
expect(body).toContain('>104</dd>');
data.players = { historyStartAt: null, uniquePlayers: null, newPlayers: null };
data.points = data.points.map((point) => ({ ...point, uniquePlayers: null, newPlayers: null }));
body = render(MetricsPage, {
props: { data: { ...data, user: null, setupRequired: false } }
}).body;
expect(body).toContain('No retained player history yet');
expect(body).toContain('>—</dd>');
});
it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => {
const data = report();
data.points.push({
@@ -512,7 +579,10 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
idle: 0,
unknown: 0,
playerHours: 0,
capturedHours: 0
capturedHours: 0,
uniquePlayers: null,
newPlayers: null,
playerHistoryPartial: false
});
const body = render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
+8 -1
View File
@@ -6,10 +6,17 @@ import {
metricLobbyAxis,
metricNumber,
metricPercent,
metricPeriodLabels
metricPeriodLabels,
metricPlayerPaths
} from '../../src/lib/ui/metrics.js';
describe('metric presentation', () => {
it('breaks player lines at missing history while retaining real zeroes and isolated points', () => {
const paths = metricPlayerPaths([null, 2, 0, null, 1], 2);
expect(paths.line).toBe('M30,0 L50,100 M90,50');
expect(paths.area).toBe('M30,0 L50,100 L50,100 L30,100 Z M90,50 L90,100 L90,100 Z');
expect(metricPlayerPaths([null, null], 1)).toEqual({ line: '', area: '' });
});
it('normalizes partial buckets by captured wall time and preserves unknown lobby counts', () => {
expect(metricAverageLobbies(14 * 24, 24)).toBe(14);
expect(metricAverageLobbies(14 * 0.5, 0.5)).toBe(14);