Show match demand in metrics (Refs #138)
CI / checks (push) Successful in 4m44s

This commit is contained in:
2026-10-10 16:26:32 -04:00
parent ea932ec563
commit 616b1937ed
15 changed files with 775 additions and 42 deletions
+110 -23
View File
@@ -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<string, ServerMetrics>();
const mapTotals = new Map<string, Map<string, MapMetrics>>();
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,