diff --git a/src/ingest/analytics.ts b/src/ingest/analytics.ts index 352e42699..ba39740ba 100644 --- a/src/ingest/analytics.ts +++ b/src/ingest/analytics.ts @@ -106,6 +106,7 @@ export function buildAnalytics( archiveDurationSec: lobby.archiveDurationSec, archiveConnectedPlayers: lobby.archiveConnectedPlayers, archiveActivePlayers: lobby.archiveActivePlayers, + archiveSpawnedPlayers: lobby.archiveSpawnedPlayers, scheduledStartAt: lobby.scheduledStartAt, peakClients: lobby.peakClients, maxPlayers: lobby.maxPlayers, diff --git a/src/ingest/ingestService.ts b/src/ingest/ingestService.ts index 74ccd82ff..ce550a135 100644 --- a/src/ingest/ingestService.ts +++ b/src/ingest/ingestService.ts @@ -38,6 +38,7 @@ function isBuildIntentType(type: string): boolean { function deriveReplayParticipationMetrics(archivePayload: unknown): { connectedPlayers?: number; activePlayers?: number; + spawnedPlayers?: number; } { const root = asRecord(archivePayload); if (!root) return {}; @@ -47,6 +48,7 @@ function deriveReplayParticipationMetrics(archivePayload: unknown): { const connectedClientIds = new Set(); const activeClientIds = new Set(); + const spawnedClientIds = new Set(); let sawDisconnectedMarker = false; for (const turnEntry of turns) { @@ -74,12 +76,16 @@ function deriveReplayParticipationMetrics(archivePayload: unknown): { if (isBuildIntentType(type)) { activeClientIds.add(clientID); } + if (type === "spawn") { + spawnedClientIds.add(clientID); + } } } return { connectedPlayers: sawDisconnectedMarker ? connectedClientIds.size : undefined, activePlayers: activeClientIds.size, + spawnedPlayers: spawnedClientIds.size, }; } @@ -418,6 +424,7 @@ export class LobbyIngestService { (record) => record.status !== "active" && (!record.archiveFound || + record.archiveSpawnedPlayers === undefined || (record.archiveConnectedPlayers === undefined && record.archiveActivePlayers === undefined)) && !!record.closedAt && @@ -579,6 +586,7 @@ export class LobbyIngestService { players: info?.players?.length, connectedPlayers: replayMetrics.connectedPlayers, activePlayers: replayMetrics.activePlayers, + spawnedPlayers: replayMetrics.spawnedPlayers, durationSec: typeof info?.duration === "number" ? Math.round(info.duration) : undefined, winner: winnerLabel, diff --git a/src/ingest/store.ts b/src/ingest/store.ts index cb75ed08b..fdbcf396e 100644 --- a/src/ingest/store.ts +++ b/src/ingest/store.ts @@ -327,6 +327,7 @@ export class JsonStore { players?: number; connectedPlayers?: number; activePlayers?: number; + spawnedPlayers?: number; durationSec?: number; winner?: string; lobbyCreatedAt?: number; @@ -340,6 +341,7 @@ export class JsonStore { lobby.archivePlayers = payload.players; lobby.archiveConnectedPlayers = payload.connectedPlayers; lobby.archiveActivePlayers = payload.activePlayers; + lobby.archiveSpawnedPlayers = payload.spawnedPlayers; lobby.archiveDurationSec = payload.durationSec; lobby.archiveWinner = payload.winner; lobby.actualLobbyCreatedAt = payload.lobbyCreatedAt; diff --git a/src/shared/types.ts b/src/shared/types.ts index f887138cb..f41de4a24 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -93,6 +93,7 @@ export interface LobbyRecord { archivePlayers?: number; archiveConnectedPlayers?: number; archiveActivePlayers?: number; + archiveSpawnedPlayers?: number; archiveDurationSec?: number; archiveWinner?: string; actualLobbyCreatedAt?: number; @@ -166,6 +167,7 @@ export interface AnalyticsPayload { archiveDurationSec?: number; archiveConnectedPlayers?: number; archiveActivePlayers?: number; + archiveSpawnedPlayers?: number; scheduledStartAt: number; peakClients: number; maxPlayers?: number; @@ -206,8 +208,19 @@ export function safeMaxPlayers(record: Pick): number return Math.max(1, record.maxPlayers ?? 1); } -export function peakFillRatio(record: Pick): number { - return record.peakClients / safeMaxPlayers(record as Pick); +export function peakFillClients( + record: Pick, +): number { + return record.archiveConnectedPlayers ?? record.archivePlayers ?? record.peakClients; +} + +export function peakFillRatio( + record: Pick< + LobbyRecord, + "peakClients" | "maxPlayers" | "archiveConnectedPlayers" | "archivePlayers" + >, +): number { + return peakFillClients(record) / safeMaxPlayers(record as Pick); } export function bucketForConfig( diff --git a/src/web/main.ts b/src/web/main.ts index 8c30da05c..c341bf18a 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -3,6 +3,7 @@ import { BucketMode, LobbyRecord, TimelineBucket, + peakFillClients, } from "../shared/types"; import * as d3 from "d3"; import "./styles.css"; @@ -617,7 +618,7 @@ function renderOrder(payload: AnalyticsPayload): void { Status Lobby + Game Peak Fill - Connected / Active + Connected / Active / Spawned Join/min Opened @@ -635,7 +636,7 @@ function renderOrder(payload: AnalyticsPayload): void { ${row.status} ${formatDurationMs(row.openDurationMs)} + ${formatGameDuration(row, payload.now)} - ${row.maxPlayers ? `${row.peakClients}/${row.maxPlayers}` : row.peakClients} + ${formatPeakFill(row)} ${formatReplayParticipation(row)} ${row.joinRatePerMin.toFixed(2)} ${new Date(row.openedAt).toLocaleString()} @@ -684,7 +685,7 @@ function renderInteresting(target: "neverStarted" | "lowFill", rows: LobbyRecord ${row.gameID} ${row.gameConfig?.gameMode ?? "-"} ${row.gameConfig?.gameMap ?? "-"} - ${row.maxPlayers ? `${row.peakClients}/${row.maxPlayers}` : row.peakClients} + ${formatPeakFill(row)} ${row.fillRatioAtStart !== undefined ? `${(row.fillRatioAtStart * 100).toFixed(1)}%` : "-"} ${formatDurationMs(row.openDurationMs)} @@ -723,20 +724,37 @@ function formatDurationSec(durationSec: number | undefined): string { function formatReplayParticipation( row: Pick< AnalyticsPayload["order"][number], - "archivePlayers" | "archiveConnectedPlayers" | "archiveActivePlayers" + | "archivePlayers" + | "archiveConnectedPlayers" + | "archiveActivePlayers" + | "archiveSpawnedPlayers" >, ): string { const connected = row.archiveConnectedPlayers; const active = row.archiveActivePlayers; + const spawned = row.archiveSpawnedPlayers; const total = row.archivePlayers; - if (connected === undefined && active === undefined) { + if (connected === undefined && active === undefined && spawned === undefined) { return "-"; } - const pair = `${connected ?? "-"} / ${active ?? "-"}`; - if (total === undefined) return pair; - return `${pair} of ${total}`; + const triplet = `${connected ?? "-"} / ${active ?? "-"} / ${spawned ?? "-"}`; + if (total === undefined) return triplet; + return `${triplet} of ${total}`; +} + +function formatPeakFill( + row: Pick< + AnalyticsPayload["order"][number], + "peakClients" | "maxPlayers" | "archiveConnectedPlayers" | "archivePlayers" + >, +): string { + const peak = peakFillClients(row); + if (row.maxPlayers && row.maxPlayers > 0) { + return `${peak}/${row.maxPlayers}`; + } + return String(peak); } function formatGameDuration(