This commit is contained in:
+110
-23
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user