From aebfc7a78359da4547a5a85d42350ca0311cde79 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 10 Oct 2026 16:40:07 -0400 Subject: [PATCH] Replace instance metrics with map lobby counts (Refs #139) --- docs/guides/metrics.md | 17 +- src/lib/metrics.ts | 2 + src/lib/server/fleet/metrics.ts | 2 + .../components/metrics/MetricsTables.svelte | 372 ++++++------------ src/lib/ui/metrics-refresh.ts | 1 + src/routes/metrics/+page.svelte | 16 +- tests/unit/metrics-insights.test.ts | 1 + tests/unit/metrics-refresh.test.ts | 15 + tests/unit/metrics.test.ts | 48 +++ tests/unit/ui-metrics-page.test.ts | 82 +++- 10 files changed, 278 insertions(+), 278 deletions(-) diff --git a/docs/guides/metrics.md b/docs/guides/metrics.md index a8a7611..3e955a5 100644 --- a/docs/guides/metrics.md +++ b/docs/guides/metrics.md @@ -73,16 +73,23 @@ the same figures for comparison and assistive technology. Zero captured time is distinct from an observed empty lobby. Short durations use seconds or minutes so a real observation is not rounded to zero. -Map and instance tables support numeric sorting and local search. The map table's +The map table supports numeric sorting and local search. **Lobbies (avg · peak)** +shows the time-weighted average and peak concurrent lobby count for each recorded +game/map pair, with empty lobby-hours beneath. Sort this column by average count. +The average divides all captured lobby-hours, including unknown occupancy, by +that map's captured wall time; collection gaps and periods without recorded lobbies +for that map are excluded. Peak is the largest captured concurrent count, not the +number of distinct lobby names. Empty lobby-hours sum time with zero reported +players across the map's lobbies. Unknown occupancy is not counted as empty; +empty time is unavailable when no occupancy is known. Lobby counts are unavailable +without captured time. Per-lobby match and activity history is available on server pages. + +The map table's **No empty lobby** column uses occupancy alone and retains the known map-time denominator in its details. Unknown map availability stays distinct from zero time without a spare. Evidence can be limited by short observations or unknown occupancy, never by missing player limits. -Instance rows describe history grouped by server name, which can include different -instances if a name was reused. **Open current server** appears only when that name -is currently registered; its destination represents the current registration. - ## Comparisons and activity Previous-period comparisons use the preceding equally long window with identical diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index de99f43..2342e9e 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -72,6 +72,8 @@ export interface MapMetrics extends Utilization { game: string; map: string; matches: MatchMetrics; + /** Concurrent lobbies over this map's captured wall time, including unknown occupancy. */ + running: RunningMetrics; /** Map wall time, not summed lobby time. Capacity does not affect availability. */ noEmpty: number; availabilityObserved: number; diff --git a/src/lib/server/fleet/metrics.ts b/src/lib/server/fleet/metrics.ts index 7d20b7d..6b13f29 100644 --- a/src/lib/server/fleet/metrics.ts +++ b/src/lib/server/fleet/metrics.ts @@ -249,6 +249,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) map = { game: gameName, map: label, + running: emptyRunning(), matches: matchMetrics({ started: 0, withRecordedPlayers: 0, @@ -453,6 +454,7 @@ export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()) }); } for (const [map, state] of availability) { + addRunning(map.running, state.lobbies, hours); if (state.known) { map.availabilityObserved += hours; if (!state.empty) { diff --git a/src/lib/ui/components/metrics/MetricsTables.svelte b/src/lib/ui/components/metrics/MetricsTables.svelte index 011cf24..2cf75ee 100644 --- a/src/lib/ui/components/metrics/MetricsTables.svelte +++ b/src/lib/ui/components/metrics/MetricsTables.svelte @@ -1,7 +1,7 @@ -
+
-

{view === 'maps' ? 'Map play time and lobby availability' : 'Instance utilization'}

+

Map play time and lobby availability

- {#if currentPage.total} + {#if mapPage.total} -
+
{#each view === 'maps' ? mapColumns : serverColumns as column} + > + {#each mapColumns as column} - {/each} - {#if view === 'maps'} - {#each mapPage.rows as map (JSON.stringify([map.game, map.map]))} - + + + + + - - - - - {/each} - {:else} - {#each serverPage.rows as server (server.server)} - + - - {/each} - {/if} + +
Solo matches
+
+ {map.matches.soloShare === null + ? 'Unavailable · no recorded players' + : `${metricNumber(map.matches.solo)} · ${metricPercent(map.matches.soloShare, 100)} of matches with recorded players`} +
+
Captured map time
+
{metricDuration(map.running.capturedHours)}
+
Empty lobby-hours
+
+ {map.observed + ? metricDuration(map.idle) + : 'Unavailable · no known occupancy'} +
+
Observed lobby time
+
{metricDuration(map.observed)}
+
Unknown status
+
{metricDuration(map.unknown)}
+
No empty lobby
+
+ {map.availabilityObserved + ? `${metricDuration(map.noEmpty)} of ${metricDuration(map.availabilityObserved)} known map time` + : 'Lobby availability unavailable'} +
+
Unknown map availability
+
{metricDuration(map.availabilityUnknown)}
+ + + {/each}
{#if column.key === 'details'}Details{:else}{/if} + > + {/if}
{map.map}{gameName(map.game)}{map.observed < 1 || - map.availabilityObserved < 1 || - map.unknown > map.observed / 4 || - map.availabilityUnknown > map.availabilityObserved / 4 - ? ' · Limited evidence' - : ''}{map.map}{gameName(map.game)}{map.observed < 1 || + map.availabilityObserved < 1 || + map.unknown > map.observed / 4 || + map.availabilityUnknown > map.availabilityObserved / 4 + ? ' · Limited evidence' + : ''}{metricNumber(map.matches.started)}{map.matches.averageGroupSize === null + ? 'Average group unavailable' + : `avg group ${metricNumber(map.matches.averageGroupSize)}`}{map.observed ? metricNumber(map.playerHours) : '—'} + {map.observed ? metricDuration(map.active) : '—'} + {map.running.average === null + ? '—' + : `${metricNumber(map.running.average)} · ${metricNumber(map.running.peak)}`} + {map.observed + ? `${metricDuration(map.idle)} empty` + : 'Empty time unavailable'} - {metricNumber(map.matches.started)}{map.matches.averageGroupSize === null - ? 'Average group unavailable' - : `avg group ${metricNumber(map.matches.averageGroupSize)}`}{map.observed ? metricNumber(map.playerHours) : '—'} - {map.observed ? metricDuration(map.active) : '—'}{metricPercent(map.noEmpty, map.availabilityObserved)}
- {map.map} Details -
-
Matches with recorded players
-
- {metricNumber(map.matches.withRecordedPlayers)} of {metricNumber( - map.matches.started - )} -
-
Solo matches
-
- {map.matches.soloShare === null - ? 'Unavailable · no recorded players' - : `${metricNumber(map.matches.solo)} · ${metricPercent(map.matches.soloShare, 100)} of matches with recorded players`} -
-
Observed lobby time
-
{metricDuration(map.observed)}
-
Unknown status
-
{metricDuration(map.unknown)}
-
No empty lobby
-
- {map.availabilityObserved - ? `${metricDuration(map.noEmpty)} of ${metricDuration(map.availabilityObserved)} known map time` - : 'Lobby availability unavailable'} -
-
Unknown map availability
-
{metricDuration(map.availabilityUnknown)}
-
-
{#if server.currentRegistered} + {metricPercent(map.noEmpty, map.availabilityObserved)}
+ {map.map} Details +
+
Matches with recorded players
+
+ {metricNumber(map.matches.withRecordedPlayers)} of {metricNumber( + map.matches.started )} - title="Open current instance with this name" - >{serverTitle(server.server)}{:else}{serverTitle(server.server)}Historical{/if} -
{metricPercent(server.active, server.observed)}{metricDuration(server.observed)}{metricDuration(server.unknown)}
- {serverTitle(server.server)} - Details -
-
Recorded name
-
{server.server}
-
Time with players
-
{metricDuration(server.active)}
-
Empty spare
-
- {metricPercent(server.idle, server.observed)} · {metricDuration( - server.idle - )} -
-
-
{:else}{#snippet actions()}{#if currentQuery.trim()}{/if}{/snippet}{/if} -
@@ -456,6 +349,9 @@ font-size: var(--text-xs); color: var(--text-secondary); } + .lobbies { + white-space: nowrap; + } .comparison { height: 3px; width: 90px; @@ -502,12 +398,6 @@ color: var(--text-primary); overflow-wrap: anywhere; } - .identity-note { - margin: 0; - padding: 0 20px 12px; - color: var(--text-secondary); - font-size: var(--text-xs); - } .table-help { padding: 0 20px 16px; } diff --git a/src/lib/ui/metrics-refresh.ts b/src/lib/ui/metrics-refresh.ts index 17832d3..debcd80 100644 --- a/src/lib/ui/metrics-refresh.ts +++ b/src/lib/ui/metrics-refresh.ts @@ -93,6 +93,7 @@ function validReport(value: unknown): value is MetricsData { record(item) && totals(item) && matches(item.matches) && + running(item.running) && typeof item.game === 'string' && typeof item.map === 'string' && [ diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte index 83175c9..845bd9b 100644 --- a/src/routes/metrics/+page.svelte +++ b/src/routes/metrics/+page.svelte @@ -40,11 +40,7 @@ (page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search ); const view = $derived( - new URLSearchParams(contextSearch).get('view') === 'instances' - ? 'instances' - : new URLSearchParams(contextSearch).get('view') === 'insights' - ? 'insights' - : 'maps' + new URLSearchParams(contextSearch).get('view') === 'insights' ? 'insights' : 'maps' ); function changeView(value: string) { const url = new URL(page.url); @@ -317,7 +313,6 @@ {#if view === 'insights'}{:else}{/if} {/if} @@ -413,11 +406,12 @@ buckets can be partial. Average bars divide occupied, empty spare, and unknown lobby-hours by captured wall time within each bucket, so a partial period is not a lower lobby count merely because it is shorter. Percentage bars use all captured lobby time. - Instance time with players uses known lobby time only.

- Historical server rows are grouped by name and may include multiple registrations using - that name. Map groups preserve the recorded game and map label. + Map groups preserve the recorded game and map label. Each map’s average and peak count + concurrent lobbies during its captured wall time, including unknown occupancy. Empty + lobby-hours sum time with zero reported players across that map’s lobbies. Per-lobby + history is available on server pages.

diff --git a/tests/unit/metrics-insights.test.ts b/tests/unit/metrics-insights.test.ts index 5595cb5..bcfd006 100644 --- a/tests/unit/metrics-insights.test.ts +++ b/tests/unit/metrics-insights.test.ts @@ -13,6 +13,7 @@ const policy = { const map = (values: Partial = {}): MapMetrics => ({ game: 't5', map: 'Moon', + running: { average: 2, peak: 2, capturedHours: 5 }, matches: { started: 0, withRecordedPlayers: 0, diff --git a/tests/unit/metrics-refresh.test.ts b/tests/unit/metrics-refresh.test.ts index 76fca78..3100a4f 100644 --- a/tests/unit/metrics-refresh.test.ts +++ b/tests/unit/metrics-refresh.test.ts @@ -155,6 +155,7 @@ describe('metrics background refresh', () => { game: 't5', map: 'Moon', matches, + running: { average: null, peak: 0, capturedHours: 0 }, noEmpty: 0, availabilityObserved: 0, availabilityUnknown: 0, @@ -205,6 +206,7 @@ describe('metrics background refresh', () => { game: 't5', map: 'Moon', matches: emptyMatches, + running: { average: 1, peak: 1, capturedHours: 1 }, noEmpty: 0, availabilityObserved: 0, availabilityUnknown: 1, @@ -232,6 +234,19 @@ describe('metrics background refresh', () => { ] }; const invalid = [ + { ...value, maps: [{ ...value.maps[0], running: undefined }] }, + { + ...value, + maps: [{ ...value.maps[0], running: { average: -1, peak: 1, capturedHours: 1 } }] + }, + { + ...value, + maps: [{ ...value.maps[0], running: { average: 1, peak: '1', capturedHours: 1 } }] + }, + { + ...value, + maps: [{ ...value.maps[0], running: { average: 1, peak: 1, capturedHours: null } }] + }, { ...value, autoscalingPaused: undefined }, { ...value, running: undefined }, { ...value, previous: { ...value.previous, running: undefined } }, diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts index 055e102..ca6866d 100644 --- a/tests/unit/metrics.test.ts +++ b/tests/unit/metrics.test.ts @@ -77,6 +77,7 @@ describe('historical metrics', () => { }); expect(result.maps).toHaveLength(1); expect(result.maps[0].matches).toEqual(result.matches); + expect(result.maps[0].running).toEqual({ average: null, peak: 0, capturedHours: 0 }); expect(result.registeredServerCount).toBe(0); expect(result.servers).toEqual([]); expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' }); @@ -365,6 +366,8 @@ describe('historical metrics', () => { expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90); expect(later.pooledAvailability.value).toBeNull(); expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 }); + expect(initial.maps[0].running).toEqual(initial.running); + expect(later.maps[0].running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 }); }); it('returns clipped interval boundaries and marks only partial UTC buckets', async () => { @@ -597,6 +600,7 @@ describe('historical metrics', () => { expect(result.running.average).toBeCloseTo(1.6); expect(result.running.peak).toBe(3); expect(result.running.capturedHours * 3600).toBeCloseTo(150); + expect(result.maps[0].running).toEqual(result.running); expect(result.previous.running.average).toBe(4); expect(result.previous.running.peak).toBe(4); expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90); @@ -610,6 +614,50 @@ describe('historical metrics', () => { ); }); + it('counts concurrent lobbies per recorded game/map across reused names, clipped intervals and collection gaps', async () => { + const { recordMetrics, readMetrics } = + await import('../../src/lib/server/fleet/metrics.js'); + const now = Date.UTC(2026, 0, 3, 12); + const since = now - 86400_000; + const one = { server: 'one', game: 't5', map: 'Moon', players: 0, capacity: null }; + const two = { ...one, server: 'two', players: null }; + const otherGame = { ...one, server: 't6-one', game: 't6' }; + recordMetrics([one, two, otherGame], since - 30_000); + recordMetrics([{ ...one, server: 'three', players: 2 }], since + 15_000); + recordMetrics( + [ + otherGame, + { ...otherGame, server: 't6-two' }, + { ...otherGame, server: 't6-three' }, + { ...one, map: 'Kino Der Toten' } + ], + since + 45_000 + ); + // Both lobby names are reused after the map was absent from a collection. + recordMetrics([one, { ...two, players: 0 }], now - 30_000); + // A snapshot with no elapsed time must not inflate the captured peak. + recordMetrics([one, two, { ...one, server: 'three' }], now); + const result = readMetrics(1, now); + const moon = result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!; + expect(moon.running.average).toBeCloseTo(1.6); + expect(moon.running.peak).toBe(2); + expect(moon.running.capturedHours * 3600).toBeCloseTo(75); + expect(moon.idle * 3600).toBeCloseTo(75); + expect(moon.unknown * 3600).toBeCloseTo(15); + expect(moon.observed + moon.unknown).toBeCloseTo( + moon.running.average! * moon.running.capturedHours + ); + const t6 = result.maps.find((map) => map.game === 't6')!; + expect(t6.running.average).toBeCloseTo(285 / 105); + expect(t6.running.peak).toBe(3); + expect(t6.running.capturedHours * 3600).toBeCloseTo(105); + const kino = result.maps.find((map) => map.map === 'Kino Der Toten')!; + expect(kino.running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 }); + const scoped = readMetrics({ days: 1, game: 't5', map: 'Moon', timeZone: 'UTC' }, now); + expect(scoped.maps).toEqual([moon]); + expect(scoped.running).toEqual(moon.running); + }); + it('matches current policy context through map aliases and prefers the durable owner', async () => { const { recordMetrics, readMetrics } = 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 8af98ca..cd6bc1a 100644 --- a/tests/unit/ui-metrics-page.test.ts +++ b/tests/unit/ui-metrics-page.test.ts @@ -30,6 +30,7 @@ const matches = (changes: Partial = {}): MatchMetrics => ({ ...changes }); const map = (i: number, changes: Partial = {}): MapMetrics => ({ + running: { average: 2.5, peak: 3, capturedHours: 4 }, game: 't5', map: `Map ${String(i).padStart(2, '0')}`, matches: matches({ @@ -189,11 +190,11 @@ it('shows concurrent lobbies and empty spare share with unknown lobby-hours in t it('searches and sorts the complete map set before pagination and clamps invalid pages', () => { const data = report(); scope('?mapPage=2'); - let body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body; + let body = render(MetricsTables, { props: { maps: data.maps } }).body; expect(body).toContain('Map 09'); expect(body).not.toContain('Map 24'); scope('?mapPage=2', '?mapQ=Map+24&mapPage=100&mapSort=playerHours'); - body = render(MetricsTables, { props: { maps: data.maps, servers: data.servers } }).body; + body = render(MetricsTables, { props: { maps: data.maps } }).body; expect(body).toContain('Map 24'); expect(body.replace(/\s+/g, ' ')).toContain('1–1 of 1 maps'); expect(body).not.toContain('>Next<'); @@ -245,7 +246,7 @@ it('sorts map match counts numerically, including maps without occupancy or reco map(4, { matches: { ...noRoster, started: 0 } }) ]; scope('?mapSort=matches&mapDirection=asc'); - let body = render(MetricsTables, { props: { maps, servers: [] } }).body; + let body = render(MetricsTables, { props: { maps } }).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')); @@ -255,7 +256,7 @@ it('sorts map match counts numerically, including maps without occupancy or reco expect(body.replace(/\s+/g, ' ')).toContain('0 of 100'); scope('?mapSort=matches&mapDirection=desc'); - body = render(MetricsTables, { props: { maps, servers: [] } }).body; + body = render(MetricsTables, { props: { maps } }).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')); @@ -274,6 +275,7 @@ it('keeps match summaries and map rows visible without any retained occupancy sa data.maps = [ map(1, { ...data.summary, + running: data.running, noEmpty: 0, availabilityObserved: 0, matches: data.matches @@ -302,7 +304,7 @@ it('keeps unknown availability last when sorting and distinguishes it from known map(2, { noEmpty: 0 }), map(3) ]; - const body = render(MetricsTables, { props: { maps, servers: [] } }).body; + const body = render(MetricsTables, { props: { maps } }).body; expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03')); expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01')); expect(body).toContain('0.0%'); @@ -311,18 +313,56 @@ it('keeps unknown availability last when sorting and distinguishes it from known expect(body).not.toContain('All lobbies full'); }); -it('preserves instance return context and distinguishes historic names without current links', () => { - scope('?view=instances&serverQ=theater&days=30'); - const body = render(MetricsTables, { - props: { maps: [], servers: [server(1), server(2)], view: 'instances' } - }).body; - expect(body).toContain('Historical'); - expect(body).not.toContain('href="/servers/t5_zombie_theater_1'); - expect(body).toContain('href="/servers/t5_zombie_theater_2?returnTo='); - expect(body).toContain('serverQ%3Dtheater'); - expect(body).not.toContain('>Idle'); - expect(body).not.toContain('Open current server'); -}); +it.each(['', '?view=instances&serverQ=theater&days=30'])( + 'shows map lobby metrics without an Instances tab for %s', + (search) => { + scope(search); + const body = render(MetricsPage, { + props: { data: { ...report(), user: null, setupRequired: false } } + }).body; + expect(body).toContain('aria-label="Map metrics table"'); + expect(body).toContain('aria-labelledby="metrics-views-maps"'); + expect(body).toContain('Lobbies (avg · peak)'); + expect(body).not.toContain('Instances'); + expect(body).not.toContain('Instance utilization'); + expect(body).not.toContain('Historical'); + expect(body).not.toContain('grouped by name'); + } +); + +it.each(['asc', 'desc'])( + 'sorts average lobby counts numerically %s with missing evidence last and shows empty time', + (direction) => { + scope(`?mapSort=lobbies&mapDirection=${direction}`); + const maps = [ + map(1, { running: { average: null, peak: 0, capturedHours: 0 }, observed: 0, idle: 0 }), + map(2, { running: { average: 2.5, peak: 4, capturedHours: 2 }, idle: 0 }), + map(3, { running: { average: 10, peak: 12, capturedHours: 1 }, idle: 1.2 }), + map(4, { + running: { average: 3, peak: 3, capturedHours: 1 }, + observed: 0, + idle: 0, + unknown: 3 + }) + ]; + const body = render(MetricsTables, { props: { maps } }).body; + const ordered = direction === 'asc' ? [2, 4, 3, 1] : [3, 4, 2, 1]; + for (let i = 1; i < ordered.length; i++) { + expect(body.indexOf(`Map 0${ordered[i - 1]}`)).toBeLessThan( + body.indexOf(`Map 0${ordered[i]}`) + ); + } + expect(body).toContain(`aria-sort="${direction === 'asc' ? 'ascending' : 'descending'}"`); + expect(body).toContain('2.5 · 4'); + expect(body).toContain('10 · 12'); + expect(body).toContain('3 · 3'); + expect(body).toContain('0 h empty'); + expect(body).toContain('1.2 h empty'); + expect(body).toContain('Empty time unavailable'); + expect(body).toContain('Captured map time'); + expect(body).toMatch(/class="lobbies [^"]*">—\s* { const data = report(); @@ -475,13 +515,13 @@ it('renders whole-lobby axis ticks without rounding fractional averages or other }); it('uses shallow URL state for the selected breakdown while retaining the common summary', () => { - scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2'); + scope('?view=insights', '?view=maps&mapPage=2'); let body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body; - expect(body).toContain('aria-label="Instance metrics table"'); - expect(body).not.toContain('aria-label="Map metrics table"'); - expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 instances'); + expect(body).toContain('aria-label="Map metrics table"'); + expect(body).not.toContain('id="metrics-explore-title"'); + expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 maps'); scope('?view=maps', '?view=insights'); body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } }