Preserve leaderboard context in player profiles
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { leaderboardMetrics } from './leaderboard.js';
|
||||
|
||||
const origin = 'https://electric-cherry.invalid';
|
||||
const contextKeys = ['game', 'map', 'metric', 'q', 'offset'] as const;
|
||||
|
||||
/** Only retain a local leaderboard destination and its supported view state. */
|
||||
export function leaderboardReturnHref(value: string | null | undefined): string {
|
||||
if (!value?.startsWith('/leaderboard')) return '/leaderboard';
|
||||
try {
|
||||
const url = new URL(value, origin);
|
||||
if (url.origin !== origin || url.pathname !== '/leaderboard') return '/leaderboard';
|
||||
const params = new URLSearchParams();
|
||||
for (const key of contextKeys) {
|
||||
const entry = url.searchParams.get(key);
|
||||
if (entry) params.set(key, entry);
|
||||
}
|
||||
return `/leaderboard${params.size ? `?${params}` : ''}`;
|
||||
} catch { return '/leaderboard'; }
|
||||
}
|
||||
|
||||
export function playerContextHref(guid: string, source: string): string {
|
||||
const returnTo = leaderboardReturnHref(source);
|
||||
const original = new URL(returnTo, origin).searchParams;
|
||||
const params = new URLSearchParams({ returnTo });
|
||||
// History pagination is independent of the originating leaderboard page.
|
||||
const game = original.get('game');
|
||||
const map = original.get('map');
|
||||
const metric = original.get('metric');
|
||||
// Match the leaderboard's displayed defaults for older or hand-edited URLs.
|
||||
if (game === 't5' || game === 't6') params.set('game', game);
|
||||
if (map && map.length <= 1024) params.set('map', map);
|
||||
if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric);
|
||||
return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`;
|
||||
}
|
||||
|
||||
export function profileViewHref(current: URL, changes: Record<string, string>): string {
|
||||
const params = new URLSearchParams(current.search);
|
||||
params.delete('offset');
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
}
|
||||
return `${current.pathname}${params.size ? `?${params}` : ''}`;
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export interface PlayerProfile {
|
||||
records: PersonalBestRecords;
|
||||
maps: PlayerMapRecord[];
|
||||
matches: PlayerMatch[];
|
||||
matchesTotal: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
|
||||
|
||||
/** Rank each GUID once within the selected matches; player search keeps their actual rank. */
|
||||
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
|
||||
return readLeaderboard(options);
|
||||
}
|
||||
|
||||
/** Select the exact player after ranking everyone eligible in the requested scope. */
|
||||
export function getPlayerLeaderboardEntry(guid: string,
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map' | 'metric'> = {}): LeaderboardEntry | null {
|
||||
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid).entries[0] ?? null;
|
||||
}
|
||||
|
||||
function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): LeaderboardResponse {
|
||||
const handle = db();
|
||||
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
|
||||
const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50;
|
||||
@@ -53,8 +63,10 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
|
||||
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
|
||||
)`;
|
||||
const query = options.q?.trim() ?? '';
|
||||
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
||||
const searchedParameters = query ? [...parameters, query, query] : parameters;
|
||||
const search = exactGuid !== undefined ? 'WHERE guid = ?'
|
||||
: query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
||||
const searchedParameters = exactGuid !== undefined ? [...parameters, exactGuid]
|
||||
: query ? [...parameters, query, query] : parameters;
|
||||
const rankedEntries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search}
|
||||
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`)
|
||||
.all(...searchedParameters, limit, offset) as Array<Omit<LeaderboardEntry, 'records'>>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||
import type { 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,
|
||||
SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
|
||||
@@ -11,7 +11,8 @@ const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m
|
||||
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.guid = ? AND mp.has_stats = 1
|
||||
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`;
|
||||
|
||||
export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfile | null {
|
||||
export function getPlayerProfile(guid: string, requestedOffset = 0,
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map'> = {}): PlayerProfile | null {
|
||||
const handle = db();
|
||||
// Keep game-specific ingestion identities, but show one identity with the latest reported name.
|
||||
const player = handle.prepare(`SELECT guid, name, MIN(first_seen_at) OVER () AS firstSeen,
|
||||
@@ -21,8 +22,15 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi
|
||||
if (!player) return null;
|
||||
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
|
||||
if (!stats.gamesPlayed) return null;
|
||||
const historyParameters = [guid];
|
||||
let historyScope = scope;
|
||||
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
|
||||
if (options.map) { historyScope += ' AND m.map_key = ?'; historyParameters.push(options.map); }
|
||||
const matchesTotal = options.game || options.map
|
||||
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
|
||||
: stats.gamesPlayed;
|
||||
const limit = 25;
|
||||
const lastOffset = Math.floor((stats.gamesPlayed - 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 mapStats = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name,
|
||||
MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope}
|
||||
@@ -35,7 +43,7 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi
|
||||
m.started_at AS startedAt, mp.last_seen_at AS lastSeen, m.status, mp.completed,
|
||||
mp.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived,
|
||||
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore
|
||||
${scope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
|
||||
.all(guid, limit, offset) as PlayerMatch[];
|
||||
return { ...player, stats, records, maps, matches, offset, limit };
|
||||
${historyScope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
|
||||
.all(...historyParameters, limit, offset) as PlayerMatch[];
|
||||
return { ...player, stats, records, maps, matches, matchesTotal, offset, limit };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user