Unify player profiles across T5 and T6
This commit is contained in:
@@ -42,16 +42,16 @@ it('starts counting at round 2, preserves early stats, and excludes players who
|
||||
await report('game_start', 0, [player(), departed]);
|
||||
await report('round_change', 1, [player(undefined, { kills: 6, score: 1000 }), departed]);
|
||||
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
||||
expect(getPlayerProfile('t5', player().guid)).toBeNull();
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(serverMatchHistory()).toEqual({ total: 0, matches: [] });
|
||||
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
|
||||
expect(getLeaderboard()).toMatchObject({ total: 1, entries: [expect.objectContaining({
|
||||
highestRound: 2, kills: 6, bestScore: 1000, roundsSurvived: 1, gamesPlayed: 1, gamesCompleted: 0
|
||||
})], summary: { players: 1, matches: 1, kills: 6 } });
|
||||
expect(getPlayerProfile('t5', departed.guid)).toBeNull();
|
||||
expect(getPlayerProfile(departed.guid)).toBeNull();
|
||||
await report('game_end', 2, [player(undefined, { kills: 10 })]);
|
||||
const board = getLeaderboard();
|
||||
const profile = getPlayerProfile('t5', player().guid);
|
||||
const profile = getPlayerProfile(player().guid);
|
||||
const history = serverMatchHistory();
|
||||
expect(history).toMatchObject({ total: 1, matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }] });
|
||||
await report('game_start');
|
||||
@@ -62,7 +62,7 @@ it('starts counting at round 2, preserves early stats, and excludes players who
|
||||
closeDb();
|
||||
expect(getLeaderboard()).toEqual(board);
|
||||
// Identity timestamps still reflect live presence, but gameplay records do not change.
|
||||
expect(getPlayerProfile('t5', player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
|
||||
expect(getPlayerProfile(player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
|
||||
expect(serverMatchHistory()).toEqual(history);
|
||||
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||
expect(await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json()).toEqual(history);
|
||||
@@ -165,13 +165,13 @@ it('persists independent stat high-water marks without adding cumulative snapsho
|
||||
})]);
|
||||
});
|
||||
|
||||
it('keeps a latest player identity across games on different servers without merging different game GUIDs', async () => {
|
||||
it('retains per-game identities while combining the same GUID in one profile', async () => {
|
||||
await report('round_change', 8, [player(undefined, { kills: 111 })]);
|
||||
await report('game_end', 8, [player(undefined, { kills: 120, name: 'New name' })]);
|
||||
await report('game_start');
|
||||
await report('game_end', 2, [player(undefined, { kills: 20, name: 'Latest name' })]);
|
||||
await report('game_end', 5, [player(undefined, { kills: 200, name: 'Other game' })], 't6_zombie_town_1', 'zm_transit');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
expect(db().prepare('SELECT game, guid, name FROM leaderboard_players ORDER BY game').all()).toEqual([
|
||||
{ game: 't5', guid: '2741626', name: 'Latest name' },
|
||||
{ game: 't6', guid: '2741626', name: 'Other game' }
|
||||
@@ -180,6 +180,16 @@ it('keeps a latest player identity across games on different servers without mer
|
||||
expect(db().prepare(`SELECT SUM(p.rounds_survived) AS total FROM leaderboard_match_players p
|
||||
JOIN leaderboard_matches m ON m.id = p.match_id WHERE m.game = 't5' AND p.guid = ?`).get('2741626'))
|
||||
.toEqual({ total: 1 });
|
||||
closeDb();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const profile = getPlayerProfile(player().guid)!;
|
||||
expect(profile).toMatchObject({ guid: player().guid, name: 'Other game',
|
||||
firstSeen: '2026-09-18T20:00:00.000Z', lastSeen: '2026-09-18T20:00:04.000Z',
|
||||
stats: { gamesPlayed: 3, gamesCompleted: 3, kills: 340, highestRound: 8, bestKills: 200, roundsSurvived: 1 } });
|
||||
expect(profile.matches.map(match => match.game)).toEqual(['t6', 't5', 't5']);
|
||||
await report('game_start', 0, [player(undefined, { name: 'Live name' })]);
|
||||
expect(getPlayerProfile(player().guid)).toMatchObject({ name: 'Live name', lastSeen: '2026-09-18T20:00:05.000Z',
|
||||
stats: profile.stats, maps: profile.maps, matches: profile.matches });
|
||||
});
|
||||
|
||||
it('credits the highest round but only observed survival for a late join or a return after an absent roster', async () => {
|
||||
@@ -312,7 +322,7 @@ it('keeps deleted match identities and starts a separate match when the server n
|
||||
server, server_deleted_at: expect.any(String), status: 'interrupted'
|
||||
});
|
||||
expect(history.find(match => match.id !== original.id)).toMatchObject({ server, server_deleted_at: null, status: 'active' });
|
||||
const profile = getPlayerProfile('t5', player().guid)!;
|
||||
const profile = getPlayerProfile(player().guid)!;
|
||||
expect(profile.stats.gamesPlayed).toBe(2);
|
||||
expect(profile.matches.find(match => match.id === original.id)?.server_deleted_at).toEqual(expect.any(String));
|
||||
expect(profile.stats.kills).toBe(240);
|
||||
|
||||
@@ -51,31 +51,33 @@ describe('persisted leaderboard queries', () => {
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const { leaderboardMetrics } = await import('../../src/lib/leaderboard.js');
|
||||
const boards = leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }));
|
||||
const profile = getPlayerProfile('t5', '101');
|
||||
const profile = getPlayerProfile('101');
|
||||
const history = serverMatchHistory(undefined, 2);
|
||||
const scopedHistory = serverMatchHistory('removed-server', 2);
|
||||
const scopedBoard = getLeaderboard({ game: 't5', map: 't5:theater' });
|
||||
let id = 10;
|
||||
for (const status of ['active', 'completed', 'interrupted']) {
|
||||
for (const round of [0, 1]) {
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at,
|
||||
status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', 't5', 'reset_map', 't5:reset', 'Reset Map',
|
||||
'2026-09-20', '2026-09-20', ?, ?, 'game_end', 'reset')`).run(id, status, round);
|
||||
// A historical record with large counters must not change rankings or bests.
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, highest_round, kills, headshots, revives, downs, best_score,
|
||||
has_stats, completed, first_seen_at, last_seen_at)
|
||||
VALUES (?, '101', ?, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
|
||||
.run(id++, round);
|
||||
for (const game of ['t5', 't6']) {
|
||||
for (const status of ['active', 'completed', 'interrupted']) {
|
||||
for (const round of [0, 1]) {
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at,
|
||||
status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', ?, 'reset_map', ?, 'Reset Map',
|
||||
'2026-09-20', '2026-09-20', ?, ?, 'game_end', 'reset')`).run(id, game, `${game}:reset`, status, round);
|
||||
// A historical record with large counters must not change rankings or bests.
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, highest_round, kills, headshots, revives, downs, best_score,
|
||||
has_stats, completed, first_seen_at, last_seen_at)
|
||||
VALUES (?, '101', ?, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
|
||||
.run(id++, round);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }))).toEqual(boards);
|
||||
expect(getLeaderboard({ game: 't5', map: 't5:theater' })).toEqual(scopedBoard);
|
||||
expect(getLeaderboard({ map: 't5:reset' })).toMatchObject({ entries: [], total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
|
||||
expect(getPlayerProfile('t5', '101')).toEqual(profile);
|
||||
expect(getPlayerProfile('101')).toEqual(profile);
|
||||
expect(serverMatchHistory(undefined, 2)).toEqual(history);
|
||||
expect(serverMatchHistory('removed-server', 2)).toEqual(scopedHistory);
|
||||
});
|
||||
@@ -144,54 +146,127 @@ describe('persisted leaderboard queries', () => {
|
||||
});
|
||||
|
||||
describe('player profiles', () => {
|
||||
it('returns exact player totals, map records and history independently of current server registration', async () => {
|
||||
it('combines totals, map records and history across T5 and T6 independently of current server registration', async () => {
|
||||
await seedRecords();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const profile = getPlayerProfile('t5', '101')!;
|
||||
const entry = getLeaderboard({ game: 't5', q: '101' }).entries[0];
|
||||
expect(profile).toMatchObject({ name: 'Ace', game: 't5', guid: '101', offset: 0, limit: 25 });
|
||||
for (const [key, value] of Object.entries(profile.stats)) expect(entry[key as keyof typeof entry]).toBe(value);
|
||||
expect(profile.maps).toHaveLength(2);
|
||||
expect(profile.maps[0]).toMatchObject({ key: 't5:theater', highestRound: 10, kills: 250, gamesPlayed: 2, gamesCompleted: 1 });
|
||||
expect(profile.matches.map(match => match.id)).toEqual([3, 2, 1]);
|
||||
expect(profile.matches[1]).toMatchObject({ status: 'active', completed: 0, server: 'removed-server', kills: 150 });
|
||||
expect(getPlayerProfile('t6', '101')).toMatchObject({ name: 'Other game', stats: { kills: 500, highestRound: 20 } });
|
||||
expect(getPlayerProfile('t5', '404')).toBeNull();
|
||||
expect(getPlayerProfile('t5', "' OR 1=1 --")).toBeNull();
|
||||
const profile = getPlayerProfile('101')!;
|
||||
expect(profile).toMatchObject({ name: 'Ace', guid: '101', offset: 0, limit: 25 });
|
||||
expect(profile).not.toHaveProperty('game');
|
||||
expect(profile.stats).toEqual({ highestRound: 20, kills: 770, roundsSurvived: 38,
|
||||
headshots: 320, revives: 8, downs: 4, gamesPlayed: 4, gamesCompleted: 3,
|
||||
bestKills: 500, bestScore: 5000 });
|
||||
expect(profile.maps).toHaveLength(3);
|
||||
expect(profile.maps[0]).toMatchObject({ game: 't6', key: 't6:town', highestRound: 20, kills: 500, gamesPlayed: 1, gamesCompleted: 1 });
|
||||
expect(profile.maps[1]).toMatchObject({ game: 't5', key: 't5:theater', highestRound: 10, kills: 250, gamesPlayed: 2, gamesCompleted: 1 });
|
||||
expect(profile.matches.map(match => [match.id, match.game])).toEqual([[4, 't6'], [3, 't5'], [2, 't5'], [1, 't5']]);
|
||||
expect(profile.matches[2]).toMatchObject({ status: 'active', completed: 0, server: 'removed-server', kills: 150 });
|
||||
expect(getPlayerProfile('505')).toMatchObject({ name: 'Eve', stats: { kills: 200, gamesPlayed: 1 },
|
||||
maps: [expect.objectContaining({ game: 't6' })], matches: [expect.objectContaining({ game: 't6' })] });
|
||||
expect(getPlayerProfile('404')).toBeNull();
|
||||
expect(getPlayerProfile("' OR 1=1 --")).toBeNull();
|
||||
});
|
||||
|
||||
it('paginates history without changing lifetime totals and clamps invalid offsets', async () => {
|
||||
it('uses the latest reported name and the full identity date range across supported games', async () => {
|
||||
const handle = await seedRecords();
|
||||
handle.prepare(`UPDATE leaderboard_players SET first_seen_at = '2026-08-01', last_seen_at = '2026-09-07'
|
||||
WHERE game = 't5' AND guid = '101'`).run();
|
||||
handle.prepare(`UPDATE leaderboard_players SET first_seen_at = '2026-07-01', last_seen_at = '2026-09-08'
|
||||
WHERE game = 't6' AND guid = '101'`).run();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
expect(getPlayerProfile('101')).toMatchObject({ name: 'Other game', firstSeen: '2026-07-01', lastSeen: '2026-09-08' });
|
||||
handle.prepare("UPDATE leaderboard_players SET last_seen_at = '2026-09-08' WHERE game = 't5' AND guid = '101'").run();
|
||||
expect(getPlayerProfile('101')?.name).toBe('Ace');
|
||||
handle.prepare("UPDATE leaderboard_players SET last_seen_at = '2026-09-09' WHERE game = 't5' AND guid = '101'").run();
|
||||
expect(getPlayerProfile('101')).toMatchObject({ name: 'Ace', firstSeen: '2026-07-01', lastSeen: '2026-09-09' });
|
||||
});
|
||||
|
||||
it('keeps map records separate across games even when their stored keys are equal', async () => {
|
||||
const handle = await seedRecords();
|
||||
handle.prepare("UPDATE leaderboard_matches SET map_key = 'shared-map' WHERE id IN (1, 2, 4)").run();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const records = getPlayerProfile('101')!.maps.filter(map => map.key === 'shared-map');
|
||||
expect(records).toEqual([
|
||||
expect.objectContaining({ game: 't6', highestRound: 20, kills: 500, gamesPlayed: 1 }),
|
||||
expect.objectContaining({ game: 't5', highestRound: 10, kills: 250, gamesPlayed: 2 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes unsupported games and players without qualifying stats from every part of a profile', async () => {
|
||||
const handle = await seedRecords();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const original = getPlayerProfile('101');
|
||||
const match = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', ?, 'excluded', 'excluded', 'Excluded', '2026-09-20', '2026-09-20', 'completed', ?, 'game_end', 'test')`);
|
||||
const record = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, kills, highest_round, has_stats, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, 99999, ?, ?, '2026-09-20', '2026-09-20')`);
|
||||
match.run(10, 't7', 99);
|
||||
record.run(10, '101', 99, 1);
|
||||
record.run(10, 'unsupported-only', 99, 1);
|
||||
for (const guid of ['101', 'unsupported-only']) {
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t7', ?, 'Unsupported name', '2020-01-01', '2030-01-01')`).run(guid);
|
||||
}
|
||||
match.run(11, 't5', 99);
|
||||
record.run(11, '101', 1, 1);
|
||||
match.run(12, 't6', 99);
|
||||
record.run(12, '101', 99, 0);
|
||||
match.run(13, 't6', 1);
|
||||
record.run(13, '101', 99, 1);
|
||||
expect(getPlayerProfile('101')).toEqual(original);
|
||||
expect(getPlayerProfile('unsupported-only')).toBeNull();
|
||||
});
|
||||
|
||||
it('paginates one history across both games without changing lifetime totals and clamps invalid offsets', async () => {
|
||||
const handle = await seedRecords();
|
||||
for (let id = 10; id < 40; id++) {
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
|
||||
SELECT ?, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint
|
||||
FROM leaderboard_matches WHERE id = 1`).run(id);
|
||||
FROM leaderboard_matches WHERE id = ?`).run(id, id % 2 === 0 ? 1 : 4);
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, has_stats, highest_round, first_seen_at, last_seen_at)
|
||||
VALUES (?, '101', 1, 2, '2026-09-01', '2026-09-01')`).run(id);
|
||||
VALUES (?, '101', 1, 2, '2026-09-01', '2026-09-06')`).run(id);
|
||||
}
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const first = getPlayerProfile('t5', '101')!;
|
||||
const second = getPlayerProfile('t5', '101', 25)!;
|
||||
const first = getPlayerProfile('101')!;
|
||||
const second = getPlayerProfile('101', 25)!;
|
||||
expect(first.matches).toHaveLength(25);
|
||||
expect(second.matches).toHaveLength(8);
|
||||
expect(second.matches).toHaveLength(9);
|
||||
expect(second.stats).toEqual(first.stats);
|
||||
expect(new Set([...first.matches, ...second.matches].map(match => match.id)).size).toBe(33);
|
||||
expect(getPlayerProfile('t5', '101', 9999)?.offset).toBe(25);
|
||||
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('t5', '101', offset)?.offset).toBe(0);
|
||||
expect(first.stats).toMatchObject({ gamesPlayed: 34, kills: 770 });
|
||||
expect(first.matches.map(match => [match.id, match.game])).toEqual(Array.from({ length: 25 }, (_, index) => {
|
||||
const id = 39 - index;
|
||||
return [id, id % 2 === 0 ? 't5' : 't6'];
|
||||
}));
|
||||
expect(second.matches.map(match => match.id)).toEqual([14, 13, 12, 11, 10, 4, 3, 2, 1]);
|
||||
expect(new Set([...first.matches, ...second.matches].map(match => match.id)).size).toBe(34);
|
||||
expect(getPlayerProfile('101', 9999)?.offset).toBe(25);
|
||||
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0);
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') }).profile).toEqual(second);
|
||||
});
|
||||
|
||||
it('loads direct profile URLs and returns 404 for missing players or games', async () => {
|
||||
it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => {
|
||||
await seedRecords();
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[game]/[guid]/+page.server.js');
|
||||
const url = new URL('http://localhost/leaderboard/players/t5/101');
|
||||
expect(load({ params: { game: 't5', guid: '101' }, url }).profile.name).toBe('Ace');
|
||||
for (const params of [{ game: 't7', guid: '101' }, { game: 't5', guid: 'missing' }]) {
|
||||
expect(() => load({ params, url })).toThrow(expect.objectContaining({ status: 404 }));
|
||||
}
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const url = new URL('http://localhost/leaderboard/players/101');
|
||||
expect(load({ params: { guid: '101' }, url }).profile).toMatchObject({ name: 'Ace', stats: { kills: 770, gamesPlayed: 4 } });
|
||||
expect(() => load({ params: { guid: 'missing' }, url })).toThrow(expect.objectContaining({ status: 404 }));
|
||||
const { playerProfileHref } = await import('../../src/lib/leaderboard.js');
|
||||
expect(playerProfileHref('t5', '001/2?#')).toBe('/leaderboard/players/t5/001%2F2%3F%23');
|
||||
expect(playerProfileHref('001/2?#')).toBe('/leaderboard/players/001%2F2%3F%23');
|
||||
});
|
||||
|
||||
it('permanently redirects both legacy game URLs while preserving the GUID and pagination query', async () => {
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[game]/[guid]/+page.server.js');
|
||||
for (const game of ['t5', 't6']) {
|
||||
const url = new URL(`http://localhost/leaderboard/players/${game}/001%2F2%3F%23?offset=25&source=old%20link`);
|
||||
expect(() => load({ params: { game, guid: '001/2?#' }, url })).toThrow(expect.objectContaining({
|
||||
status: 308, location: '/leaderboard/players/001%2F2%3F%23?offset=25&source=old%20link'
|
||||
}));
|
||||
}
|
||||
expect(() => load({ params: { game: 't7', guid: '101' }, url: new URL('http://localhost/leaderboard/players/t7/101') }))
|
||||
.toThrow(expect.objectContaining({ status: 404 }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user