This commit is contained in:
@@ -96,6 +96,21 @@ of inferring one. GUIDs stay strings.
|
|||||||
|
|
||||||
## Recorded statistics
|
## 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 |
|
| Statistic | Meaning |
|
||||||
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Highest round | Highest round reported with that player present, including players who joined mid-game. |
|
| Highest round | Highest round reported with that player present, including players who joined mid-game. |
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export interface MatchPlayer {
|
|||||||
downs: number | null;
|
downs: number | null;
|
||||||
revives: number | null;
|
revives: number | null;
|
||||||
bestScore: number | null;
|
bestScore: number | null;
|
||||||
|
/** Highest reported cumulative match score; null when the reporter omitted it. */
|
||||||
|
scoreTotal: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchDetail extends ServerMatch {
|
export interface MatchDetail extends ServerMatch {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { migrateHostSchema } from '../hosts/schema.js';
|
|||||||
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
|
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
|
||||||
import { migrateGameFileSchema } from '../game-files/schema.js';
|
import { migrateGameFileSchema } from '../game-files/schema.js';
|
||||||
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||||
|
import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||||
import type { Migration } from './migration-runner.js';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
// Append only: never renumber entries or change an applied migration to add an
|
// Append only: never renumber entries or change an applied migration to add an
|
||||||
@@ -103,6 +104,11 @@ export const migrations: readonly Migration[] = [
|
|||||||
version: 18,
|
version: 18,
|
||||||
name: 'durable leaderboard report identity and sequences',
|
name: 'durable leaderboard report identity and sequences',
|
||||||
run: migrateLeaderboardReports
|
run: migrateLeaderboardReports
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 19,
|
||||||
|
name: 'cumulative player score per match',
|
||||||
|
run: migratePlayerScoreTotal
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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))`);
|
||||||
|
}
|
||||||
@@ -71,7 +71,21 @@ export function recordLeaderboardTelemetry(
|
|||||||
report: GameTelemetry
|
report: GameTelemetry
|
||||||
): { matchId: number; current: boolean } {
|
): { matchId: number; current: boolean } {
|
||||||
if (report.match !== undefined) return recordKeyedTelemetry(handle, report);
|
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.
|
// 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;
|
.get(report.server) as { id: number } | undefined;
|
||||||
result.current = latest?.id === match.id && match.server_deleted_at === null;
|
result.current = latest?.id === match.id && match.server_deleted_at === null;
|
||||||
}
|
}
|
||||||
|
if (applied) saveScoreTotals(handle, report, match.id);
|
||||||
if (applied && report.seq !== undefined) {
|
if (applied && report.seq !== undefined) {
|
||||||
handle
|
handle
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -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.downs END AS downs,
|
||||||
CASE WHEN p.has_stats = 1 THEN p.revives END AS revives,
|
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,
|
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
|
EXISTS (SELECT 1 FROM leaderboard_players identity
|
||||||
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) AS canViewProfile
|
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) AS canViewProfile
|
||||||
FROM leaderboard_match_players p WHERE p.match_id = ?
|
FROM leaderboard_match_players p WHERE p.match_id = ?
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface TelemetryPlayerStats extends TelemetryPlayer {
|
|||||||
downs: number;
|
downs: number;
|
||||||
revives: number;
|
revives: number;
|
||||||
score: number;
|
score: number;
|
||||||
|
/** Cumulative match score; absent for reporters predating total-score support. */
|
||||||
|
score_total?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameEvent = {
|
export type GameEvent = {
|
||||||
@@ -101,6 +103,11 @@ export function parseGameEvent(value: unknown): GameEvent {
|
|||||||
throw new Error(`players[${index}].${key} must be a nonnegative integer`);
|
throw new Error(`players[${index}].${key} must be a nonnegative integer`);
|
||||||
stats[key] = player[key] as number;
|
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 };
|
return { ...identity, ...stats };
|
||||||
});
|
});
|
||||||
const common = {
|
const common = {
|
||||||
|
|||||||
@@ -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 () => {
|
it('deduplicates a retried sequence after intervening reports and a database reopen', async () => {
|
||||||
await report('one', 1, 'game_start');
|
await report('one', 1, 'game_start');
|
||||||
await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
|
await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ it('upgrades version 17 without inventing reporter identity or changing historic
|
|||||||
last_seq: 0,
|
last_seq: 0,
|
||||||
report_count: 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<string, unknown>), score_total: null }))
|
||||||
|
);
|
||||||
expect(handle.prepare('SELECT * FROM match_chat').all()).toEqual(chat);
|
expect(handle.prepare('SELECT * FROM match_chat').all()).toEqual(chat);
|
||||||
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
|
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
|
||||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ describe('completed match details', () => {
|
|||||||
headshots: 20,
|
headshots: 20,
|
||||||
downs: 2,
|
downs: 2,
|
||||||
revives: 3,
|
revives: 3,
|
||||||
bestScore: 2000
|
bestScore: 2000,
|
||||||
|
scoreTotal: null
|
||||||
});
|
});
|
||||||
expect(players[1]).toEqual({
|
expect(players[1]).toEqual({
|
||||||
guid: '202',
|
guid: '202',
|
||||||
@@ -106,7 +107,8 @@ describe('completed match details', () => {
|
|||||||
headshots: 0,
|
headshots: 0,
|
||||||
downs: 0,
|
downs: 0,
|
||||||
revives: 0,
|
revives: 0,
|
||||||
bestScore: 1500
|
bestScore: 1500,
|
||||||
|
scoreTotal: null
|
||||||
});
|
});
|
||||||
expect(players[2]).toMatchObject({
|
expect(players[2]).toMatchObject({
|
||||||
guid: '404',
|
guid: '404',
|
||||||
@@ -132,7 +134,8 @@ describe('completed match details', () => {
|
|||||||
headshots: null,
|
headshots: null,
|
||||||
downs: null,
|
downs: null,
|
||||||
revives: null,
|
revives: null,
|
||||||
bestScore: null
|
bestScore: null,
|
||||||
|
scoreTotal: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -18,6 +18,59 @@ afterEach(async () => fixture.cleanup());
|
|||||||
|
|
||||||
const payload = gameEvent('round_change', 17);
|
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)(
|
it.each(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)(
|
||||||
'preserves optional reporter identity and sequence for %s',
|
'preserves optional reporter identity and sequence for %s',
|
||||||
async (event) => {
|
async (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user