diff --git a/docs/guides/leaderboard.md b/docs/guides/leaderboard.md index a42fafa..f11c7d0 100644 --- a/docs/guides/leaderboard.md +++ b/docs/guides/leaderboard.md @@ -96,6 +96,21 @@ of inferring one. GUIDs stay strings. ## Recorded statistics +Telemetry stat reports (`round_change`, `heartbeat`, and `game_end`) may include +`score_total`, a nonnegative integer read from the game's cumulative match score. +`score` remains the spendable balance. Updated T5/T6 reporters capture game end +at the first end signal, before stock intermission replaces the balance with the +total. `game_start` remains identity-only. Deploy the receiver before the updated +reporters so it retains the new field. + +The receiver accepts older reports without `score_total`. Latest telemetry +preserves the field when supplied; match history retains the highest observed +total independently of the balance and exposes it as `scoreTotal` on each player +in the match-detail API. Missing totals and pre-upgrade history are unknown +(`null`), not zero, and are never inferred from balances. Totals are per match; +repeated snapshots are not added together. Existing score rankings continue to +use the highest reported balance. + | Statistic | Meaning | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Highest round | Highest round reported with that player present, including players who joined mid-game. | diff --git a/src/lib/match-history.ts b/src/lib/match-history.ts index b5871be..764951c 100644 --- a/src/lib/match-history.ts +++ b/src/lib/match-history.ts @@ -42,6 +42,8 @@ export interface MatchPlayer { downs: number | null; revives: number | null; bestScore: number | null; + /** Highest reported cumulative match score; null when the reporter omitted it. */ + scoreTotal: number | null; } export interface MatchDetail extends ServerMatch { diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 82bc2db..9bb4921 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -17,6 +17,7 @@ import { migrateHostSchema } from '../hosts/schema.js'; import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js'; import { migrateGameFileSchema } from '../game-files/schema.js'; import { migrateLeaderboardReports } from './leaderboard-reports.js'; +import { migratePlayerScoreTotal } from './player-score-total.js'; import type { Migration } from './migration-runner.js'; // Append only: never renumber entries or change an applied migration to add an @@ -103,6 +104,11 @@ export const migrations: readonly Migration[] = [ version: 18, name: 'durable leaderboard report identity and sequences', run: migrateLeaderboardReports + }, + { + version: 19, + name: 'cumulative player score per match', + run: migratePlayerScoreTotal } ]; diff --git a/src/lib/server/db/player-score-total.ts b/src/lib/server/db/player-score-total.ts new file mode 100644 index 0000000..be7f6aa --- /dev/null +++ b/src/lib/server/db/player-score-total.ts @@ -0,0 +1,11 @@ +import type Database from 'better-sqlite3'; + +/** Old score balances cannot be used to reconstruct cumulative points. */ +export function migratePlayerScoreTotal(handle: Database.Database): void { + const columns = handle.prepare('PRAGMA table_info(leaderboard_match_players)').all() as { + name: string; + }[]; + if (!columns.some((column) => column.name === 'score_total')) + handle.exec(`ALTER TABLE leaderboard_match_players ADD COLUMN score_total INTEGER + CHECK (score_total IS NULL OR (typeof(score_total) = 'integer' AND score_total >= 0))`); +} diff --git a/src/lib/server/fleet/leaderboard-ingest.ts b/src/lib/server/fleet/leaderboard-ingest.ts index d749e31..f00a0cc 100644 --- a/src/lib/server/fleet/leaderboard-ingest.ts +++ b/src/lib/server/fleet/leaderboard-ingest.ts @@ -71,7 +71,21 @@ export function recordLeaderboardTelemetry( report: GameTelemetry ): { matchId: number; current: boolean } { if (report.match !== undefined) return recordKeyedTelemetry(handle, report); - return { matchId: recordLegacyTelemetry(handle, report), current: true }; + const matchId = recordLegacyTelemetry(handle, report); + saveScoreTotals(handle, report, matchId); + return { matchId, current: true }; +} + +// Separate from applyReport so the historical version-0 backfill still works +// before the score_total migration. Missing totals never erase known values. +function saveScoreTotals(handle: Database.Database, report: GameTelemetry, matchId: number): void { + if (report.event === 'game_start') return; + const players = report.players.filter((player) => player.score_total !== undefined); + if (!players.length) return; + const save = handle.prepare(`UPDATE leaderboard_match_players + SET score_total = MAX(COALESCE(score_total, 0), ?) + WHERE match_id = ? AND guid = ?`); + for (const player of players) save.run(player.score_total, matchId, player.guid); } // Also used by the version-0 snapshot backfill, before keyed schema exists. @@ -325,6 +339,7 @@ function recordKeyedTelemetry( .get(report.server) as { id: number } | undefined; result.current = latest?.id === match.id && match.server_deleted_at === null; } + if (applied) saveScoreTotals(handle, report, match.id); if (applied && report.seq !== undefined) { handle .prepare( diff --git a/src/lib/server/fleet/match-history.ts b/src/lib/server/fleet/match-history.ts index d592e02..61eb4cf 100644 --- a/src/lib/server/fleet/match-history.ts +++ b/src/lib/server/fleet/match-history.ts @@ -125,6 +125,7 @@ export function getMatchDetail(id: number): MatchDetail | null { CASE WHEN p.has_stats = 1 THEN p.downs END AS downs, CASE WHEN p.has_stats = 1 THEN p.revives END AS revives, CASE WHEN p.has_stats = 1 THEN p.best_score END AS bestScore, + p.score_total AS scoreTotal, EXISTS (SELECT 1 FROM leaderboard_players identity WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) AS canViewProfile FROM leaderboard_match_players p WHERE p.match_id = ? diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index c6c862c..b64f3da 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -11,6 +11,8 @@ export interface TelemetryPlayerStats extends TelemetryPlayer { downs: number; revives: number; score: number; + /** Cumulative match score; absent for reporters predating total-score support. */ + score_total?: number; } export type GameEvent = { @@ -101,6 +103,11 @@ export function parseGameEvent(value: unknown): GameEvent { throw new Error(`players[${index}].${key} must be a nonnegative integer`); stats[key] = player[key] as number; } + if (player.score_total !== undefined) { + if (!Number.isSafeInteger(player.score_total) || (player.score_total as number) < 0) + throw new Error(`players[${index}].score_total must be a nonnegative integer`); + stats.score_total = player.score_total as number; + } return { ...identity, ...stats }; }); const common = { diff --git a/tests/unit/keyed-telemetry.test.ts b/tests/unit/keyed-telemetry.test.ts index fc40a0e..f21ff7a 100644 --- a/tests/unit/keyed-telemetry.test.ts +++ b/tests/unit/keyed-telemetry.test.ts @@ -88,6 +88,31 @@ async function live() { }; } +it('retains totals through spending, missing fields, duplicate/stale reports and delayed completion', async () => { + await report('one', 1, 'game_start'); + const first = await report('one', 2, 'heartbeat', 17, [ + player('ace', { score: 32000, score_total: 62000 }) + ]); + const id = first.timing!.matchId; + await report('one', 3, 'heartbeat', 17, [player('ace', { score: 28000, score_total: 63000 })]); + // A retried sequence cannot change the total even if its body differs. + await report('one', 3, 'heartbeat', 17, [player('ace', { score: 99000, score_total: 999999 })]); + // A legacy-shaped snapshot cannot erase a known total. + await report('one', 6, 'heartbeat', 18, [player('ace', { score: 29000 })]); + await report('one', 4, 'heartbeat', 18, [player('ace', { score: 99000, score_total: 999999 })]); + expect(await participation(id)).toMatchObject([{ best_score: 32000, score_total: 63000 }]); + // An unseen older end can complete the match and preserve its final total. + await report('one', 5, 'game_end', 18, [player('ace', { score: 25200, score_total: 65000 })]); + expect(await participation(id)).toMatchObject([{ best_score: 32000, score_total: 65000 }]); + const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js'); + expect(getMatchDetail(id)?.players[0]).toMatchObject({ bestScore: 32000, scoreTotal: 65000 }); + const { closeDb } = await import('../../src/lib/server/db/index.js'); + closeDb(); + expect(getMatchDetail(id)?.players[0].scoreTotal).toBe(65000); + const next = await report('two', 1, 'game_start'); + expect(await participation(next.timing!.matchId)).toMatchObject([{ score_total: null }]); +}); + it('deduplicates a retried sequence after intervening reports and a database reopen', async () => { await report('one', 1, 'game_start'); await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]); diff --git a/tests/unit/leaderboard-report-migration.test.ts b/tests/unit/leaderboard-report-migration.test.ts index 5c271e5..e6713d6 100644 --- a/tests/unit/leaderboard-report-migration.test.ts +++ b/tests/unit/leaderboard-report-migration.test.ts @@ -57,7 +57,9 @@ it('upgrades version 17 without inventing reporter identity or changing historic last_seq: 0, report_count: 0 }); - expect(handle.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual(players); + expect(handle.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual( + players.map((player) => ({ ...(player as Record), score_total: null })) + ); expect(handle.prepare('SELECT * FROM match_chat').all()).toEqual(chat); expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]); expect(handle.pragma('foreign_key_check')).toEqual([]); diff --git a/tests/unit/match-detail.test.ts b/tests/unit/match-detail.test.ts index 0ae2857..aa20d7a 100644 --- a/tests/unit/match-detail.test.ts +++ b/tests/unit/match-detail.test.ts @@ -90,7 +90,8 @@ describe('completed match details', () => { headshots: 20, downs: 2, revives: 3, - bestScore: 2000 + bestScore: 2000, + scoreTotal: null }); expect(players[1]).toEqual({ guid: '202', @@ -106,7 +107,8 @@ describe('completed match details', () => { headshots: 0, downs: 0, revives: 0, - bestScore: 1500 + bestScore: 1500, + scoreTotal: null }); expect(players[2]).toMatchObject({ guid: '404', @@ -132,7 +134,8 @@ describe('completed match details', () => { headshots: null, downs: null, revives: null, - bestScore: null + bestScore: null, + scoreTotal: null }); }); diff --git a/tests/unit/player-score-total-migration.test.ts b/tests/unit/player-score-total-migration.test.ts new file mode 100644 index 0000000..19641c0 --- /dev/null +++ b/tests/unit/player-score-total-migration.test.ts @@ -0,0 +1,36 @@ +import Database from 'better-sqlite3'; +import { expect, it } from 'vitest'; +import { migratePlayerScoreTotal } from '../../src/lib/server/db/player-score-total.js'; +import { runMigrations } from '../../src/lib/server/db/migration-runner.js'; +import { migrations } from '../../src/lib/server/db/migrations.js'; + +it('upgrades old balances to unknown totals and preserves totals on migration replay', () => { + const handle = new Database(':memory:'); + try { + const migration = migrations.find( + (entry) => entry.name === 'cumulative player score per match' + )!; + runMigrations(handle, migrations.slice(0, migration.version - 1)); + handle.exec(`INSERT INTO leaderboard_matches + (id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint) + VALUES (1,'server','t5','zombie_theater','t5:zombie_theater','Kino','start','end','game_end','old'); + INSERT INTO leaderboard_match_players (match_id,guid,best_score,first_seen_at,last_seen_at) + VALUES (1,'ace',32000,'start','end')`); + runMigrations(handle, migrations); + expect( + handle.prepare('SELECT best_score,score_total FROM leaderboard_match_players').get() + ).toEqual({ best_score: 32000, score_total: null }); + handle.exec('UPDATE leaderboard_match_players SET score_total=65000'); + migratePlayerScoreTotal(handle); + expect( + handle.prepare('SELECT best_score,score_total FROM leaderboard_match_players').get() + ).toEqual({ best_score: 32000, score_total: 65000 }); + for (const value of [-1, 1.5, 'bad']) + expect(() => + handle.prepare('UPDATE leaderboard_match_players SET score_total=?').run(value) + ).toThrow('CHECK constraint failed'); + expect(handle.pragma('foreign_key_check')).toEqual([]); + } finally { + handle.close(); + } +}); diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts index 9e5fadd..a653e47 100644 --- a/tests/unit/telemetry.test.ts +++ b/tests/unit/telemetry.test.ts @@ -18,6 +18,59 @@ afterEach(async () => fixture.cleanup()); const payload = gameEvent('round_change', 17); +it.each(['round_change', 'heartbeat', 'game_end'] as const)( + 'accepts and stores score_total separately from the balance for %s', + async (event) => { + const body = gameEvent(event, 17, 1); + const response = await post({ + ...body, + players: [{ ...body.players[0], score: 28000, score_total: 63000 }] + }); + expect(response.status).toBe(200); + expect((await response.json()).telemetry.players[0]).toMatchObject({ + score: 28000, + score_total: 63000 + }); + const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); + expect(listTelemetry()[0].players[0]).toMatchObject({ score: 28000, score_total: 63000 }); + const { db } = await import('../../src/lib/server/db/index.js'); + expect( + db().prepare('SELECT best_score, score_total FROM leaderboard_match_players').get() + ).toEqual({ best_score: 28000, score_total: 63000 }); + } +); + +it.each([0, Number.MAX_SAFE_INTEGER])( + 'accepts cumulative score boundary %s', + async (score_total) => { + const { parseGameEvent } = await import('../../src/lib/telemetry.js'); + expect( + parseGameEvent({ ...payload, players: [{ ...roster(1)[0], score_total }] }).players[0] + ).toMatchObject({ score_total }); + } +); + +it.each([null, -1, 1.5, '63000', true, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid score_total without saving it: %j', + async (score_total) => { + expect( + (await post({ ...payload, players: [{ ...roster(1)[0], score_total }] })).status + ).toBe(400); + const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); + expect(listTelemetry()).toEqual([]); + } +); + +it('accepts older reporters without fabricating a cumulative score', async () => { + const response = await post(gameEvent('game_end', 17, 1)); + expect(response.status).toBe(200); + expect((await response.json()).telemetry.players[0]).not.toHaveProperty('score_total'); + const { db } = await import('../../src/lib/server/db/index.js'); + expect(db().prepare('SELECT score_total FROM leaderboard_match_players').get()).toEqual({ + score_total: null + }); +}); + it.each(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)( 'preserves optional reporter identity and sequence for %s', async (event) => {