157 lines
6.7 KiB
TypeScript
157 lines
6.7 KiB
TypeScript
import { db } from '../db/index.js';
|
|
import { matchChatTimeline } from './chat.js';
|
|
import {
|
|
MIN_RECORDED_ROUND,
|
|
type MatchDetail,
|
|
type MatchPlayer,
|
|
type MatchHistoryResponse,
|
|
type ServerMatch
|
|
} from '../../match-history.js';
|
|
|
|
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
|
|
|
|
const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
|
m.reporter_match_key AS reporterMatchKey,
|
|
CASE WHEN m.last_seq > 0 THEN m.last_seq - m.report_count END AS missingReports,
|
|
m.last_received_at AS lastReportedAt,
|
|
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
|
m.start_players AS startPlayers,
|
|
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
|
CASE WHEN m.status = 'completed' THEN
|
|
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id
|
|
AND (CASE WHEN m.reporter_match_key IS NULL THEN p.present ELSE p.completed END) = 1)
|
|
END AS endPlayers,
|
|
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
|
|
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
|
|
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound`;
|
|
|
|
export function serverMatchHistory(
|
|
server?: string,
|
|
limit = 25,
|
|
query: MatchHistoryQuery = readMatchQuery(new URLSearchParams()),
|
|
now = Date.now()
|
|
): MatchHistoryResponse {
|
|
const handle = db();
|
|
const clauses = [query.archive ? '1=1' : `m.highest_round >= ${MIN_RECORDED_ROUND}`];
|
|
const parameters: Array<string | number> = [];
|
|
if (server !== undefined) {
|
|
clauses.push('m.server = ?');
|
|
parameters.push(server);
|
|
}
|
|
if (query.game) {
|
|
clauses.push('m.game = ?');
|
|
parameters.push(query.game);
|
|
}
|
|
const maps = handle
|
|
.prepare(
|
|
`SELECT m.map_key AS key, MAX(m.map_name) AS name, m.game
|
|
FROM leaderboard_matches m WHERE ${clauses.join(' AND ')} GROUP BY m.map_key, m.game ORDER BY name COLLATE NOCASE, game`
|
|
)
|
|
.all(...parameters) as MatchHistoryResponse['maps'];
|
|
if (query.map) {
|
|
clauses.push('m.map_key = ?');
|
|
parameters.push(query.map);
|
|
}
|
|
if (query.status) {
|
|
clauses.push('m.status = ?');
|
|
parameters.push(query.status);
|
|
}
|
|
if (query.days) {
|
|
clauses.push('julianday(m.started_at) >= julianday(?)');
|
|
parameters.push(new Date(now - query.days * 86400000).toISOString());
|
|
}
|
|
// Search readable instance names as well as their stored identifiers. Tokens allow "Town Lobby 1".
|
|
for (const token of query.q
|
|
.toLowerCase()
|
|
.replace(/\blobby\s+(?=\d+\b)/g, '')
|
|
.split(/[\s_]+/)
|
|
.filter(Boolean)) {
|
|
clauses.push("instr(lower(m.server || ' ' || m.map_name), ?) > 0");
|
|
parameters.push(token);
|
|
}
|
|
const where = `WHERE ${clauses.join(' AND ')}`;
|
|
const total = (
|
|
handle
|
|
.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`)
|
|
.get(...parameters) as { total: number }
|
|
).total;
|
|
const page = Math.min(query.page, Math.max(1, Math.ceil(total / limit)));
|
|
const column = {
|
|
started: 'startedAt',
|
|
round: 'observedRound',
|
|
duration: 'durationMs',
|
|
players: 'uniquePlayers'
|
|
}[query.sort];
|
|
const direction = query.direction === 'asc' ? 'ASC' : 'DESC';
|
|
const matches = handle
|
|
.prepare(
|
|
`SELECT ${matchSummary} FROM leaderboard_matches m
|
|
${where} ORDER BY ${column} IS NULL ASC, ${column} ${direction}, m.id DESC LIMIT ? OFFSET ?`
|
|
)
|
|
.all(...parameters, limit, (page - 1) * limit) as ServerMatch[];
|
|
return { matches, total, page, limit, maps };
|
|
}
|
|
|
|
/** Read durable match records without depending on a current server or scoreboard. */
|
|
export function getMatchDetail(id: number, archive = false): MatchDetail | null {
|
|
if (!Number.isSafeInteger(id) || id < 1) return null;
|
|
const handle = db();
|
|
const match = handle
|
|
.prepare(
|
|
`SELECT ${matchSummary}, m.start_observed AS startObserved
|
|
FROM leaderboard_matches m WHERE m.id = ?${
|
|
archive
|
|
? ''
|
|
: ` AND m.status = 'completed'
|
|
AND m.highest_round >= ${MIN_RECORDED_ROUND}`
|
|
}`
|
|
)
|
|
.get(id) as
|
|
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number })
|
|
| undefined;
|
|
if (!match) return null;
|
|
|
|
const players = handle
|
|
.prepare(
|
|
`SELECT p.guid,
|
|
COALESCE((SELECT identity.name FROM leaderboard_players identity
|
|
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')
|
|
ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1),
|
|
(SELECT identity.name FROM leaderboard_players identity
|
|
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
|
p.has_stats AS hasStats,
|
|
CASE WHEN ? != 'completed' THEN 0 WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
|
|
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
|
|
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
|
|
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
|
|
CASE WHEN p.has_stats = 1 THEN p.kills END AS kills,
|
|
CASE WHEN p.has_stats = 1 THEN p.headshots END AS headshots,
|
|
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.best_score END AS bestScore,
|
|
p.score_total AS scoreTotal,
|
|
EXISTS (SELECT 1 FROM leaderboard_players identity
|
|
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')) AS canViewProfile
|
|
FROM leaderboard_match_players p WHERE p.match_id = ?
|
|
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
|
|
)
|
|
.all(match.game, match.status, match.reporterMatchKey, id) as Array<
|
|
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
|
hasStats: number;
|
|
presentAtEnd: number;
|
|
canViewProfile: number;
|
|
}
|
|
>;
|
|
return {
|
|
...match,
|
|
chat: matchChatTimeline(id),
|
|
startObserved: Boolean(match.startObserved),
|
|
players: players.map((player) => ({
|
|
...player,
|
|
hasStats: Boolean(player.hasStats),
|
|
presentAtEnd: Boolean(player.presentAtEnd),
|
|
canViewProfile: Boolean(player.canViewProfile)
|
|
}))
|
|
};
|
|
}
|