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(), fresh: new Set() }; }); 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(); const fresh = new Set(); 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 }; }