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

This commit is contained in:
2026-10-25 16:00:48 +00:00
parent 9114b5433b
commit 80803ef5ac
24 changed files with 30747 additions and 5 deletions

View 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>>;

View 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);
}