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. +

+
+
+
+
+
Unique players
+
{count(players.uniquePlayers)}
+
+
+
New players
+
{count(players.newPlayers)}
+
+
+

+ Last {days === 1 ? '24 hours' : `${days} days`}{limitedHistory + ? ' · Partial history' + : ''} +

+
+
+
+ Unique players + New players + Partial period +
+ {#if players.historyStartAt === null} +

+ 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 points as point, index (point.at)} + + {/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. +

+ {#if selected} +
+ {summary(selected)} +
+
+ Inspect a period +
+
+