import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { MetricsDays } from '../../src/lib/metrics.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); const hour = 3600_000; const day = 24 * hour; const now = Date.UTC(2026, 9, 10, 12, 30); const iso = (at: number) => new Date(at).toISOString(); async function match(start: number, end: number, guids: string[], game = 't5', map = 'Moon') { const { db } = await import('../../src/lib/server/db/index.js'); const handle = db(); const { lastInsertRowid: id } = handle .prepare( `INSERT INTO leaderboard_matches (server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint) VALUES ('removed-server',?,?,?, ?,?,?,'completed','game_end','test')` ) .run(game, map, `${game}:${map}`, map, iso(start), iso(end)); for (const guid of guids) { handle .prepare( `INSERT INTO leaderboard_match_players (match_id,guid,first_seen_at,last_seen_at) VALUES(?,?,?,?)` ) .run(id, guid, iso(start), iso(end)); handle .prepare( `INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?) ON CONFLICT(game,guid) DO UPDATE SET last_seen_at=excluded.last_seen_at` ) .run(game, guid, guid, iso(start), iso(end)); } return Number(id); } describe('metrics player history', () => { it.each([1, 7, 30])( 'counts spans and repeated matches once per period and range for %i days', async (days) => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const bucket = days === 1 ? hour : day; const boundary = Math.floor((now - bucket) / bucket) * bucket; await match(now - 40 * day, now - 40 * day, ['regular']); await match(boundary - 10_000, boundary + 10_000, ['regular', 'newcomer']); await match(boundary + 20_000, boundary + 30_000, ['regular', 'newcomer']); const result = readMetrics(days, now); const before = result.points.find((point) => point.at === boundary - bucket)!; const after = result.points.find((point) => point.at === boundary)!; expect(before).toMatchObject({ uniquePlayers: 2, newPlayers: 1 }); expect(after).toMatchObject({ uniquePlayers: 2, newPlayers: 0 }); expect(result.players).toMatchObject({ uniquePlayers: 2, newPlayers: 1 }); expect(result.points.reduce((sum, point) => sum + (point.uniquePlayers ?? 0), 0)).toBe( 4 ); expect(result.summary.observed).toBe(0); } ); it('uses participation rather than match start and includes both range endpoints', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const since = now - day; await match(since - 2 * hour, since - 1, ['ended-before']); await match(since - hour, since + hour, ['spanning-start']); await match(since, since, ['at-start']); await match(now - hour, now + hour, ['spanning-end']); await match(now, now, ['at-end']); await match(now + 1, now + 1, ['future']); const result = readMetrics(1, now); expect(result.players).toMatchObject({ uniquePlayers: 4, newPlayers: 3 }); expect(result.points[0]).toMatchObject({ uniquePlayers: 2, newPlayers: 1, partial: true }); expect(result.points.at(-1)).toMatchObject({ uniquePlayers: 2, newPlayers: 1, partial: true }); }); it('assigns a single sighting on an internal boundary only to the following period', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); await match(now - 2 * day, now - 2 * day, []); const boundary = Date.UTC(2026, 9, 10, 11); await match(boundary, boundary, ['boundary']); const result = readMetrics(1, now); expect(result.points.find((point) => point.at === boundary - hour)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 }); expect(result.points.find((point) => point.at === boundary)).toMatchObject({ uniquePlayers: 1, newPlayers: 1 }); expect(result.points.find((point) => point.at === boundary + hour)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 }); }); it('deduplicates identity across games and scopes new players to their original match', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); await match(now - 2 * day, now - 2 * day, ['regular']); await match( now - 4 * hour, now - 4 * hour, ['both-games', 'regular'], 't6', 'Town Survival' ); await match(now - 3 * hour, now - 3 * hour, ['both-games', 'regular', 'moon-new']); await match( now - 2 * hour, now - 2 * hour, ['moon-new', 'kino-new'], 't5', 'Kino Der Toten' ); await match(now - hour, now - hour, ['town-new'], 't6', 'Town Survival'); await match(now - hour, now - hour, ['grief-new'], 't6', 'Town Grief'); const read = (game: string | null, map: string | null = null) => readMetrics({ days: 1, game, map, timeZone: 'UTC' }, now); expect(read(null).players).toMatchObject({ uniquePlayers: 6, newPlayers: 5 }); expect(read('t5').players).toMatchObject({ uniquePlayers: 4, newPlayers: 2 }); expect(read('t5', 'Moon').players).toMatchObject({ uniquePlayers: 3, newPlayers: 1 }); expect(read('t5', 'Kino Der Toten').players).toMatchObject({ uniquePlayers: 2, newPlayers: 1 }); expect(read('t6', 'Town Survival').players).toMatchObject({ uniquePlayers: 3, newPlayers: 2 }); expect(read('t6', 'Town Grief').players).toMatchObject({ uniquePlayers: 1, newPlayers: 1 }); expect(read('t5', 'Missing').players).toMatchObject({ uniquePlayers: 0, newPlayers: 0 }); expect(read('t5', 'Missing').points.every((point) => point.uniquePlayers === 0)).toBe(true); }); it('leaves periods before recording as gaps and marks the first retained period partial', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const start = now - 2 * hour; await match(start, start + 10_000, ['first']); const result = readMetrics(1, now); expect(result.players).toEqual({ historyStartAt: start, uniquePlayers: 1, newPlayers: 1 }); expect( result.points .filter((point) => point.endAt <= start) .every((point) => point.uniquePlayers === null && point.newPlayers === null) ).toBe(true); expect( result.points.find((point) => point.at === Math.floor(start / hour) * hour) ).toMatchObject({ uniquePlayers: 1, newPlayers: 1, partial: false, playerHistoryPartial: true }); expect(result.points.at(-1)).toMatchObject({ uniquePlayers: 0, newPlayers: 0 }); }); it('moves the history bound after retention without making returning players new', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { db } = await import('../../src/lib/server/db/index.js'); const firstMatch = await match(now - 40 * day, now - 40 * day, ['regular']); await match(now - day, now - day + hour, ['regular'], 't6', 'Town Survival'); db().prepare('DELETE FROM leaderboard_matches WHERE id=?').run(firstMatch); const result = readMetrics( { days: 30, game: 't6', map: 'Town Survival', timeZone: 'UTC' }, now ); expect(result.players).toEqual({ historyStartAt: now - day, uniquePlayers: 1, newPlayers: 0 }); expect(result.points[0].uniquePlayers).toBeNull(); expect(db().prepare('SELECT COUNT(*) AS n FROM leaderboard_players').get()).toEqual({ n: 2 }); }); it('keeps no history unavailable, including when only durable player identities remain', async () => { const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js'); const { db } = await import('../../src/lib/server/db/index.js'); const assertMissing = () => { const result = readMetrics(1, now); expect(result.players).toEqual({ historyStartAt: null, uniquePlayers: null, newPlayers: null }); expect( result.points.every( (point) => point.uniquePlayers === null && point.newPlayers === null ) ).toBe(true); }; assertMissing(); await match(now - day, now - day, ['old']); db().prepare('DELETE FROM leaderboard_matches').run(); await match(now + hour, now + hour, ['future']); assertMissing(); }); });