Exclude same-round departures from games played
This commit is contained in:
@@ -92,11 +92,15 @@ of inferring one. GUIDs stay strings.
|
|||||||
| 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. |
|
||||||
| Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. |
|
| Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. |
|
||||||
| Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. |
|
| Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. |
|
||||||
| Games played | Completed games in which a stats report at round 2 or later contains the player, even if they left before the end. Start-only and round 1 participation do not count. |
|
| Games played | Completed games in which a stats report at round 2 or later contains the player, excluding players who left in the same round they joined. Staying through game end counts even without a round change. Legacy participation with an unknown joining round still counts. Start-only and round 1 participation do not count. |
|
||||||
| Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. |
|
| Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. |
|
||||||
| Best game kills | Highest kill count recorded in any one game. |
|
| Best game kills | Highest kill count recorded in any one game. |
|
||||||
| Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. |
|
| Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. |
|
||||||
|
|
||||||
|
Same-round departures retain their recorded stats, personal bests, and match
|
||||||
|
history; only the games-played count excludes them. This also changes the game
|
||||||
|
count used for efficiency qualification and per-game averages.
|
||||||
|
|
||||||
Every ranking sorts highest first, including downs (a count, not an efficiency
|
Every ranking sorts highest first, including downs (a count, not an efficiency
|
||||||
rating). Equal values share a rank; name and GUID provide a stable display order.
|
rating). Equal values share a rank; name and GUID provide a stable display order.
|
||||||
Searching preserves the player's rank for the selected game/map filters, or
|
Searching preserves the player's rank for the selected game/map filters, or
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Keep legacy participation countable when its joining round is unknown.
|
||||||
|
// A player who stays through game end counts even without a round change.
|
||||||
|
export const countsAsGamePlayed = `CASE WHEN mp.completed = 1 OR mp.first_observed_round IS NULL
|
||||||
|
OR mp.highest_round > mp.first_observed_round THEN 1 ELSE 0 END`;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||||
|
import { countsAsGamePlayed } from './leaderboard-participation.js';
|
||||||
import { EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS, EFFICIENCY_MIN_DOWNS,
|
import { EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS, EFFICIENCY_MIN_DOWNS,
|
||||||
isEfficiencyMetric } from '../../leaderboard-efficiency.js';
|
isEfficiencyMetric } from '../../leaderboard-efficiency.js';
|
||||||
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
|
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
|
||||||
@@ -74,6 +75,7 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
|
|||||||
const aggregated = `WITH observations AS ${observationStorage} (
|
const aggregated = `WITH observations AS ${observationStorage} (
|
||||||
SELECT mp.match_id, mp.guid, mp.highest_round, mp.kills, mp.rounds_survived,
|
SELECT mp.match_id, mp.guid, mp.highest_round, mp.kills, mp.rounds_survived,
|
||||||
mp.headshots, mp.revives, mp.downs, mp.completed, mp.best_score, mp.last_seen_at,
|
mp.headshots, mp.revives, mp.downs, mp.completed, mp.best_score, mp.last_seen_at,
|
||||||
|
${countsAsGamePlayed} AS counts_as_game,
|
||||||
m.started_at AS matchStartedAt, m.last_received_at AS matchUpdatedAt
|
m.started_at AS matchStartedAt, m.last_received_at AS matchUpdatedAt
|
||||||
FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
|
FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||||
WHERE ${conditions.join(' AND ')}
|
WHERE ${conditions.join(' AND ')}
|
||||||
@@ -81,7 +83,7 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
|
|||||||
SELECT guid, MAX(highest_round) AS highestRound,
|
SELECT guid, MAX(highest_round) AS highestRound,
|
||||||
SUM(kills) AS kills, SUM(rounds_survived) AS roundsSurvived,
|
SUM(kills) AS kills, SUM(rounds_survived) AS roundsSurvived,
|
||||||
SUM(headshots) AS headshots, SUM(revives) AS revives, SUM(downs) AS downs,
|
SUM(headshots) AS headshots, SUM(revives) AS revives, SUM(downs) AS downs,
|
||||||
COUNT(*) AS gamesPlayed, SUM(completed) AS gamesCompleted,
|
SUM(counts_as_game) AS gamesPlayed, SUM(completed) AS gamesCompleted,
|
||||||
MAX(kills) AS bestKills, MAX(best_score) AS bestScore, MAX(last_seen_at) AS lastSeen
|
MAX(kills) AS bestKills, MAX(best_score) AS bestScore, MAX(last_seen_at) AS lastSeen
|
||||||
FROM observations GROUP BY guid
|
FROM observations GROUP BY guid
|
||||||
), measures AS (
|
), measures AS (
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||||
|
import { countsAsGamePlayed } from './leaderboard-participation.js';
|
||||||
import type { LeaderboardOptions, PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
|
import type { LeaderboardOptions, PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
|
||||||
|
|
||||||
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
|
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
|
||||||
SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
|
SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
|
||||||
SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, COUNT(*) AS gamesPlayed,
|
SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, SUM(${countsAsGamePlayed}) AS gamesPlayed,
|
||||||
SUM(mp.completed) AS gamesCompleted, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore`;
|
SUM(mp.completed) AS gamesCompleted, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore`;
|
||||||
const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
|
const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||||
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.guid = ? AND mp.has_stats = 1
|
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.guid = ? AND mp.has_stats = 1
|
||||||
@@ -20,8 +21,9 @@ export function getPlayerProfile(guid: string, requestedOffset = 0,
|
|||||||
WHERE game IN ('t5', 't6') AND guid = ? ORDER BY last_seen_at DESC, game LIMIT 1`)
|
WHERE game IN ('t5', 't6') AND guid = ? ORDER BY last_seen_at DESC, game LIMIT 1`)
|
||||||
.get(guid) as Pick<PlayerProfile, 'guid' | 'name' | 'firstSeen' | 'lastSeen'> | undefined;
|
.get(guid) as Pick<PlayerProfile, 'guid' | 'name' | 'firstSeen' | 'lastSeen'> | undefined;
|
||||||
if (!player) return null;
|
if (!player) return null;
|
||||||
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
|
const { matchCount, ...stats } = handle.prepare(`SELECT COUNT(*) AS matchCount, ${totals} ${scope}`)
|
||||||
if (!stats.gamesPlayed) return null;
|
.get(guid) as PlayerStats & { matchCount: number };
|
||||||
|
if (!matchCount) return null;
|
||||||
const historyParameters: Array<string | number> = [guid];
|
const historyParameters: Array<string | number> = [guid];
|
||||||
let historyScope = scope;
|
let historyScope = scope;
|
||||||
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
|
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
|
||||||
@@ -29,7 +31,7 @@ export function getPlayerProfile(guid: string, requestedOffset = 0,
|
|||||||
if (options.players) { historyScope += ' AND m.start_players = ?'; historyParameters.push(options.players); }
|
if (options.players) { historyScope += ' AND m.start_players = ?'; historyParameters.push(options.players); }
|
||||||
const matchesTotal = options.game || options.map || options.players
|
const matchesTotal = options.game || options.map || options.players
|
||||||
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
|
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
|
||||||
: stats.gamesPlayed;
|
: matchCount;
|
||||||
const limit = 25;
|
const limit = 25;
|
||||||
const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit);
|
const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit);
|
||||||
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0;
|
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
headshots: 'Cumulative headshots across recorded games.',
|
headshots: 'Cumulative headshots across recorded games.',
|
||||||
revives: 'Cumulative teammates revived across recorded games.',
|
revives: 'Cumulative teammates revived across recorded games.',
|
||||||
downs: 'Cumulative downs across recorded games, ranked from most to fewest.',
|
downs: 'Cumulative downs across recorded games, ranked from most to fewest.',
|
||||||
gamesPlayed: 'Finished games with recorded player stats, including players who left before the end.',
|
gamesPlayed: 'Finished games with recorded player stats, excluding players who left in the same round they joined.',
|
||||||
gamesCompleted: 'Games where the player appears in the final game-end report.',
|
gamesCompleted: 'Games where the player appears in the final game-end report.',
|
||||||
bestKills: 'The most kills reported for a player in a single game.',
|
bestKills: 'The most kills reported for a player in a single game.',
|
||||||
bestScore: 'The highest reported points balance. Spending points reduces this balance; it is not total points earned.'
|
bestScore: 'The highest reported points balance. Spending points reduces this balance; it is not total points earned.'
|
||||||
|
|||||||
@@ -141,3 +141,15 @@ it('accepts each efficiency metric through the API when the qualified group is e
|
|||||||
summary: { players: 1, matches: 4, kills: 80 } });
|
summary: { players: 1, matches: 4, kills: 80 } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not use same-round departures to qualify for efficiency rankings', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
addSamples(db(), 'short-visit', [...samples(4), { completed: 0 }]);
|
||||||
|
db().prepare(`UPDATE leaderboard_match_players SET first_observed_round = highest_round
|
||||||
|
WHERE completed = 0`).run();
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
expect(getLeaderboard().entries[0]).toMatchObject({ gamesPlayed: 4, gamesCompleted: 4,
|
||||||
|
kills: 100, averageKills: 25, completionRate: 1 });
|
||||||
|
const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js');
|
||||||
|
for (const { key } of efficiencyMetrics) expect(getLeaderboard({ metric: key }).entries).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -269,6 +269,40 @@ it('keeps disconnected player records while completing only the final roster', a
|
|||||||
roundsSurvived: 3, gamesPlayed: 1, gamesCompleted: 0 }, matches: [{ status: 'completed', completed: 0 }] });
|
roundsSurvived: 3, gamesPlayed: 1, gamesCompleted: 0 }, matches: [{ status: 'completed', completed: 0 }] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[server, 'zombie_theater', 't5'],
|
||||||
|
['t6_zombie_town_1', 'zm_transit', 't6']
|
||||||
|
])('does not count same-round departures as games played on %s', async (source, map, game) => {
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||||
|
const departed = player('departed', { kills: 10 });
|
||||||
|
const advanced = player('advanced', { kills: 20 });
|
||||||
|
const finished = player('finished', { kills: 30 });
|
||||||
|
await report('game_start', 0, [], source, map);
|
||||||
|
await report('heartbeat', 5, [departed, advanced], source, map);
|
||||||
|
await report('heartbeat', 5, [player(departed.guid, { kills: 15 }), advanced], source, map);
|
||||||
|
await report('heartbeat', 5, [advanced], source, map);
|
||||||
|
await report('round_change', 6, [advanced, finished], source, map);
|
||||||
|
await report('game_end', 6, [finished], source, map);
|
||||||
|
|
||||||
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
closeDb();
|
||||||
|
const board = getLeaderboard({ game });
|
||||||
|
expect(board.entries.find(entry => entry.guid === departed.guid)).toMatchObject({
|
||||||
|
gamesPlayed: 0, gamesCompleted: 0, kills: 15, highestRound: 5,
|
||||||
|
averageKills: null, averageRounds: null, completionRate: null
|
||||||
|
});
|
||||||
|
expect(board.entries.find(entry => entry.guid === advanced.guid)).toMatchObject({ gamesPlayed: 1, gamesCompleted: 0 });
|
||||||
|
expect(board.entries.find(entry => entry.guid === finished.guid)).toMatchObject({ gamesPlayed: 1, gamesCompleted: 1 });
|
||||||
|
const profile = getPlayerProfile(departed.guid)!;
|
||||||
|
expect(profile).toMatchObject({ stats: { gamesPlayed: 0, kills: 15 },
|
||||||
|
maps: [{ gamesPlayed: 0, kills: 15 }], matchesTotal: 1,
|
||||||
|
matches: [{ kills: 15, completed: 0 }], records: { bestKills: { value: 15 } } });
|
||||||
|
expect(getLeaderboard({ game, map: profile.maps[0].key, metric: 'gamesPlayed' }).entries)
|
||||||
|
.toContainEqual(expect.objectContaining({ guid: departed.guid, gamesPlayed: 0 }));
|
||||||
|
expect(getPlayerProfile(departed.guid, 0, { game, map: profile.maps[0].key })?.matchesTotal).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('starts separate matches for game starts, map changes, round resets, and new rounds after an end', async () => {
|
it('starts separate matches for game starts, map changes, round resets, and new rounds after an end', async () => {
|
||||||
await report('game_start');
|
await report('game_start');
|
||||||
await report('round_change', 5, [player(undefined, { kills: 50 })]);
|
await report('round_change', 5, [player(undefined, { kills: 50 })]);
|
||||||
|
|||||||
@@ -387,6 +387,14 @@ describe('player profiles', () => {
|
|||||||
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0);
|
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');
|
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||||
expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') })).profile).toEqual(second);
|
expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') })).profile).toEqual(second);
|
||||||
|
|
||||||
|
// Historical same-round departures retain both pages even with no counted games.
|
||||||
|
handle.prepare(`UPDATE leaderboard_match_players SET first_observed_round = highest_round,
|
||||||
|
completed = 0 WHERE guid = '101'`).run();
|
||||||
|
const departures = getPlayerProfile('101', 25)!;
|
||||||
|
expect(departures).toMatchObject({ stats: { gamesPlayed: 0, kills: 770 }, matchesTotal: 34, offset: 25 });
|
||||||
|
expect(departures.matches).toHaveLength(9);
|
||||||
|
expect(departures.maps.every(map => map.gamesPlayed === 0)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => {
|
it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user