From 616b1937edaab482cfadc973f6f484d5d0b60e3a Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 10 Oct 2026 16:26:32 -0400 Subject: [PATCH] Show match demand in metrics (Refs #138) --- docs/guides/metrics.md | 56 ++++- src/lib/metrics.ts | 14 ++ src/lib/server/db/migrations.ts | 11 +- src/lib/server/fleet/metrics.ts | 133 +++++++++--- src/lib/server/reports/revisions.ts | 17 ++ .../components/metrics/MetricsSummary.svelte | 32 ++- .../components/metrics/MetricsTables.svelte | 43 +++- src/lib/ui/metrics-refresh.ts | 11 +- src/routes/metrics/+page.svelte | 19 +- tests/unit/database-migrations.test.ts | 38 ++++ tests/unit/metrics-insights.test.ts | 7 + tests/unit/metrics-refresh.test.ts | 63 ++++++ tests/unit/metrics.test.ts | 193 ++++++++++++++++++ tests/unit/report-workers.test.ts | 64 ++++++ tests/unit/ui-metrics-page.test.ts | 116 ++++++++++- 15 files changed, 775 insertions(+), 42 deletions(-) diff --git a/docs/guides/metrics.md b/docs/guides/metrics.md index 0698dc2..a8a7611 100644 --- a/docs/guides/metrics.md +++ b/docs/guides/metrics.md @@ -26,8 +26,37 @@ history or the original installation date. Background refresh keeps the last successful report visible on failure and offers Retry. Returning to a visible tab refreshes immediately. Game and map filters apply to the whole report, including comparisons, signals, and the heatmap. Map choices -preserve the exact recorded game/map pair. Range and scope are shareable through -the URL; refreshes preserve table controls and chart selection. +preserve the exact recorded game/map pair and include retained occupancy and match +history. Range and scope are shareable through the URL; refreshes preserve table +controls and chart selection. + +## Matches and groups + +**Matches started** counts retained matches whose recorded start falls within the +selected period, including both endpoints. A missing start event uses the first +recorded match observation, which may be later than the actual start. Active, +completed, and interrupted matches all count, including matches that never reach +round 2 and matches on removed servers. These counts do not use the leaderboard's +completed-match or round qualification rules. + +**Average group size** is the average number of distinct recorded player GUIDs per +match. A player counts once across all reports for that match, including late +joins and players who left before its end. Each match has equal weight in the +fleet average; the average is not an average of map averages. This measures +recorded participation over the match's lifetime, not its starting squad or peak +simultaneous occupancy. Active matches can change as more players are recorded. + +**Solo games** counts matches with exactly one distinct recorded player. Its share +uses only matches with at least one recorded player. Matches with no recorded +players still count as started, but their group size is unknown and they are +excluded from both the average and solo-share denominator. If no matches have +recorded players, those two values are unavailable rather than zero. + +The map table includes these same match statistics. A map with match history in +the selected period remains visible even without occupancy samples; its match +statistics do not establish lobby-hours, availability, or an autoscaling shortage. +Match statistics cover the selected period only and have no previous-period +comparison. ## Timeline and tables @@ -125,11 +154,18 @@ Map variants use the configured variant when it matches the reported map. Player capacity may be retained in raw samples, but no metric or signal requires `sv_maxclients` or an assumed four-player limit. -Collection begins after this feature is installed; earlier occupancy cannot be +Occupancy collection begins after this feature is installed; earlier occupancy cannot be reconstructed. Samples default to 31 days of retention; **Admin → Settings** can change this period. Cleanup runs during collection and in the background. Samples survive removal of a server and contain no player identities. +Match statistics use existing saved match history, independently of occupancy +samples and raw telemetry diagnostics. **Match history retention** under +**Admin → Settings → Matches & players** defaults to indefinite. Expiring a +completed or interrupted match also removes its player participation and changes +these statistics; active matches are protected. Increasing retention cannot +restore expired matches. Saved match history survives removal of a server. + ## API `GET /api/metrics` supplies the same report as the page and disables HTTP response @@ -140,9 +176,19 @@ from selected-scope evidence timestamps. Collection boundaries are determined before scope filtering, so samples from other groups still end a batch interval. `running` contains `average`, `peak`, and `capturedHours`; timeline points include -their own `capturedHours`. Map rows expose `noEmpty`, `availabilityObserved`, and +their own `capturedHours`. Report-level `matches` and each map row's `matches` +contain `started`, `withRecordedPlayers`, `averageGroupSize`, `solo`, and +`soloShare`. `withRecordedPlayers` is the denominator for the group average and +solo share; `soloShare` is a percentage from 0 to 100. When that denominator is +zero, `averageGroupSize` and `soloShare` are `null`, `solo` is zero, and `started` +retains the recorded number of starts. Match scope uses the exact saved `game` and `map_name` +labels, and its time window uses the saved `started_at` timestamp. Occupancy +collection and evidence timestamps remain independent of match history. + +Map rows expose `noEmpty`, `availabilityObserved`, and `availabilityUnknown` durations, plus current `policy` context and `noEmptyAtMaximum` / `noEmptyBelowMaximum` durations. `pooledAvailability` combines map durations and coverage. `autoscalingPaused` reports the current global pause separately from each policy's own `pausedReason`. Policy edits and global pause changes invalidate -cached metrics reports. +cached metrics reports, as do changes to the match and player participation data +used by the report. diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index d7f7301..de99f43 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -56,9 +56,22 @@ export interface ServerMetrics extends Utilization { currentRegistered: boolean; } +export interface MatchMetrics { + /** Retained matches whose recorded start is within the selected window. */ + started: number; + /** Matches with at least one distinct recorded participant. */ + withRecordedPlayers: number; + /** Distinct participants over each match's lifetime, not simultaneous players. */ + averageGroupSize: number | null; + solo: number; + /** Percentage of matches with recorded participants, from 0 to 100. */ + soloShare: number | null; +} + export interface MapMetrics extends Utilization { game: string; map: string; + matches: MatchMetrics; /** Map wall time, not summed lobby time. Capacity does not affect availability. */ noEmpty: number; availabilityObserved: number; @@ -139,6 +152,7 @@ export interface MetricsData { summary: Utilization; running: RunningMetrics; servers: ServerMetrics[]; + matches: MatchMetrics; maps: MapMetrics[]; points: MetricPoint[]; previous: PreviousMetrics; diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index bd4bfaa..46562ee 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -5,7 +5,11 @@ import { ensureAutoscalingSchema } from '../autoscaling/schema.js'; import { ensureRebalancingSchema } from '../rebalancing/schema.js'; import { migrateFileBlobs } from './file-blobs.js'; import { installPerformanceIndexes } from './performance-indexes.js'; -import { installMetricsPolicyRevisions, installReportRevisions } from '../reports/revisions.js'; +import { + installMetricsMatchRevisions, + installMetricsPolicyRevisions, + installReportRevisions +} from '../reports/revisions.js'; import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'; import { migrateHostSchema } from '../hosts/schema.js'; import { migrateAuthSchema } from '../auth/schema.js'; @@ -70,6 +74,11 @@ export const migrations: readonly Migration[] = [ version: 13, name: 'metrics policy report revisions', run: installMetricsPolicyRevisions + }, + { + version: 14, + name: 'metrics match report revisions', + run: installMetricsMatchRevisions } ]; diff --git a/src/lib/server/fleet/metrics.ts b/src/lib/server/fleet/metrics.ts index 7ec1f22..7d20b7d 100644 --- a/src/lib/server/fleet/metrics.ts +++ b/src/lib/server/fleet/metrics.ts @@ -6,6 +6,7 @@ import { parseMetricsQuery, type HeatmapCell, type MapMetrics, + type MatchMetrics, type MetricPoint, type MetricsData, type MetricsDays, @@ -47,6 +48,27 @@ function emptyRunning(): RunningMetrics { return { average: null, peak: 0, capturedHours: 0 }; } +interface MatchTotals { + started: number; + withRecordedPlayers: number; + participants: number; + solo: number; +} + +function matchMetrics(totals: MatchTotals): MatchMetrics { + return { + started: totals.started, + withRecordedPlayers: totals.withRecordedPlayers, + averageGroupSize: + totals.withRecordedPlayers > 0 + ? totals.participants / totals.withRecordedPlayers + : null, + solo: totals.solo, + soloShare: + totals.withRecordedPlayers > 0 ? (100 * totals.solo) / totals.withRecordedPlayers : null + }; +} + function addRunning(total: RunningMetrics, count: number, hours: number): void { if (hours <= 0) return; const lobbyHours = (total.average ?? 0) * total.capturedHours + count * hours; @@ -183,9 +205,25 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) scopeFirstSampleAt: scopedBounds.first, scopeLatestSampleAt: scopedBounds.latest }; + // Match history has independent retention and may outlive occupancy samples. + // Preserve exact recorded labels, including distinct T6 variants. + const matchMaps = handle + .prepare( + `SELECT DISTINCT game, map_name AS map FROM leaderboard_matches WHERE started_at <= ?` + ) + .all(new Date(now).toISOString()) as { game: string; map: string }[]; + const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + const availableMaps = [ + ...new Map( + [...groups, ...matchMaps].map(({ game, map }) => [ + JSON.stringify([game, map]), + { game, map } + ]) + ).values() + ].sort((a, b) => compareText(a.game, b.game) || compareText(a.map, b.map)); const scopeOptions = { - games: [...new Set(groups.map((group) => group.game))], - maps: groups.map(({ game, map }) => ({ game, map })) + games: [...new Set(availableMaps.map((group) => group.game))], + maps: availableMaps }; const summary = emptyUtilization(); const running = emptyRunning(); @@ -200,6 +238,74 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) }; const serverTotals = new Map(); const mapTotals = new Map>(); + function mapFor(gameName: string, label: string): MapMetrics { + let game = mapTotals.get(gameName); + if (!game) { + game = new Map(); + mapTotals.set(gameName, game); + } + let map = game.get(label); + if (!map) { + map = { + game: gameName, + map: label, + matches: matchMetrics({ + started: 0, + withRecordedPlayers: 0, + participants: 0, + solo: 0 + }), + noEmpty: 0, + availabilityObserved: 0, + availabilityUnknown: 0, + noEmptyAtMaximum: 0, + noEmptyBelowMaximum: 0, + policy: policyFor(gameName, label), + ...emptyUtilization() + }; + game.set(label, map); + } + return map; + } + const matchConditions = ['m.started_at >= ?', 'm.started_at <= ?']; + const matchParams = [new Date(since).toISOString(), new Date(now).toISOString()]; + if (scope.game !== null) { + matchConditions.push('m.game = ?'); + matchParams.push(scope.game); + } + if (scope.map !== null) { + matchConditions.push('m.map_name = ?'); + matchParams.push(scope.map); + } + // Group participants per match before aggregating maps, so roster size never + // multiplies the start count. The participant PK deduplicates returning players. + const matchGroups = handle + .prepare( + `SELECT game, map, COUNT(*) AS started, + SUM(players > 0) AS withRecordedPlayers, + SUM(players) AS participants, SUM(players = 1) AS solo + FROM ( + SELECT m.game, m.map_name AS map, COUNT(p.guid) AS players + FROM leaderboard_matches m + LEFT JOIN leaderboard_match_players p ON p.match_id = m.id + WHERE ${matchConditions.join(' AND ')} + GROUP BY m.id + ) GROUP BY game, map` + ) + .all(...matchParams) as (MatchTotals & { game: string; map: string })[]; + const totalMatches: MatchTotals = { + started: 0, + withRecordedPlayers: 0, + participants: 0, + solo: 0 + }; + for (const group of matchGroups) { + mapFor(group.game, group.map).matches = matchMetrics(group); + totalMatches.started += group.started; + totalMatches.withRecordedPlayers += group.withRecordedPlayers; + totalMatches.participants += group.participants; + totalMatches.solo += group.solo; + } const points: MetricPoint[] = []; for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) { const end = Math.min(at + bucketMs, now); @@ -327,26 +433,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) }; serverTotals.set(sample.server, server); } - let game = mapTotals.get(sample.game); - if (!game) { - game = new Map(); - mapTotals.set(sample.game, game); - } - let map = game.get(sample.map); - if (!map) { - map = { - game: sample.game, - map: sample.map, - noEmpty: 0, - availabilityObserved: 0, - availabilityUnknown: 0, - noEmptyAtMaximum: 0, - noEmptyBelowMaximum: 0, - policy: policyFor(sample.game, sample.map), - ...emptyUtilization() - }; - game.set(sample.map, map); - } + const map = mapFor(sample.game, sample.map); addObservation(summary, sample.players, hours); addObservation(server, sample.players, hours); addObservation(map, sample.players, hours); @@ -404,7 +491,6 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) } aggregateBatch(now); - const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); const servers = [...serverTotals.values()].sort( (a, b) => b.active - a.active || compareText(a.server, b.server) ); @@ -442,6 +528,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) scopeOptions, summary, running, + matches: matchMetrics(totalMatches), servers, maps, points, diff --git a/src/lib/server/reports/revisions.ts b/src/lib/server/reports/revisions.ts index 5839c69..b26aa70 100644 --- a/src/lib/server/reports/revisions.ts +++ b/src/lib/server/reports/revisions.ts @@ -1,5 +1,22 @@ import type Database from 'better-sqlite3'; +/** Match demand includes active and interrupted matches, independent of leaderboard eligibility. */ +export function installMetricsMatchRevisions(handle: Database.Database) { + for (const table of ['leaderboard_matches', 'leaderboard_match_players']) + for (const action of ['INSERT', 'UPDATE', 'DELETE']) { + const fields = + table === 'leaderboard_matches' ? ['game', 'map_name', 'started_at'] : ['match_id']; + // Score/presence heartbeats do not change distinct participant counts. + const condition = + action === 'UPDATE' + ? `WHEN ${fields.map((field) => `OLD.${field} IS NOT NEW.${field}`).join(' OR ')}` + : ''; + handle.exec(`CREATE TRIGGER IF NOT EXISTS report_metrics_${table}_${action} + AFTER ${action} ON ${table} ${condition} + BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`); + } +} + /** Current policy settings are inputs to historical metrics advice. */ export function installMetricsPolicyRevisions(handle: Database.Database) { for (const table of ['autoscaling_policies', 'settings']) diff --git a/src/lib/ui/components/metrics/MetricsSummary.svelte b/src/lib/ui/components/metrics/MetricsSummary.svelte index 91c6738..5ff138b 100644 --- a/src/lib/ui/components/metrics/MetricsSummary.svelte +++ b/src/lib/ui/components/metrics/MetricsSummary.svelte @@ -63,6 +63,31 @@

Players × observed time. One player for one hour equals one player-hour.

+ +

+ {report.matches.averageGroupSize === null + ? 'Average group unavailable' + : `Average group ${metricNumber(report.matches.averageGroupSize)} ${plural(report.matches.averageGroupSize, 'player')}`} +

+ {report.matches.soloShare === null + ? 'Solo share unavailable · no recorded players' + : `${metricNumber(report.matches.solo)} solo · ${metricPercent(report.matches.soloShare, 100)} of matches with recorded players`} + {#if report.matches.withRecordedPlayers < report.matches.started}Players recorded for {metricNumber(report.matches.withRecordedPlayers)} of {countLabel( + report.matches.started, + 'match', + 'matches' + )}{/if} +
.summary { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; } p { @@ -117,6 +142,11 @@ margin: 0; font-size: var(--text-xs); } + @media (max-width: 1100px) { + .summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } @media (max-width: 760px) { .summary { grid-template-columns: 1fr; diff --git a/src/lib/ui/components/metrics/MetricsTables.svelte b/src/lib/ui/components/metrics/MetricsTables.svelte index 77cd716..011cf24 100644 --- a/src/lib/ui/components/metrics/MetricsTables.svelte +++ b/src/lib/ui/components/metrics/MetricsTables.svelte @@ -23,6 +23,7 @@ }: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props(); const mapColumns = [ { key: 'map', label: 'Map' }, + { key: 'matches', label: 'Matches' }, { key: 'playerHours', label: 'Player-hours' }, { key: 'active', label: 'With players' }, { key: 'noEmpty', label: 'No empty lobby' }, @@ -103,12 +104,14 @@ const mapPage = $derived(metricPage(mapRows, params.get('mapPage'))); const serverPage = $derived(metricPage(serverRows, params.get('serverPage'))); const currentPage = $derived(view === 'maps' ? mapPage : serverPage); + const currentQuery = $derived(view === 'maps' ? mapQuery : serverQuery); const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage'); function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); } function mapValue(map: MapMetrics, key: Exclude): number | null { if (key === 'details') return null; + if (key === 'matches') return map.matches.started; if (key === 'noEmpty') return map.availabilityObserved ? map.noEmpty / map.availabilityObserved : null; return (key === 'playerHours' || key === 'active') && !map.observed ? null : map[key]; @@ -233,6 +236,13 @@ : ''} + {metricNumber(map.matches.started)}{map.matches.averageGroupSize === null + ? 'Average group unavailable' + : `avg group ${metricNumber(map.matches.averageGroupSize)}`} {map.observed ? metricNumber(map.playerHours) : '—'} {:else}{#snippet actions()}{/snippet}{#snippet actions()}{#if currentQuery.trim()}{/if}{/snippet}{/if} {/if}

{scopeLabel} · {format.period(report.since)} – {format.period(report.now)}

- {#if captured > 0}{/if} + {#if captured === 0} changeScope({ timeZone: value })} /> + {/if} + {#if captured > 0 || report.matches.started > 0} +

+ Matches started counts retained matches whose start time falls within this report’s + window, including both endpoints and all match statuses. If a match did not report a + start time, its first observation is used. Average group is the mean number of distinct + recorded players across each match’s lifetime, including later arrivals. Solo means + exactly one recorded player. Both average group and solo share exclude matches with no + recorded players; those matches still count as started. Ongoing matches can change as + players join. Match records are independent of sampled lobby occupancy, and missing or + purged records are not estimated. +

No empty lobby measures map time when at least one lobby has known occupancy and no tracked lobby reports zero players. If every lobby is unknown, map availability is @@ -389,8 +401,9 @@

Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection - gaps are excluded. History is retained for 31 days. Player-hours include all players reported - by the server and are estimates, not individual session records. + gaps are excluded. Occupancy history defaults to 31 days; saved match retention is configured + separately. Player-hours include all players reported by the server and are estimates, not + individual session records.

Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll( diff --git a/tests/unit/database-migrations.test.ts b/tests/unit/database-migrations.test.ts index 800cec4..4d9bdf2 100644 --- a/tests/unit/database-migrations.test.ts +++ b/tests/unit/database-migrations.test.ts @@ -167,3 +167,41 @@ it('adds policy report invalidation when upgrading a database at version 12', as upgraded.prepare("DELETE FROM settings WHERE key='autoscaling_paused'").run(); expect(revision()).toBeGreaterThan(paused); }); + +it('adds match report invalidation on upgrade without invalidating for score heartbeats', async () => { + const { db, closeDb } = await import('../../src/lib/server/db/index.js'); + const handle = db(); + for (const table of ['leaderboard_matches', 'leaderboard_match_players']) + for (const action of ['INSERT', 'UPDATE', 'DELETE']) + handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`); + handle.pragma('user_version = 13'); + closeDb(); + const upgraded = db(); + const revision = () => + ( + upgraded + .prepare("SELECT revision FROM report_revisions WHERE topic='metrics'") + .get() as { revision: number } + ).revision; + const before = 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','zombie_moon','t5:zombie_moon','Moon', + '2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','start');`); + expect(revision()).toBeGreaterThan(before); + const started = revision(); + upgraded.exec(`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');`); + expect(revision()).toBeGreaterThan(started); + const joined = revision(); + upgraded.exec(`UPDATE leaderboard_matches SET highest_round=2,last_event='heartbeat', + last_received_at='2026-10-10T12:00:30.000Z' WHERE id=1; + UPDATE leaderboard_match_players SET kills=10,present=0 WHERE match_id=1;`); + expect(revision()).toBe(joined); + upgraded.exec("UPDATE leaderboard_matches SET map_name='Custom Moon' WHERE id=1"); + expect(revision()).toBeGreaterThan(joined); + const renamed = revision(); + upgraded.exec('DELETE FROM leaderboard_matches WHERE id=1'); + expect(revision()).toBeGreaterThan(renamed); + expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]); +}); diff --git a/tests/unit/metrics-insights.test.ts b/tests/unit/metrics-insights.test.ts index b524962..5595cb5 100644 --- a/tests/unit/metrics-insights.test.ts +++ b/tests/unit/metrics-insights.test.ts @@ -13,6 +13,13 @@ const policy = { const map = (values: Partial = {}): MapMetrics => ({ game: 't5', map: 'Moon', + matches: { + started: 0, + withRecordedPlayers: 0, + averageGroupSize: null, + solo: 0, + soloShare: null + }, observed: 10, active: 5, idle: 5, diff --git a/tests/unit/metrics-refresh.test.ts b/tests/unit/metrics-refresh.test.ts index 3cc1182..76fca78 100644 --- a/tests/unit/metrics-refresh.test.ts +++ b/tests/unit/metrics-refresh.test.ts @@ -3,12 +3,20 @@ import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js'; afterEach(() => vi.useRealTimers()); const totals = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }; +const emptyMatches = { + started: 0, + withRecordedPlayers: 0, + averageGroupSize: null, + solo: 0, + soloShare: null +}; const report = (days: number) => ({ days, now: 1000, since: 0, summary: totals, running: { average: null, peak: 0, capturedHours: 0 }, + matches: emptyMatches, points: [], maps: [], servers: [], @@ -134,6 +142,60 @@ describe('metrics background refresh', () => { expect(refresh.onLoading).toHaveBeenLastCalledWith(false); }); + it('preserves successful match reports when fleet or map match data is malformed', async () => { + const matches = { + started: 3, + withRecordedPlayers: 2, + averageGroupSize: 1.5, + solo: 1, + soloShare: 50 + }; + const map = { + ...totals, + game: 't5', + map: 'Moon', + matches, + noEmpty: 0, + availabilityObserved: 0, + availabilityUnknown: 0, + noEmptyAtMaximum: 0, + noEmptyBelowMaximum: 0, + policy: null + }; + const value = { ...report(7), matches, maps: [map] }; + const invalidMatches = [ + undefined, + null, + { ...matches, withRecordedPlayers: undefined }, + { ...matches, started: -1 }, + { ...matches, solo: 0.5 }, + { ...matches, averageGroupSize: '1.5' }, + { ...matches, soloShare: 101 } + ]; + const invalid = invalidMatches.flatMap((incomplete) => [ + { ...value, matches: incomplete }, + { ...value, maps: [{ ...map, matches: incomplete }] } + ]); + const fetcher = vi.fn().mockResolvedValueOnce(Response.json(value)); + for (const incomplete of invalid) fetcher.mockResolvedValueOnce(Response.json(incomplete)); + const empty = { ...report(7), maps: [{ ...map, matches: emptyMatches }] }; + fetcher.mockResolvedValueOnce(Response.json(empty)); + const refresh = setup(fetcher); + await refresh.refresh(); + expect(refresh.onData).toHaveBeenLastCalledWith(value); + for (const _ of invalid) { + await refresh.refresh(); + expect(refresh.onData).toHaveBeenCalledTimes(1); + expect(refresh.onError).toHaveBeenLastCalledWith( + 'The metrics response was incomplete.' + ); + } + await refresh.refresh(); + expect(refresh.onData).toHaveBeenLastCalledWith(empty); + expect(refresh.onData).toHaveBeenCalledTimes(2); + expect(refresh.onError).toHaveBeenLastCalledWith(null); + }); + it('accepts availability reports and rejects incomplete running or policy evidence', async () => { const value = { ...report(7), @@ -142,6 +204,7 @@ describe('metrics background refresh', () => { ...totals, game: 't5', map: 'Moon', + matches: emptyMatches, noEmpty: 0, availabilityObserved: 0, availabilityUnknown: 1, diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts index 7cff950..055e102 100644 --- a/tests/unit/metrics.test.ts +++ b/tests/unit/metrics.test.ts @@ -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'); diff --git a/tests/unit/report-workers.test.ts b/tests/unit/report-workers.test.ts index 9ca925f..3c238f2 100644 --- a/tests/unit/report-workers.test.ts +++ b/tests/unit/report-workers.test.ts @@ -35,6 +35,70 @@ it('runs reports off the request thread and invalidates cached history after wri expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3); }); +it('refreshes cached match metrics after active matches and recorded participants change without new samples', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); + const { metricsReport } = await import('../../src/lib/server/reports/index.js'); + const now = Date.UTC(2026, 9, 4, 12); + vi.spyOn(Date, 'now').mockReturnValue(now); + const at = new Date(now - 30_000).toISOString(); + const query = parseMetricsQuery(new URLSearchParams('days=1')); + const report = async () => { + const result = await metricsReport(query); + expect(result).toEqual(readMetrics(query, now)); + return result; + }; + expect((await report()).matches.started).toBe(0); + const match = db().prepare(`INSERT INTO leaderboard_matches + (id, server, game, map, map_key, map_name, started_at, last_received_at, + status, last_event, last_fingerprint) + VALUES (?, 'removed-server', 't5', 'zombie_moon', 't5:moon', 'Moon', ?, ?, 'active', 'game_start', 'test')`); + match.run(1, at, at); + expect((await report()).matches).toEqual({ + started: 1, + withRecordedPlayers: 0, + averageGroupSize: null, + solo: 0, + soloShare: null + }); + const player = db().prepare(`INSERT INTO leaderboard_match_players + (match_id, guid, first_seen_at, last_seen_at) VALUES (1, ?, ?, ?)`); + player.run('one', at, at); + expect((await report()).matches).toMatchObject({ averageGroupSize: 1, soloShare: 100 }); + player.run('two', at, at); + expect((await report()).matches).toMatchObject({ averageGroupSize: 2, soloShare: 0 }); + match.run(2, at, at); + expect((await report()).matches).toMatchObject({ started: 2, withRecordedPlayers: 1 }); + db().prepare("UPDATE leaderboard_match_players SET match_id=2 WHERE guid='two'").run(); + expect((await report()).matches).toMatchObject({ + withRecordedPlayers: 2, + averageGroupSize: 1, + solo: 2, + soloShare: 100 + }); + db().prepare("DELETE FROM leaderboard_match_players WHERE guid='one'").run(); + expect((await report()).matches).toMatchObject({ withRecordedPlayers: 1, solo: 1 }); + db().prepare('DELETE FROM leaderboard_matches WHERE id=2').run(); + expect((await report()).matches).toMatchObject({ + started: 1, + withRecordedPlayers: 0, + averageGroupSize: null + }); + db().prepare("UPDATE leaderboard_matches SET map_name='Town Survival', game='t6'").run(); + expect((await report()).maps.map((map) => [map.game, map.map])).toEqual([ + ['t6', 'Town Survival'] + ]); + db() + .prepare('UPDATE leaderboard_matches SET started_at=?') + .run(new Date(now - 2 * 86400_000).toISOString()); + const outsideWindow = await report(); + expect(outsideWindow.matches.started).toBe(0); + expect(outsideWindow.maps).toEqual([]); + expect(outsideWindow.scopeOptions.maps).toEqual([{ game: 't6', map: 'Town Survival' }]); + db().prepare('DELETE FROM leaderboard_matches').run(); + expect((await report()).scopeOptions).toEqual({ games: [], maps: [] }); +}); + 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 803fde0..8af98ca 100644 --- a/tests/unit/ui-metrics-page.test.ts +++ b/tests/unit/ui-metrics-page.test.ts @@ -1,6 +1,11 @@ import { beforeEach, expect, it, vi } from 'vitest'; import { render } from 'svelte/server'; -import type { MetricsData, MapMetrics, ServerMetrics } from '../../src/lib/metrics.js'; +import type { + MetricsData, + MapMetrics, + ServerMetrics, + MatchMetrics +} from '../../src/lib/metrics.js'; import MetricsPage from '../../src/routes/metrics/+page.svelte'; import MetricsSummary from '../../src/lib/ui/components/metrics/MetricsSummary.svelte'; import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte'; @@ -16,9 +21,24 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn(), replaceState: vi.fn() })); +const matches = (changes: Partial = {}): MatchMetrics => ({ + started: 271, + withRecordedPlayers: 271, + averageGroupSize: 2.4, + solo: 48, + soloShare: (100 * 48) / 271, + ...changes +}); const map = (i: number, changes: Partial = {}): MapMetrics => ({ game: 't5', map: `Map ${String(i).padStart(2, '0')}`, + matches: matches({ + started: i, + withRecordedPlayers: i, + averageGroupSize: 2, + solo: 0, + soloShare: 0 + }), observed: 10, active: 5, idle: 5, @@ -56,6 +76,7 @@ function report(): MetricsData { scopeOptions: { games: ['t5'], maps: [] }, scopeFirstSampleAt: now - 14 * 86400000, scopeLatestSampleAt: now, + matches: matches(), summary, running: { average: 12, peak: 24, capturedHours: 1 }, maps: Array.from({ length: 24 }, (_, i) => map(i + 1)), @@ -181,6 +202,99 @@ it('searches and sorts the complete map set before pagination and clamps invalid expect(metricPage([], '20')).toMatchObject({ page: 1, start: 0, end: 0, total: 0 }); }); +it('shows match counts, average group and solo share with explicit missing roster coverage', () => { + const data = report(); + let body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' '); + expect(body).toContain('Matches started'); + expect(body).toContain('>271<'); + expect(body).toContain('Average group 2.4 players'); + expect(body).toContain('48 solo · 17.7% of matches with recorded players'); + + data.matches = matches({ started: 300 }); + body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' '); + expect(body).toContain('48 solo · 17.7% of matches with recorded players'); + expect(body).toContain('Players recorded for 271 of 300 matches'); + + data.matches = matches({ + started: 3, + withRecordedPlayers: 0, + averageGroupSize: null, + solo: 0, + soloShare: null + }); + body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' '); + expect(body).toContain('>3<'); + expect(body).toContain('Average group unavailable'); + expect(body).toContain('Solo share unavailable'); + expect(body).toContain('Players recorded for 0 of 3 matches'); + expect(body).not.toContain('0 solo'); +}); + +it('sorts map match counts numerically, including maps without occupancy or recorded players', () => { + const noRoster = matches({ + started: 100, + withRecordedPlayers: 0, + averageGroupSize: null, + solo: 0, + soloShare: null + }); + const maps = [ + map(1, { matches: noRoster, observed: 0, active: 0, idle: 0, playerHours: 0 }), + map(2, { matches: matches({ started: 2, withRecordedPlayers: 2 }) }), + map(3, { matches: matches({ started: 10, withRecordedPlayers: 10 }) }), + map(4, { matches: { ...noRoster, started: 0 } }) + ]; + scope('?mapSort=matches&mapDirection=asc'); + let body = render(MetricsTables, { props: { maps, servers: [] } }).body; + expect(body.indexOf('Map 04')).toBeLessThan(body.indexOf('Map 02')); + expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03')); + expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01')); + expect(body).toContain('aria-sort="ascending"'); + expect(body).toContain('avg group 2.4'); + expect(body).toContain('Average group unavailable'); + expect(body.replace(/\s+/g, ' ')).toContain('0 of 100'); + + scope('?mapSort=matches&mapDirection=desc'); + body = render(MetricsTables, { props: { maps, servers: [] } }).body; + expect(body.indexOf('Map 01')).toBeLessThan(body.indexOf('Map 03')); + expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 02')); + expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 04')); + expect(body).toContain('aria-sort="descending"'); +}); + +it('keeps match summaries and map rows visible without any retained occupancy samples', () => { + const data = report(); + data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }; + data.running = { average: null, peak: 0, capturedHours: 0 }; + data.latestSampleAt = null; + data.firstRetainedSampleAt = null; + data.scopeFirstSampleAt = null; + data.scopeLatestSampleAt = null; + data.servers = []; + data.maps = [ + map(1, { + ...data.summary, + noEmpty: 0, + availabilityObserved: 0, + matches: data.matches + }) + ]; + const body = render(MetricsPage, { + props: { data: { ...data, user: null, setupRequired: false } } + }).body; + expect(body).toContain('Matches started'); + expect(body).toContain('Average group 2.4 players'); + expect(body).toContain('aria-label="Map metrics table"'); + expect(body).toContain('Map 01'); + expect(body).toContain('No retained observations yet'); + expect(body).not.toContain('Lobbies running over time'); + expect(body.replace(/\s+/g, ' ')).toContain('including both endpoints and all match statuses'); + expect(body.replace(/\s+/g, ' ')).toContain( + 'distinct recorded players across each match’s lifetime' + ); + expect(body.replace(/\s+/g, ' ')).toContain('missing or purged records are not estimated'); +}); + it('keeps unknown availability last when sorting and distinguishes it from known empty lobbies', () => { scope('?mapSort=noEmpty&mapDirection=asc'); const maps = [