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
+42
View File
@@ -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
+15 -1
View File
@@ -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[];
+6
View File
@@ -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
}
];
+87
View File
@@ -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<string>(), fresh: new Set<string>() };
});
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<string>();
const fresh = new Set<string>();
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 };
}
+6
View File
@@ -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,
+21
View File
@@ -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'])
@@ -0,0 +1,614 @@
<script lang="ts">
import type { MetricPoint, MetricsDays, PlayerMetrics } from '$lib/metrics.js';
import { createTimeFormatter } from '$lib/time-zone.js';
import {
metricLobbyAxis,
metricNumber,
metricPeriodLabels,
metricPlayerPaths
} from '$ui/metrics.js';
import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
let {
points,
players,
days,
since,
timeZone
}: {
points: MetricPoint[];
players: PlayerMetrics;
days: MetricsDays;
since: number;
timeZone: string;
} = $props();
let selectedAt = $state<number | null>(null);
let announcement = $state('');
const format = $derived(createTimeFormatter(timeZone));
const selectedIndex = $derived.by(() => {
const existing = points.findIndex((point) => point.at === selectedAt);
if (existing >= 0) return existing;
for (let index = points.length - 1; index >= 0; index--)
if (!partial(points[index]) && points[index].uniquePlayers !== null) return index;
return Math.max(0, points.length - 1);
});
const selected = $derived(points[selectedIndex]);
const axis = $derived(
metricLobbyAxis(
Math.max(
0,
...points.flatMap((point) => [point.uniquePlayers ?? 0, point.newPlayers ?? 0])
)
)
);
const uniquePath = $derived(
metricPlayerPaths(
points.map((point) => point.uniquePlayers),
axis.ceiling
)
);
const newPath = $derived(
metricPlayerPaths(
points.map((point) => point.newPlayers),
axis.ceiling
)
);
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
const periodOptions = $derived(
points.map((point) => ({
value: String(point.at),
label: `${format.period(point.startAt)}${partial(point) ? ' · partial' : ''}${point.uniquePlayers === null ? ' · no retained history' : ''}`
}))
);
const limitedHistory = $derived(
players.historyStartAt !== null && players.historyStartAt > since
);
function partial(point: MetricPoint) {
return point.partial || point.playerHistoryPartial;
}
function count(value: number | null) {
return value === null ? '—' : metricNumber(value);
}
function summary(point: MetricPoint) {
return point.uniquePlayers === null
? 'No retained player history'
: `${count(point.uniquePlayers)} unique players · ${count(point.newPlayers)} new`;
}
function selectPeriod(at: number) {
selectedAt = at;
const point = points.find((item) => item.at === at);
if (point)
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${partial(point) ? ', partial period' : ''}. ${summary(point)}`;
}
function move(direction: number) {
const point = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
if (point) selectPeriod(point.at);
}
function chartKey(event: KeyboardEvent, index: number) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? points.length - 1
: Math.max(
0,
Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1))
);
selectPeriod(points[next].at);
(event.currentTarget as HTMLElement).parentElement
?.querySelectorAll<HTMLButtonElement>('button')
[next]?.focus();
}
</script>
<section class="panel" aria-labelledby="metrics-players-title">
<div class="heading">
<div>
<h2 id="metrics-players-title">Players over time</h2>
<p>
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.
</p>
</div>
<div class="totals">
<dl aria-label="Players across the selected range">
<div>
<dt>Unique players</dt>
<dd>{count(players.uniquePlayers)}</dd>
</div>
<div>
<dt>New players</dt>
<dd>{count(players.newPlayers)}</dd>
</div>
</dl>
<p>
Last {days === 1 ? '24 hours' : `${days} days`}{limitedHistory
? ' · Partial history'
: ''}
</p>
</div>
</div>
<div class="legend">
<span><i class="unique-key"></i>Unique players</span>
<span><i class="new-key"></i>New players</span>
<span><i class="partial-key"></i>Partial period</span>
</div>
{#if players.historyStartAt === null}
<p class="history-note">
No retained player history yet. Counts will appear after match participation is
recorded.
</p>
{:else if limitedHistory}
<p class="history-note">
Retained player history starts {format.period(players.historyStartAt)}. Earlier periods
are gaps; range totals cover available history only.
</p>
{/if}
<div class="axis-title">Players</div>
<div class="chart-grid">
<div class="y-axis" aria-hidden="true">
{#each axis.ticks as value}<span style:top={`${(1 - value / axis.ceiling) * 100}%`}
>{metricNumber(value)}</span
>{/each}
</div>
<div
class="plot"
role="group"
aria-label="Players by period. Select a point or use the period selector below."
>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{#each axis.ticks as value}<line
x1="0"
x2="100"
y1={100 - (value / axis.ceiling) * 100}
y2={100 - (value / axis.ceiling) * 100}
class="grid-line"
/>{/each}
<path d={uniquePath.area} class="area" />
<path d={uniquePath.line} class="unique-line" />
<path d={newPath.line} class="new-line" />
</svg>
{#each points as point, index (point.at)}
<button
type="button"
class="period"
class:selected={point.at === selected?.at}
class:partial={partial(point)}
tabindex={index === selectedIndex ? 0 : -1}
aria-pressed={point.at === selected?.at}
aria-label={`${format.period(point.startAt)}${partial(point) ? ', partial period' : ''}: ${summary(point)}`}
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}: ${summary(point)}`}
onclick={() => selectPeriod(point.at)}
onkeydown={(event) => chartKey(event, index)}
>
{#if point.uniquePlayers !== null}<span
class="dot unique-dot"
style:top={`${100 - (point.uniquePlayers / axis.ceiling) * 100}%`}
></span>{/if}
{#if point.newPlayers !== null}<span
class="dot new-dot"
style:top={`${100 - (point.newPlayers / axis.ceiling) * 100}%`}
></span>{/if}
</button>
{/each}
</div>
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">
{#each periodLabels as item}<span
style:left={`${item.position}%`}
title={format.period(points[item.index].startAt)}
>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span
>{/each}
</div>
</div>
<p class="period-note">
{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.
</p>
{#if selected}
<div class="selection-summary" aria-label="Selected player period summary">
<time
>{format.period(selected.startAt)}{partial(selected)
? ' · Partial period'
: ''}</time
><span>{summary(selected)}</span>
</div>
<details>
<summary>Inspect a period</summary>
<div class="period-controls">
<div class="period-select">
<Field label={`Inspect a player period · ${timeZone.replaceAll('_', ' ')}`}
><Select
value={String(selected.at)}
options={periodOptions}
onchange={(event) =>
selectPeriod(
Number((event.currentTarget as HTMLSelectElement).value)
)}
/></Field
>
</div>
<div class="step-buttons">
<Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}
>← Previous</Button
>
<Button
size="sm"
disabled={selectedIndex === points.length - 1}
onclick={() => move(1)}>Next →</Button
>
</div>
</div>
<p class="readout" aria-label="Selected player period details">
{format.period(selected.startAt)} – {format.period(selected.endAt)}{partial(
selected
)
? ' · Partial period'
: ''}<br />{summary(selected)}{selected.playerHistoryPartial
? ' · Retained history begins during this period.'
: ''}
</p>
</details>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
<details>
<summary>View exact time data</summary>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Player time data" tabindex="0">
<table>
<caption
>Players by period in {timeZone.replaceAll('_', ' ')}. A dash means no retained
history.</caption
>
<thead
><tr
><th scope="col">Period</th><th scope="col">Unique players</th><th
scope="col">New players</th
></tr
></thead
>
<tbody
>{#each points as point (point.at)}<tr
><th scope="row"
>{format.period(point.startAt)}<small
>to {format.period(point.endAt)}{partial(point)
? ' · Partial'
: ''}{point.uniquePlayers === null
? ' · No retained history'
: point.playerHistoryPartial
? ' · History begins'
: ''}</small
></th
><td>{count(point.uniquePlayers)}</td><td>{count(point.newPlayers)}</td
></tr
>{/each}</tbody
>
</table>
</div>
</details>
<p class="method-note">
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.
</p>
</section>
<style>
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-5);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
.heading {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--space-4);
}
.heading > div:first-child {
flex: 1 1 280px;
min-width: 0;
}
.heading p {
margin: var(--space-2) 0 var(--space-4);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.totals dl {
display: flex;
gap: var(--space-5);
margin: 0;
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
text-transform: uppercase;
}
dd {
margin: var(--space-1) 0 0;
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
font-variant-numeric: tabular-nums;
}
.totals p {
font-size: var(--text-xs);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
font-size: var(--text-xs);
}
.legend span {
display: flex;
align-items: center;
gap: 6px;
}
.legend i {
display: block;
width: 16px;
}
.unique-key {
border-top: 2px solid var(--chart-active);
}
.new-key {
border-top: 2px dashed var(--text-secondary);
}
.legend .partial-key {
width: 8px;
height: 8px;
border: 1px solid var(--text-secondary);
border-radius: 50%;
}
.axis-title {
margin-top: var(--space-5);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.chart-grid {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
column-gap: 8px;
margin-top: 12px;
}
.y-axis {
position: relative;
height: 210px;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.y-axis span {
position: absolute;
right: 0;
transform: translateY(-50%);
}
.plot {
position: relative;
display: flex;
height: 210px;
}
svg {
position: absolute;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
svg path,
svg line {
vector-effect: non-scaling-stroke;
}
.grid-line {
stroke: var(--border-subtle);
stroke-width: 1;
}
.area {
fill: var(--chart-active);
opacity: 0.12;
}
.unique-line,
.new-line {
fill: none;
stroke-width: 2;
}
.unique-line {
stroke: var(--chart-active);
}
.new-line {
stroke: var(--text-secondary);
stroke-dasharray: 5 4;
}
.period {
position: relative;
flex: 1;
min-width: 0;
padding: 0;
margin: 0;
border: 0;
border-radius: 2px;
background: transparent;
cursor: pointer;
}
.period.selected {
background: color-mix(in srgb, var(--text-secondary) 6%, transparent);
}
.period:hover {
background: color-mix(in srgb, var(--chart-active) 10%, transparent);
}
.period:focus-visible {
outline: 2px solid var(--accent-primary-hover);
outline-offset: 3px;
}
.dot {
position: absolute;
left: 50%;
width: 7px;
height: 7px;
transform: translate(-50%, -50%);
border: 1px solid;
border-radius: 50%;
pointer-events: none;
}
.unique-dot {
background: var(--chart-active);
border-color: var(--chart-active);
}
.new-dot {
background: var(--text-secondary);
border-color: var(--text-secondary);
}
.partial .dot {
background: var(--surface);
}
.x-axis {
grid-column: 2;
position: relative;
min-width: 0;
height: 1.6em;
margin-top: 10px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.x-axis.with-dates {
height: 3em;
}
.x-axis > span {
position: absolute;
transform: translateX(-50%);
text-align: center;
white-space: nowrap;
}
small {
display: block;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: normal;
line-height: 1.5;
}
.period-note,
.method-note,
.history-note {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: var(--leading-body);
margin: var(--space-3) 0 0;
}
.history-note {
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.selection-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-top: var(--space-4);
font-size: var(--text-xs);
color: var(--text-secondary);
}
details {
margin-top: var(--space-4);
font-size: var(--text-sm);
}
summary {
cursor: pointer;
color: var(--text-secondary);
padding: 4px 0;
}
.period-controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--space-3);
margin-top: var(--space-4);
}
.period-select {
flex: 1 1 250px;
max-width: 450px;
}
.step-buttons {
display: flex;
gap: var(--space-2);
padding-bottom: 3px;
}
.readout {
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.table-wrap {
max-width: 100%;
overflow-x: auto;
margin-top: var(--space-3);
}
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
caption {
text-align: left;
color: var(--text-tertiary);
margin-bottom: 8px;
}
th,
td {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
th:first-child {
text-align: left;
}
thead th {
color: var(--text-secondary);
}
tbody th {
font-weight: var(--weight-regular);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.panel {
padding: var(--space-4);
}
.legend {
gap: 10px;
}
.chart-grid {
grid-template-columns: 32px minmax(0, 1fr);
column-gap: 6px;
}
}
</style>
+10
View File
@@ -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'
)
)
+26
View File
@@ -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 }[],
+8
View File
@@ -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 @@
</p>{/if}
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
{/if}
<MetricsPlayers
points={report.points}
players={report.players}
days={report.days}
since={report.since}
timeZone={report.scope.timeZone}
/>
{#if captured > 0}
<MetricsHeatmap
heatmap={report.heatmap}
+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);