diff --git a/docs/guides/metrics.md b/docs/guides/metrics.md
index 89735a9..908c8be 100644
--- a/docs/guides/metrics.md
+++ b/docs/guides/metrics.md
@@ -83,6 +83,40 @@ statistics do not establish lobby-hours, availability, or an autoscaling shortag
Match statistics cover the selected period only and have no previous-period
comparison.
+## Players over time
+
+The player chart uses the lobby chart's periods: hourly for 24 hours, and UTC-aligned
+24-hour periods for 7 or 30 days. Labels use the report timezone. **Unique players**
+counts distinct player GUIDs in every period overlapped by their recorded match
+participation, from first to last sighting. Multiple matches in one period count
+once, and the same GUID across Black Ops I and II is one player. Matches that began
+before the range still contribute participation inside it. These are recorded
+participants, not concurrent player counts or the lobby chart's player-hours.
+
+**New players** counts a GUID at its earliest recorded sighting across both games.
+Game and Map filters limit unique players to matching retained matches; new players
+count only when their original sighting was in a matching match. A regular's first
+visit to another map or game does not make them new again. Period boundaries belong
+to the following period; a sighting exactly at the range's end counts in the final
+period. Range totals count distinct GUIDs across the entire window independently:
+daily unique counts cannot be added to get the range total.
+
+Periods before the earliest retained match are gaps, not zero players. This history
+boundary is fleet-wide, so filtering to a quiet map does not hide recorded zeroes.
+The chart notes limited history and marks periods clipped by the selected range or
+the retained-history boundary with hollow points. Totals cover available history
+only. Select a point, use Left/Right or Home/End, or open **Inspect a period** for
+the exact interval and counts. **View exact time data** provides the same values
+in a keyboard-accessible table, with missing history shown as a dash.
+
+History begins when leaderboard recording started; earlier play cannot be recovered.
+Players already playing then count as new when first recorded. Participation spans
+do not prove continuous presence between reports, and unrecorded matches are absent.
+Match retention removes participation, which can shorten or leave holes in this
+history. The durable first-seen player identities survive retention, so removing an
+old match cannot make a returning player new again. The original map cannot be
+recovered from an expired match. The chart remains available without occupancy samples.
+
## Timeline and tables
The timeline defaults to **Average lobbies running**, split into **With players**,
@@ -217,6 +251,14 @@ retains the recorded number of starts. Match scope uses the exact saved `game` a
labels, and its time window uses the saved `started_at` timestamp. Occupancy
collection and evidence timestamps remain independent of match history.
+`players` contains the separately deduplicated range totals `uniquePlayers` and
+`newPlayers`, plus `historyStartAt` (the earliest retained match, in epoch
+milliseconds). Every `points` entry also has `uniquePlayers`, `newPlayers`, and
+`playerHistoryPartial`. Counts before retained history are `null`; known periods
+with no scoped participation are zero. Player charts treat either `partial` or
+`playerHistoryPartial` as partial. Participation timestamp changes and durable
+first-seen identity changes invalidate the report cache.
+
Map rows expose `noEmpty`, `availabilityObserved`, and
`availabilityUnknown` durations, plus current `policy` context and
`noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines
diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts
index 3f1e713..2bf00ee 100644
--- a/src/lib/metrics.ts
+++ b/src/lib/metrics.ts
@@ -92,7 +92,18 @@ export interface RunningMetrics {
capturedHours: number;
}
-export interface MetricPoint extends Utilization {
+export interface PlayerCounts {
+ /** Distinct GUIDs, across games; null means this interval predates retained history. */
+ uniquePlayers: number | null;
+ newPlayers: number | null;
+}
+
+export interface PlayerMetrics extends PlayerCounts {
+ /** Global earliest retained match, independent of the selected game/map. */
+ historyStartAt: number | null;
+}
+
+export interface MetricPoint extends Utilization, PlayerCounts {
/** UTC bucket boundary, including when the selected interval starts later. */
at: number;
startAt: number;
@@ -100,6 +111,8 @@ export interface MetricPoint extends Utilization {
partial: boolean;
/** Captured scope wall time, excluding collection gaps. */
capturedHours: number;
+ /** Retained player history begins inside this period. */
+ playerHistoryPartial: boolean;
}
export interface PreviousMetrics {
@@ -164,6 +177,7 @@ export interface MetricsData {
/** Distinct recorded names sampled in this scope/window, including zero elapsed time. */
sampledServerCount: number;
matches: MatchMetrics;
+ players: PlayerMetrics;
autoscaling: AutoscalingMetrics;
maps: MapMetrics[];
points: MetricPoint[];
diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts
index dc3bf14..52bb46a 100644
--- a/src/lib/server/db/migrations.ts
+++ b/src/lib/server/db/migrations.ts
@@ -7,6 +7,7 @@ import { migrateFileBlobs } from './file-blobs.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import {
installMetricsMatchRevisions,
+ installMetricsPlayerRevisions,
installMetricsPolicyRevisions,
installMetricsProvisioningRevisions,
installReportRevisions
@@ -85,6 +86,11 @@ export const migrations: readonly Migration[] = [
version: 15,
name: 'metrics provisioning report revisions',
run: installMetricsProvisioningRevisions
+ },
+ {
+ version: 16,
+ name: 'metrics player history and report revisions',
+ run: installMetricsPlayerRevisions
}
];
diff --git a/src/lib/server/fleet/metrics-players.ts b/src/lib/server/fleet/metrics-players.ts
new file mode 100644
index 0000000..cd5c0a3
--- /dev/null
+++ b/src/lib/server/fleet/metrics-players.ts
@@ -0,0 +1,87 @@
+import { db } from '../db/index.js';
+import type { MetricPoint, MetricsScope, PlayerMetrics } from '../../metrics.js';
+
+/** Populate the shared periods from retained participation, independently of occupancy samples. */
+export function readPlayerMetrics(
+ scope: MetricsScope,
+ since: number,
+ now: number,
+ points: MetricPoint[]
+): PlayerMetrics {
+ const handle = db();
+ const nowIso = new Date(now).toISOString();
+ const sinceIso = new Date(since).toISOString();
+ const { first } = handle
+ .prepare('SELECT MIN(started_at) AS first FROM leaderboard_matches WHERE started_at <= ?')
+ .get(nowIso) as { first: string | null };
+ const historyStartAt = first === null ? null : Date.parse(first);
+ const periods = points.map((point) => {
+ const available =
+ historyStartAt !== null &&
+ (historyStartAt < point.endAt || (historyStartAt === now && point.endAt === now));
+ point.uniquePlayers = available ? 0 : null;
+ point.newPlayers = available ? 0 : null;
+ point.playerHistoryPartial = available && historyStartAt! > point.startAt;
+ return { unique: new Set(), fresh: new Set() };
+ });
+ if (historyStartAt === null) return { historyStartAt, uniquePlayers: null, newPlayers: null };
+
+ const conditions = [
+ 'm.last_received_at >= ?',
+ 'm.started_at <= ?',
+ 'p.last_seen_at >= ?',
+ 'p.first_seen_at <= ?'
+ ];
+ const params = [sinceIso, nowIso, sinceIso, nowIso];
+ if (scope.game !== null) {
+ conditions.push('m.game = ?');
+ params.push(scope.game);
+ }
+ if (scope.map !== null) {
+ conditions.push('m.map_name = ?');
+ params.push(scope.map);
+ }
+ // The match clock and participation PK bound this scan to recent matches.
+ // The GUID/first-seen index looks up the durable cross-game identity without
+ // reclassifying a returning player after their older matches expire.
+ const rows = handle
+ .prepare(
+ `SELECT p.guid, p.first_seen_at AS start, p.last_seen_at AS end,
+ (SELECT MIN(first_seen_at) FROM leaderboard_players WHERE guid=p.guid) AS firstSeen
+ FROM leaderboard_matches m JOIN leaderboard_match_players p ON p.match_id=m.id
+ WHERE ${conditions.join(' AND ')}`
+ )
+ .iterate(...params) as Iterable<{
+ guid: string;
+ start: string;
+ end: string;
+ firstSeen: string | null;
+ }>;
+ const unique = new Set();
+ const fresh = new Set();
+ for (const row of rows) {
+ const start = Date.parse(row.start);
+ const end = Date.parse(row.end);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) continue;
+ unique.add(row.guid);
+ // Scope the original sighting, never the first later visit to this game/map.
+ const isNew =
+ row.firstSeen !== null && Date.parse(row.firstSeen) === start && start >= since;
+ if (isNew) fresh.add(row.guid);
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ // Internal boundaries belong to the next period. Include a sighting
+ // exactly at the report's inclusive right edge in its final period.
+ const startsBeforeEnd = start < point.endAt || (start === now && point.endAt === now);
+ if (point.uniquePlayers === null || end < point.startAt || !startsBeforeEnd) continue;
+ periods[index].unique.add(row.guid);
+ if (isNew && start >= point.startAt) periods[index].fresh.add(row.guid);
+ }
+ }
+ for (let index = 0; index < points.length; index++) {
+ if (points[index].uniquePlayers === null) continue;
+ points[index].uniquePlayers = periods[index].unique.size;
+ points[index].newPlayers = periods[index].fresh.size;
+ }
+ return { historyStartAt, uniquePlayers: unique.size, newPlayers: fresh.size };
+}
diff --git a/src/lib/server/fleet/metrics.ts b/src/lib/server/fleet/metrics.ts
index 54bd3fe..41743d0 100644
--- a/src/lib/server/fleet/metrics.ts
+++ b/src/lib/server/fleet/metrics.ts
@@ -1,5 +1,6 @@
import { fleetSettings } from '../settings.js';
import { db } from '../db/index.js';
+import { readPlayerMetrics } from './metrics-players.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { zombieMaps } from '../../map-aliases.js';
import { readAutoscalingMetrics } from './metrics-autoscaling.js';
@@ -314,9 +315,13 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
endAt: end,
partial: start !== at || end !== at + bucketMs,
capturedHours: 0,
+ uniquePlayers: null,
+ newPlayers: null,
+ playerHistoryPartial: false,
...emptyUtilization()
});
}
+ const players = readPlayerMetrics(scope, since, now, points);
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
weekday: Math.floor(index / 24),
@@ -517,6 +522,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now())
summary,
running,
matches: matchMetrics(totalMatches),
+ players,
autoscaling: autoscaling.summary,
sampledServerCount: sampledServers.size,
maps,
diff --git a/src/lib/server/reports/revisions.ts b/src/lib/server/reports/revisions.ts
index 661772a..18c0b97 100644
--- a/src/lib/server/reports/revisions.ts
+++ b/src/lib/server/reports/revisions.ts
@@ -1,5 +1,26 @@
import type Database from 'better-sqlite3';
+/** Player trends also change when a participation interval extends into another period. */
+export function installMetricsPlayerRevisions(handle: Database.Database) {
+ handle.exec(`CREATE INDEX IF NOT EXISTS idx_leaderboard_players_guid_first_seen
+ ON leaderboard_players(guid,first_seen_at);
+ DROP TRIGGER IF EXISTS report_metrics_leaderboard_match_players_UPDATE;
+ CREATE TRIGGER report_metrics_leaderboard_match_players_UPDATE
+ AFTER UPDATE ON leaderboard_match_players
+ WHEN OLD.match_id IS NOT NEW.match_id OR OLD.guid IS NOT NEW.guid
+ OR OLD.first_seen_at IS NOT NEW.first_seen_at OR OLD.last_seen_at IS NOT NEW.last_seen_at
+ BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
+ for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
+ const condition =
+ action === 'UPDATE'
+ ? 'WHEN OLD.guid IS NOT NEW.guid OR OLD.first_seen_at IS NOT NEW.first_seen_at'
+ : '';
+ handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_leaderboard_players_${action}
+ AFTER ${action} ON leaderboard_players ${condition}
+ BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
+ }
+}
+
/** Provisioning outcomes remain report inputs even after the policy is removed. */
export function installMetricsProvisioningRevisions(handle: Database.Database) {
for (const table of ['server_deployments', 'provisioning_sources'])
diff --git a/src/lib/ui/components/metrics/MetricsPlayers.svelte b/src/lib/ui/components/metrics/MetricsPlayers.svelte
new file mode 100644
index 0000000..ef89fa9
--- /dev/null
+++ b/src/lib/ui/components/metrics/MetricsPlayers.svelte
@@ -0,0 +1,614 @@
+
+
+
+
+
+
Players over time
+
+ Distinct players seen in each {days === 1 ? 'hour' : '24-hour period'}, and players
+ seen for the first time. A returning player counts once per period.
+
+ No retained player history yet. Counts will appear after match participation is
+ recorded.
+
+ {:else if limitedHistory}
+
+ Retained player history starts {format.period(players.historyStartAt)}. Earlier periods
+ are gaps; range totals cover available history only.
+
+ {/if}
+
Players
+
+
+ {#each axis.ticks as value}{metricNumber(value)}{/each}
+
+
+
+ {#each points as point, index (point.at)}
+
+ {/each}
+
+
+ {#each periodLabels as item}{item.label}{#if item.date}{item.date}{/if}{/each}
+
+
+
+ {days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll(
+ '_',
+ ' '
+ )}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'} Partial periods contain less history.
+
+ Range totals count each player once, so period counts do not add up to the unique total.
+ “New” means first recorded across both games; players already playing when recording began
+ also count as new. Missing or expired matches cannot be reconstructed.
+
+
+
+
diff --git a/src/lib/ui/metrics-refresh.ts b/src/lib/ui/metrics-refresh.ts
index 105c5e2..46b9b78 100644
--- a/src/lib/ui/metrics-refresh.ts
+++ b/src/lib/ui/metrics-refresh.ts
@@ -32,6 +32,11 @@ const autoscaling = (value: unknown) =>
['readinessMeanSeconds', 'readinessMaxSeconds'].every(
(key) => value[key] === null || nonnegative(value[key])
);
+const players = (value: unknown) =>
+ record(value) &&
+ ['uniquePlayers', 'newPlayers'].every(
+ (key) => value[key] === null || (nonnegative(value[key]) && Number.isInteger(value[key]))
+ );
const policy = (value: unknown) =>
value === null ||
(record(value) &&
@@ -51,6 +56,9 @@ function validReport(value: unknown): value is MetricsData {
!totals(value.summary) ||
!running(value.running) ||
!matches(value.matches) ||
+ !record(value.players) ||
+ !players(value.players) ||
+ !timestamp(value.players.historyStartAt) ||
!autoscaling(value.autoscaling)
)
return false;
@@ -95,6 +103,8 @@ function validReport(value: unknown): value is MetricsData {
numeric(item.startAt) &&
numeric(item.endAt) &&
nonnegative(item.capturedHours) &&
+ players(item) &&
+ typeof item.playerHistoryPartial === 'boolean' &&
typeof item.partial === 'boolean'
)
)
diff --git a/src/lib/ui/metrics.ts b/src/lib/ui/metrics.ts
index 0ee6da3..ad794a3 100644
--- a/src/lib/ui/metrics.ts
+++ b/src/lib/ui/metrics.ts
@@ -1,5 +1,31 @@
const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
+/** Separate SVG paths at unavailable periods, including isolated recorded points. */
+export function metricPlayerPaths(values: readonly (number | null)[], ceiling: number) {
+ const segments: { x: number; y: number }[][] = [];
+ let segment: { x: number; y: number }[] = [];
+ for (let index = 0; index < values.length; index++) {
+ const value = values[index];
+ if (value === null) {
+ segment = [];
+ continue;
+ }
+ if (!segment.length) segments.push(segment);
+ segment.push({
+ x: ((index + 0.5) / values.length) * 100,
+ y: 100 - (value / ceiling) * 100
+ });
+ }
+ const line = (points: typeof segment) =>
+ points.map(({ x, y }, index) => `${index ? 'L' : 'M'}${x},${y}`).join(' ');
+ return {
+ line: segments.map(line).join(' '),
+ area: segments
+ .map((points) => `${line(points)} L${points.at(-1)!.x},100 L${points[0].x},100 Z`)
+ .join(' ')
+ };
+}
+
/** Label the actual bucket start in the chosen timezone; never imply local calendar-day buckets. */
export function metricPeriodLabels(
points: readonly { startAt: number }[],
diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte
index 1c8fe64..9e14975 100644
--- a/src/routes/metrics/+page.svelte
+++ b/src/routes/metrics/+page.svelte
@@ -19,6 +19,7 @@
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
+ import MetricsPlayers from '$ui/components/metrics/MetricsPlayers.svelte';
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
import Freshness from '$ui/components/Freshness.svelte';
@@ -304,6 +305,13 @@
{/if}
{/if}
+
{#if captured > 0}
{
+ 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();
diff --git a/tests/unit/metrics-players.test.ts b/tests/unit/metrics-players.test.ts
new file mode 100644
index 0000000..800ecc8
--- /dev/null
+++ b/tests/unit/metrics-players.test.ts
@@ -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([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();
+ });
+});
diff --git a/tests/unit/metrics-refresh.test.ts b/tests/unit/metrics-refresh.test.ts
index 12b7d00..62d27f5 100644
--- a/tests/unit/metrics-refresh.test.ts
+++ b/tests/unit/metrics-refresh.test.ts
@@ -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,
diff --git a/tests/unit/report-workers.test.ts b/tests/unit/report-workers.test.ts
index f2c9c9b..ffa620a 100644
--- a/tests/unit/report-workers.test.ts
+++ b/tests/unit/report-workers.test.ts
@@ -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');
diff --git a/tests/unit/ui-metrics-page.test.ts b/tests/unit/ui-metrics-page.test.ts
index 06f1a75..bf2a952 100644
--- a/tests/unit/ui-metrics-page.test.ts
+++ b/tests/unit/ui-metrics-page.test.ts
@@ -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');
+ expect(body).toContain('>70');
+ 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(/