games: automatic CS2 / PUBG / R6 match history — games.matches, match_players, seasons, snapshots, accounts; in-app poller (Leetify, FACEIT, Steam share codes, PUBG API, Ubisoft) every 20 min plus scripts/games-ingest.ts for backfills; match history, stats, maps and seasons on the per-game pages
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
All checks were successful
Deploy production / deploy (push) Successful in 1m6s
This commit is contained in:
137
src/lib/server/games/history.ts
Normal file
137
src/lib/server/games/history.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
import { missingKeys } from '#lib/games-ingest/run.js';
|
||||
import { gamesEnv } from './poller';
|
||||
|
||||
// Match history for the auto-tracked games (cs2, pubg, r6) on /trackers/games/<slug>.
|
||||
export const AUTO_TRACKED = ['cs2', 'pubg', 'r6'] as const;
|
||||
export const isAutoTracked = (slug: string): slug is (typeof AUTO_TRACKED)[number] =>
|
||||
(AUTO_TRACKED as readonly string[]).includes(slug);
|
||||
|
||||
export type HistoryMatch = {
|
||||
id: number;
|
||||
source: string;
|
||||
playedAt: string;
|
||||
durationS: number | null;
|
||||
mode: string | null;
|
||||
map: string | null;
|
||||
result: string | null;
|
||||
scoreOwn: number | null;
|
||||
scoreOpp: number | null;
|
||||
placement: number | null;
|
||||
teamCount: number | null;
|
||||
kills: number | null;
|
||||
deaths: number | null;
|
||||
assists: number | null;
|
||||
damage: number | null;
|
||||
headshots: number | null;
|
||||
rating: number | null;
|
||||
ratingLabel: string | null;
|
||||
matchCount: number;
|
||||
extra: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const LIMIT = 400;
|
||||
|
||||
export async function loadHistory(game: string, owner: boolean) {
|
||||
const matches = await sql<HistoryMatch[]>`
|
||||
select id, source, played_at as "playedAt", duration_s as "durationS", mode, map, result,
|
||||
score_own as "scoreOwn", score_opp as "scoreOpp", placement, team_count as "teamCount",
|
||||
kills, deaths, assists, damage, headshots, rating::float8 as rating,
|
||||
rating_label as "ratingLabel", match_count as "matchCount", extra
|
||||
from games.matches m
|
||||
where game = ${game}
|
||||
-- a FACEIT match Leetify also has: keep the Leetify row (it has more stats)
|
||||
and not (source = 'faceit' and exists (
|
||||
select 1 from games.matches l where l.game = m.game and l.source = 'leetify'
|
||||
and l.mode = 'faceit' and l.map = m.map
|
||||
and abs(extract(epoch from l.played_at - m.played_at)) < 5400))
|
||||
order by played_at desc
|
||||
limit ${LIMIT}`;
|
||||
const [totals] = await sql<
|
||||
{
|
||||
matches: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
kills: number | null;
|
||||
deaths: number | null;
|
||||
damage: number | null;
|
||||
rounds: number | null;
|
||||
ratingAvg: number | null;
|
||||
placementAvg: number | null;
|
||||
first: string | null;
|
||||
last: string | null;
|
||||
rows: number;
|
||||
}[]
|
||||
>`
|
||||
select coalesce(sum(match_count), 0)::int as matches,
|
||||
coalesce(sum(case when source = 'ubisoft' then score_own when result = 'win' then 1 else 0 end), 0)::int as wins,
|
||||
coalesce(sum(case when source = 'ubisoft' then score_opp when result = 'loss' then 1 else 0 end), 0)::int as losses,
|
||||
sum(kills)::int as kills, sum(deaths)::int as deaths, sum(damage)::int as damage,
|
||||
sum((extra->>'rounds')::int)::int as rounds,
|
||||
avg(rating) filter (where rating_label = 'Leetify rating')::float8 as "ratingAvg",
|
||||
avg(placement)::float8 as "placementAvg",
|
||||
min(played_at) as first, max(played_at) as last, count(*)::int as rows
|
||||
from games.matches where game = ${game}`;
|
||||
const seasons = await sql<
|
||||
{
|
||||
season: string;
|
||||
board: string;
|
||||
rank: string | null;
|
||||
maxRank: string | null;
|
||||
rankPoints: number | null;
|
||||
matches: number | null;
|
||||
wins: number | null;
|
||||
losses: number | null;
|
||||
kills: number | null;
|
||||
deaths: number | null;
|
||||
source: string;
|
||||
}[]
|
||||
>`
|
||||
select source, season, board, rank, max_rank as "maxRank", rank_points as "rankPoints",
|
||||
matches, wins, losses, kills, deaths
|
||||
from games.seasons where game = ${game} and coalesce(matches, 0) > 0
|
||||
order by nullif(regexp_replace(season, '[^0-9]', '', 'g'), '')::numeric desc nulls first, board`;
|
||||
const accounts = await sql<
|
||||
{
|
||||
source: string;
|
||||
name: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastError: string | null;
|
||||
enabled: boolean;
|
||||
matches: number;
|
||||
}[]
|
||||
>`
|
||||
select a.source, a.name, a.last_success_at as "lastSuccessAt", a.last_error as "lastError", a.enabled,
|
||||
(select count(*)::int from games.matches m where m.game = a.game and m.source = a.source) as matches
|
||||
from games.accounts a where a.game = ${game} order by a.id`;
|
||||
const [{ shareCodes }] =
|
||||
game === 'cs2'
|
||||
? await sql<
|
||||
{ shareCodes: number }[]
|
||||
>`select count(*)::int as "shareCodes" from games.cs2_share_codes`
|
||||
: [{ shareCodes: 0 }];
|
||||
const missing = missingKeys(gamesEnv());
|
||||
return {
|
||||
matches: matches.map((m) => ({ ...m, playedAt: new Date(m.playedAt).toISOString() })),
|
||||
truncated: totals.rows > LIMIT,
|
||||
totals: {
|
||||
...totals,
|
||||
first: totals.first ? new Date(totals.first).toISOString() : null,
|
||||
last: totals.last ? new Date(totals.last).toISOString() : null
|
||||
},
|
||||
seasons,
|
||||
shareCodes,
|
||||
sources: accounts.map((a) => ({
|
||||
source: a.source,
|
||||
name: a.name,
|
||||
matches: a.source === 'steam' ? shareCodes : a.matches,
|
||||
lastSuccessAt: a.lastSuccessAt ? new Date(a.lastSuccessAt).toISOString() : null,
|
||||
// Key names and errors only for me; never values.
|
||||
needs: owner ? (missing[a.source] ?? []) : [],
|
||||
waiting: (missing[a.source] ?? []).length > 0,
|
||||
error: owner ? a.lastError : null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export type GameHistory = Awaited<ReturnType<typeof loadHistory>>;
|
||||
60
src/lib/server/games/poller.ts
Normal file
60
src/lib/server/games/poller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
CS2_AUTH_CODE,
|
||||
GAMES_POLLER,
|
||||
LEETIFY_API_KEY,
|
||||
PUBG_API_KEY,
|
||||
STEAM_WEB_API_KEY,
|
||||
UBI_EMAIL,
|
||||
UBI_PASSWORD
|
||||
} from '$app/env/private';
|
||||
import { client } from '#lib/server/db/index.js';
|
||||
import { runIngest } from '#lib/games-ingest/run.js';
|
||||
import type { GameEnv } from '#lib/games-ingest/types.js';
|
||||
|
||||
// Match history poller for CS2 / PUBG / R6 (src/lib/games-ingest), inside the app like the
|
||||
// Spotify poller: it already has the database role and the keys from prod.env. Every 20
|
||||
// minutes; sources without their key are skipped until it is added (then restart the app).
|
||||
// A Postgres advisory lock keeps two containers (mid-deploy) from polling at once.
|
||||
// GAMES_POLLER=off disables it. Backfills: scripts/games-ingest.ts --backfill.
|
||||
const EVERY_MS = 20 * 60_000;
|
||||
const FIRST_MS = 90_000;
|
||||
const LOCK = 7_265_001; // arbitrary, unique to this job
|
||||
|
||||
export const gamesEnv = (): GameEnv => ({
|
||||
PUBG_API_KEY,
|
||||
STEAM_WEB_API_KEY,
|
||||
CS2_AUTH_CODE,
|
||||
LEETIFY_API_KEY,
|
||||
UBI_EMAIL,
|
||||
UBI_PASSWORD
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function tick() {
|
||||
const conn = await client.reserve();
|
||||
try {
|
||||
const [{ locked }] = await conn<
|
||||
{ locked: boolean }[]
|
||||
>`select pg_try_advisory_lock(${LOCK}) as locked`;
|
||||
if (!locked) return;
|
||||
try {
|
||||
const results = await runIngest(conn, gamesEnv(), { log: (m) => console.error(m) });
|
||||
for (const r of results)
|
||||
if (r.status === 'error') console.error(`[games] ${r.source} (${r.account}): ${r.note}`);
|
||||
else if (r.added) console.log(`[games] ${r.source} (${r.account}): +${r.added}`);
|
||||
} finally {
|
||||
await conn`select pg_advisory_unlock(${LOCK})`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[games] poll failed: ${(e as Error).message}`);
|
||||
} finally {
|
||||
conn.release();
|
||||
timer = setTimeout(tick, EVERY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function startGamesPoller() {
|
||||
if (timer || GAMES_POLLER === 'off') return;
|
||||
timer = setTimeout(tick, FIRST_MS);
|
||||
}
|
||||
Reference in New Issue
Block a user