This commit is contained in:
@@ -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 })]);
|
||||
|
||||
@@ -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<string, unknown>), 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([]);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user